Spec 116: Baseline drift engine v1 (meta fidelity + coverage guard) #141

Merged
ahmido merged 7 commits from 116-baseline-drift-engine into dev 2026-03-02 22:02:59 +00:00
56 changed files with 5180 additions and 270 deletions

View File

@ -39,6 +39,7 @@ ## Active Technologies
- PostgreSQL (existing tables: `workspaces`, `workspace_memberships`, `users`, `audit_logs`) (107-workspace-chooser)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12 (109-review-pack-export)
- PostgreSQL (jsonb columns for summary/options), local filesystem (`exports` disk) for ZIP artifacts (109-review-pack-export)
- PHP 8.4 + Laravel 12, Filament v5, Livewire v4 (116-baseline-drift-engine)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -58,8 +59,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 116-baseline-drift-engine-session-1772451227: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 116-baseline-drift-engine: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 110-ops-ux-enforcement: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
- 109-review-pack-export: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Framework v12
- 109-review-pack-export: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -15,6 +15,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
@ -61,6 +62,15 @@ class BaselineCompareLanding extends Page
public ?string $failureReason = null;
public ?string $coverageStatus = null;
public ?int $uncoveredTypesCount = null;
/** @var list<string>|null */
public ?array $uncoveredTypes = null;
public ?string $fidelity = null;
public static function canAccess(): bool
{
$user = auth()->user();
@ -100,6 +110,11 @@ public function refreshStats(): void
$this->lastComparedAt = $stats->lastComparedHuman;
$this->lastComparedIso = $stats->lastComparedIso;
$this->failureReason = $stats->failureReason;
$this->coverageStatus = $stats->coverageStatus;
$this->uncoveredTypesCount = $stats->uncoveredTypesCount;
$this->uncoveredTypes = $stats->uncoveredTypes !== [] ? $stats->uncoveredTypes : null;
$this->fidelity = $stats->fidelity;
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
@ -125,13 +140,12 @@ protected function getHeaderActions(): array
private function compareNowAction(): Action
{
return Action::make('compareNow')
$action = Action::make('compareNow')
->label('Compare Now')
->icon('heroicon-o-play')
->requiresConfirmation()
->modalHeading('Start baseline comparison')
->modalDescription('This will compare the current tenant inventory against the assigned baseline snapshot and generate drift findings.')
->visible(fn (): bool => $this->canCompare())
->disabled(fn (): bool => ! in_array($this->state, ['idle', 'ready', 'failed'], true))
->action(function (): void {
$user = auth()->user();
@ -181,25 +195,11 @@ private function compareNowAction(): Action
] : [])
->send();
});
}
private function canCompare(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
$resolver = app(CapabilityResolver::class);
return $resolver->can($user, $tenant, Capabilities::TENANT_SYNC);
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::TENANT_SYNC)
->preserveDisabled()
->apply();
}
public function getFindingsUrl(): ?string

View File

@ -13,6 +13,7 @@
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
@ -47,6 +48,17 @@ class DriftLanding extends Page
public ?string $currentFinishedAt = null;
public ?int $baselineCompareRunId = null;
public ?string $baselineCompareCoverageStatus = null;
public ?int $baselineCompareUncoveredTypesCount = null;
/** @var list<string>|null */
public ?array $baselineCompareUncoveredTypes = null;
public ?string $baselineCompareFidelity = null;
public ?int $operationRunId = null;
/** @var array<string, int>|null */
@ -66,6 +78,16 @@ public function mount(): void
abort(403, 'Not allowed');
}
$baselineCompareStats = BaselineCompareStats::forTenant($tenant);
if ($baselineCompareStats->operationRunId !== null) {
$this->baselineCompareRunId = (int) $baselineCompareStats->operationRunId;
$this->baselineCompareCoverageStatus = $baselineCompareStats->coverageStatus;
$this->baselineCompareUncoveredTypesCount = $baselineCompareStats->uncoveredTypesCount;
$this->baselineCompareUncoveredTypes = $baselineCompareStats->uncoveredTypes !== [] ? $baselineCompareStats->uncoveredTypes : null;
$this->baselineCompareFidelity = $baselineCompareStats->fidelity;
}
$latestSuccessful = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'inventory_sync')
@ -292,4 +314,13 @@ public function getOperationRunUrl(): ?string
return OperationRunLinks::view($this->operationRunId, Tenant::current());
}
public function getBaselineCompareRunUrl(): ?string
{
if (! is_int($this->baselineCompareRunId)) {
return null;
}
return OperationRunLinks::view($this->baselineCompareRunId, Tenant::current());
}
}

View File

@ -13,6 +13,7 @@
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -25,6 +26,7 @@
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
@ -38,6 +40,8 @@
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Unique;
use UnitEnum;
class BaselineProfileResource extends Resource
@ -147,39 +151,53 @@ public static function form(Schema $schema): Schema
TextInput::make('name')
->required()
->maxLength(255)
->rule(fn (?BaselineProfile $record): Unique => Rule::unique('baseline_profiles', 'name')
->where('workspace_id', $record?->workspace_id ?? app(WorkspaceContext::class)->currentWorkspaceId(request()))
->ignore($record))
->helperText('A descriptive name for this baseline profile.'),
Textarea::make('description')
->rows(3)
->maxLength(1000)
->helperText('Explain the purpose and scope of this baseline.'),
]),
Section::make('Controls')
->schema([
Select::make('status')
->required()
->options(fn (?BaselineProfile $record): array => self::statusOptionsForRecord($record))
->default(BaselineProfileStatus::Draft->value)
->native(false)
->disabled(fn (?BaselineProfile $record): bool => $record?->status === BaselineProfileStatus::Archived)
->helperText(fn (?BaselineProfile $record): string => match ($record?->status) {
BaselineProfileStatus::Archived => 'Archived baselines cannot be reactivated.',
BaselineProfileStatus::Active => 'Changing status to Archived is permanent.',
default => 'Only active baselines are enforced during compliance checks.',
}),
TextInput::make('version_label')
->label('Version label')
->maxLength(50)
->placeholder('e.g. v2.1 — February rollout')
->helperText('Optional label to identify this version.'),
Select::make('status')
->required()
->options([
BaselineProfile::STATUS_DRAFT => 'Draft',
BaselineProfile::STATUS_ACTIVE => 'Active',
BaselineProfile::STATUS_ARCHIVED => 'Archived',
])
->default(BaselineProfile::STATUS_DRAFT)
->native(false)
->helperText('Only active baselines are enforced during compliance checks.'),
])
->columns(2)
->columnSpanFull(),
Section::make('Scope')
->schema([
Select::make('scope_jsonb.policy_types')
->label('Policy type scope')
->label('Policy types')
->multiple()
->options(self::policyTypeOptions())
->helperText('Leave empty to include all policy types.')
->helperText('Leave empty to include all supported policy types (excluding foundations).')
->native(false),
Select::make('scope_jsonb.foundation_types')
->label('Foundations')
->multiple()
->options(self::foundationTypeOptions())
->helperText('Leave empty to exclude foundations. Select foundations to include them.')
->native(false),
Placeholder::make('metadata')
->label('Last modified')
->content(fn (?BaselineProfile $record): string => $record?->updated_at
? $record->updated_at->diffForHumans()
: '—')
->visible(fn (?BaselineProfile $record): bool => $record !== null),
])
->columnSpanFull(),
->columns(2),
]);
}
@ -207,14 +225,23 @@ public static function infolist(Schema $schema): Schema
Section::make('Scope')
->schema([
TextEntry::make('scope_jsonb.policy_types')
->label('Policy type scope')
->label('Policy types')
->badge()
->formatStateUsing(function (string $state): string {
$options = self::policyTypeOptions();
return $options[$state] ?? $state;
})
->placeholder('All policy types'),
->placeholder('All supported policy types (excluding foundations)'),
TextEntry::make('scope_jsonb.foundation_types')
->label('Foundations')
->badge()
->formatStateUsing(function (string $state): string {
$options = self::foundationTypeOptions();
return $options[$state] ?? $state;
})
->placeholder('None'),
])
->columnSpanFull(),
Section::make('Metadata')
@ -314,7 +341,21 @@ public static function getPages(): array
*/
public static function policyTypeOptions(): array
{
return collect(InventoryPolicyTypeMeta::all())
return collect(InventoryPolicyTypeMeta::supported())
->filter(fn (array $row): bool => filled($row['type'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['type'] => (string) ($row['label'] ?? $row['type']),
])
->sort()
->all();
}
/**
* @return array<string, string>
*/
public static function foundationTypeOptions(): array
{
return collect(InventoryPolicyTypeMeta::foundations())
->filter(fn (array $row): bool => filled($row['type'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['type'] => (string) ($row['label'] ?? $row['type']),
@ -346,6 +387,24 @@ public static function audit(BaselineProfile $record, AuditActionId $actionId, a
);
}
/**
* Status options scoped to valid transitions from the current record state.
*
* @return array<string, string>
*/
private static function statusOptionsForRecord(?BaselineProfile $record): array
{
if ($record === null) {
return [BaselineProfileStatus::Draft->value => BaselineProfileStatus::Draft->label()];
}
$currentStatus = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
return $currentStatus->selectOptions();
}
private static function resolveWorkspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
@ -381,13 +440,13 @@ private static function archiveTableAction(?Workspace $workspace): Action
->requiresConfirmation()
->modalHeading('Archive baseline profile')
->modalDescription('Archiving is permanent in v1. This profile can no longer be used for captures or compares.')
->visible(fn (BaselineProfile $record): bool => $record->status !== BaselineProfile::STATUS_ARCHIVED && self::hasManageCapability())
->hidden(fn (BaselineProfile $record): bool => $record->status === BaselineProfileStatus::Archived)
->action(function (BaselineProfile $record): void {
if (! self::hasManageCapability()) {
throw new AuthorizationException;
}
$record->forceFill(['status' => BaselineProfile::STATUS_ARCHIVED])->save();
$record->forceFill(['status' => BaselineProfileStatus::Archived->value])->save();
self::audit($record, AuditActionId::BaselineProfileArchived, [
'baseline_profile_id' => (int) $record->getKey(),

View File

@ -8,6 +8,7 @@
use App\Models\BaselineProfile;
use App\Models\User;
use App\Support\Audit\AuditActionId;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
@ -28,8 +29,14 @@ protected function mutateFormDataBeforeCreate(array $data): array
$user = auth()->user();
$data['created_by_user_id'] = $user instanceof User ? $user->getKey() : null;
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
if (isset($data['scope_jsonb'])) {
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
$foundationTypes = $data['scope_jsonb']['foundation_types'] ?? [];
$data['scope_jsonb'] = [
'policy_types' => is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
'foundation_types' => is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
];
}
return $data;
}
@ -45,7 +52,9 @@ protected function afterCreate(): void
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileCreated, [
'baseline_profile_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'status' => (string) $record->status,
'status' => $record->status instanceof BaselineProfileStatus
? $record->status->value
: (string) $record->status,
]);
Notification::make()

View File

@ -7,6 +7,7 @@
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Support\Audit\AuditActionId;
use App\Support\Baselines\BaselineProfileStatus;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
@ -14,14 +15,49 @@ class EditBaselineProfile extends EditRecord
{
protected static string $resource = BaselineProfileResource::class;
public function getSubHeading(): string
{
$record = $this->getRecord();
$status = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
return $status->label();
}
public function getSubHeadingBadgeColor(): string
{
$record = $this->getRecord();
$status = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
return $status->color();
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function mutateFormDataBeforeSave(array $data): array
{
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
$data['scope_jsonb'] = ['policy_types' => is_array($policyTypes) ? array_values($policyTypes) : []];
$record = $this->getRecord();
$currentStatus = $record->status instanceof BaselineProfileStatus
? $record->status
: (BaselineProfileStatus::tryFrom((string) $record->status) ?? BaselineProfileStatus::Draft);
if ($currentStatus === BaselineProfileStatus::Archived) {
unset($data['status']);
}
if (isset($data['scope_jsonb'])) {
$policyTypes = $data['scope_jsonb']['policy_types'] ?? [];
$foundationTypes = $data['scope_jsonb']['foundation_types'] ?? [];
$data['scope_jsonb'] = [
'policy_types' => is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
'foundation_types' => is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
];
}
return $data;
}
@ -37,7 +73,9 @@ protected function afterSave(): void
BaselineProfileResource::audit($record, AuditActionId::BaselineProfileUpdated, [
'baseline_profile_id' => (int) $record->getKey(),
'name' => (string) $record->name,
'status' => (string) $record->status,
'status' => $record->status instanceof BaselineProfileStatus
? $record->status->value
: (string) $record->status,
]);
Notification::make()
@ -45,4 +83,9 @@ protected function afterSave(): void
->success()
->send();
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('view', ['record' => $this->getRecord()]);
}
}

View File

@ -14,6 +14,7 @@
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
@ -36,13 +37,10 @@ protected function getHeaderActions(): array
private function captureAction(): Action
{
return Action::make('capture')
$action = Action::make('capture')
->label('Capture Snapshot')
->icon('heroicon-o-camera')
->color('primary')
->visible(fn (): bool => $this->hasManageCapability())
->disabled(fn (): bool => ! $this->hasManageCapability())
->tooltip(fn (): ?string => ! $this->hasManageCapability() ? 'You need manage permission to capture snapshots.' : null)
->requiresConfirmation()
->modalHeading('Capture Baseline Snapshot')
->modalDescription('Select the source tenant whose current inventory will be captured as the baseline snapshot.')
@ -56,13 +54,8 @@ private function captureAction(): Action
->action(function (array $data): void {
$user = auth()->user();
if (! $user instanceof User || ! $this->hasManageCapability()) {
Notification::make()
->title('Permission denied')
->danger()
->send();
return;
if (! $user instanceof User) {
abort(403);
}
/** @var BaselineProfile $profile */
@ -123,6 +116,12 @@ private function captureAction(): Action
->actions([$viewAction])
->send();
});
return WorkspaceUiEnforcement::forTableAction($action, fn (): ?Workspace => Workspace::query()
->whereKey((int) $this->getRecord()->workspace_id)
->first())
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->apply();
}
/**

View File

@ -55,7 +55,8 @@ public function table(Table $table): Table
->sortable(),
])
->headerActions([
$this->assignTenantAction(),
$this->assignTenantAction()
->hidden(fn (): bool => $this->getOwnerRecord()->tenantAssignments()->doesntExist()),
])
->actions([
$this->removeAssignmentAction(),
@ -63,7 +64,7 @@ public function table(Table $table): Table
->emptyStateHeading('No tenants assigned')
->emptyStateDescription('Assign a tenant to compare its state against this baseline profile.')
->emptyStateActions([
$this->assignTenantAction(),
$this->assignTenantAction()->name('assignEmpty'),
]);
}

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\FindingResource\Pages;
use App\Filament\Resources\FindingResource;
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
use App\Jobs\BackfillFindingLifecycleJob;
use App\Models\Finding;
use App\Models\Tenant;
@ -27,6 +28,13 @@ class ListFindings extends ListRecords
{
protected static string $resource = FindingResource::class;
protected function getHeaderWidgets(): array
{
return [
BaselineCompareCoverageBanner::class,
];
}
protected function getHeaderActions(): array
{
$actions = [];

View File

@ -184,6 +184,81 @@ public static function infolist(Schema $schema): Schema
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
->columnSpanFull(),
Section::make('Baseline compare')
->schema([
TextEntry::make('baseline_compare_fidelity')
->label('Fidelity')
->badge()
->getStateUsing(function (OperationRun $record): string {
$context = is_array($record->context) ? $record->context : [];
$fidelity = data_get($context, 'baseline_compare.fidelity');
return is_string($fidelity) && $fidelity !== '' ? $fidelity : 'meta';
}),
TextEntry::make('baseline_compare_coverage_status')
->label('Coverage')
->badge()
->getStateUsing(function (OperationRun $record): string {
$context = is_array($record->context) ? $record->context : [];
$proof = data_get($context, 'baseline_compare.coverage.proof');
$proof = is_bool($proof) ? $proof : null;
$uncovered = data_get($context, 'baseline_compare.coverage.uncovered_types');
$uncovered = is_array($uncovered) ? array_values(array_filter($uncovered, 'is_string')) : [];
return match (true) {
$proof === false => 'unproven',
$uncovered !== [] => 'warnings',
$proof === true => 'ok',
default => 'unknown',
};
})
->color(fn (?string $state): string => match ((string) $state) {
'ok' => 'success',
'warnings', 'unproven' => 'warning',
default => 'gray',
}),
TextEntry::make('baseline_compare_uncovered_types')
->label('Uncovered types')
->getStateUsing(function (OperationRun $record): ?string {
$context = is_array($record->context) ? $record->context : [];
$types = data_get($context, 'baseline_compare.coverage.uncovered_types');
$types = is_array($types) ? array_values(array_filter($types, 'is_string')) : [];
$types = array_values(array_unique(array_filter(array_map('trim', $types), fn (string $type): bool => $type !== '')));
if ($types === []) {
return null;
}
sort($types, SORT_STRING);
return implode(', ', array_slice($types, 0, 12)).(count($types) > 12 ? '…' : '');
})
->visible(function (OperationRun $record): bool {
$context = is_array($record->context) ? $record->context : [];
$types = data_get($context, 'baseline_compare.coverage.uncovered_types');
return is_array($types) && $types !== [];
})
->columnSpanFull(),
TextEntry::make('baseline_compare_inventory_sync_run_id')
->label('Inventory sync run')
->getStateUsing(function (OperationRun $record): ?string {
$context = is_array($record->context) ? $record->context : [];
$syncRunId = data_get($context, 'baseline_compare.inventory_sync_run_id');
return is_numeric($syncRunId) ? '#'.(string) (int) $syncRunId : null;
})
->visible(function (OperationRun $record): bool {
$context = is_array($record->context) ? $record->context : [];
return is_numeric(data_get($context, 'baseline_compare.inventory_sync_run_id'));
}),
])
->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_compare')
->columns(2)
->columnSpanFull(),
Section::make('Verification report')
->schema([
ViewEntry::make('verification_report')

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets\Tenant;
use App\Models\Tenant;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
class BaselineCompareCoverageBanner extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.tenant.baseline-compare-coverage-banner';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [
'shouldShow' => false,
];
}
$stats = BaselineCompareStats::forTenant($tenant);
$uncoveredTypes = $stats->uncoveredTypes ?? [];
$uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : [];
$coverageStatus = $stats->coverageStatus;
$hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== [];
$runUrl = null;
if ($stats->operationRunId !== null) {
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
}
return [
'shouldShow' => $hasWarnings && $runUrl !== null,
'runUrl' => $runUrl,
'coverageStatus' => $coverageStatus,
'fidelity' => $stats->fidelity,
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
'uncoveredTypes' => $uncoveredTypes,
];
}
}

View File

@ -11,8 +11,10 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
@ -45,6 +47,7 @@ public function middleware(): array
public function handle(
BaselineSnapshotIdentity $identity,
InventoryMetaContract $metaContract,
AuditLogger $auditLogger,
OperationRunService $operationRunService,
): void {
@ -78,7 +81,7 @@ public function handle(
$this->auditStarted($auditLogger, $sourceTenant, $profile, $initiator);
$snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity);
$snapshotItems = $this->collectSnapshotItems($sourceTenant, $effectiveScope, $identity, $metaContract);
$identityHash = $identity->computeIdentity($snapshotItems);
@ -90,7 +93,7 @@ public function handle(
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
if ($profile->status === BaselineProfile::STATUS_ACTIVE) {
if ($profile->status === BaselineProfileStatus::Active) {
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
}
@ -127,22 +130,31 @@ private function collectSnapshotItems(
Tenant $sourceTenant,
BaselineScope $scope,
BaselineSnapshotIdentity $identity,
InventoryMetaContract $metaContract,
): array {
$query = InventoryItem::query()
->where('tenant_id', $sourceTenant->getKey());
if (! $scope->isEmpty()) {
$query->whereIn('policy_type', $scope->policyTypes);
}
$query->whereIn('policy_type', $scope->allTypes());
$items = [];
$query->orderBy('policy_type')
->orderBy('external_id')
->chunk(500, function ($inventoryItems) use (&$items, $identity): void {
->chunk(500, function ($inventoryItems) use (&$items, $identity, $metaContract): void {
foreach ($inventoryItems as $inventoryItem) {
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
$baselineHash = $identity->hashItemContent($metaJsonb);
$contract = $metaContract->build(
policyType: (string) $inventoryItem->policy_type,
subjectExternalId: (string) $inventoryItem->external_id,
metaJsonb: $metaJsonb,
);
$baselineHash = $identity->hashItemContent(
policyType: (string) $inventoryItem->policy_type,
subjectExternalId: (string) $inventoryItem->external_id,
metaJsonb: $metaJsonb,
);
$items[] = [
'subject_type' => 'policy',
@ -153,6 +165,13 @@ private function collectSnapshotItems(
'display_name' => $inventoryItem->display_name,
'category' => $inventoryItem->category,
'platform' => $inventoryItem->platform,
'meta_contract' => $contract,
'fidelity' => 'meta',
'source' => 'inventory',
'observed_at' => $inventoryItem->last_seen_at?->toIso8601String(),
'observed_operation_run_id' => is_numeric($inventoryItem->last_seen_operation_run_id)
? (int) $inventoryItem->last_seen_operation_run_id
: null,
],
];
}

View File

@ -15,11 +15,11 @@
use App\Models\Workspace;
use App\Services\Baselines\BaselineAutoCloseService;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver;
use App\Support\Baselines\BaselineScope;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
@ -52,7 +52,6 @@ public function middleware(): array
}
public function handle(
DriftHasher $driftHasher,
BaselineSnapshotIdentity $snapshotIdentity,
AuditLogger $auditLogger,
OperationRunService $operationRunService,
@ -95,13 +94,75 @@ public function handle(
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$effectiveTypes = $effectiveScope->allTypes();
$scopeKey = 'baseline_profile:'.$profile->getKey();
$this->auditStarted($auditLogger, $tenant, $profile, $initiator);
$baselineItems = $this->loadBaselineItems($snapshotId, $effectiveScope);
$latestInventorySyncRunId = $this->resolveLatestInventorySyncRunId($tenant);
$currentItems = $this->loadCurrentInventory($tenant, $effectiveScope, $snapshotIdentity, $latestInventorySyncRunId);
if ($effectiveTypes === []) {
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: null,
coverageProof: false,
effectiveTypes: [],
coveredTypes: [],
uncoveredTypes: [],
errorsRecorded: 1,
);
return;
}
$inventorySyncRun = $this->resolveLatestInventorySyncRun($tenant);
$coverage = $inventorySyncRun instanceof OperationRun
? InventoryCoverage::fromContext($inventorySyncRun->context)
: null;
if (! $inventorySyncRun instanceof OperationRun || ! $coverage instanceof InventoryCoverage) {
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: $inventorySyncRun,
coverageProof: false,
effectiveTypes: $effectiveTypes,
coveredTypes: [],
uncoveredTypes: $effectiveTypes,
errorsRecorded: count($effectiveTypes),
);
return;
}
$coveredTypes = array_values(array_intersect($effectiveTypes, $coverage->coveredTypes()));
$uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes));
if ($coveredTypes === []) {
$this->completeWithCoverageWarning(
operationRunService: $operationRunService,
auditLogger: $auditLogger,
tenant: $tenant,
profile: $profile,
initiator: $initiator,
inventorySyncRun: $inventorySyncRun,
coverageProof: true,
effectiveTypes: $effectiveTypes,
coveredTypes: [],
uncoveredTypes: $effectiveTypes,
errorsRecorded: count($effectiveTypes),
);
return;
}
$baselineItems = $this->loadBaselineItems($snapshotId, $coveredTypes);
$currentItems = $this->loadCurrentInventory($tenant, $coveredTypes, $snapshotIdentity, (int) $inventorySyncRun->getKey());
$driftResults = $this->computeDrift(
$baselineItems,
@ -110,20 +171,22 @@ public function handle(
);
$upsertResult = $this->upsertFindings(
$driftHasher,
$tenant,
$profile,
$snapshotId,
$scopeKey,
$driftResults,
);
$severityBreakdown = $this->countBySeverity($driftResults);
$countsByChangeType = $this->countByChangeType($driftResults);
$summaryCounts = [
'total' => count($driftResults),
'processed' => count($driftResults),
'succeeded' => (int) $upsertResult['processed_count'],
'failed' => 0,
'errors_recorded' => count($uncoveredTypes),
'high' => $severityBreakdown[Finding::SEVERITY_HIGH] ?? 0,
'medium' => $severityBreakdown[Finding::SEVERITY_MEDIUM] ?? 0,
'low' => $severityBreakdown[Finding::SEVERITY_LOW] ?? 0,
@ -135,7 +198,7 @@ public function handle(
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
outcome: $uncoveredTypes !== [] ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value,
summaryCounts: $summaryCounts,
);
@ -160,6 +223,25 @@ public function handle(
}
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['baseline_compare'] = array_merge(
is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [],
[
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
'coverage' => [
'effective_types' => $effectiveTypes,
'covered_types' => $coveredTypes,
'uncovered_types' => $uncoveredTypes,
'proof' => true,
],
'fidelity' => 'meta',
],
);
$updatedContext['findings'] = array_merge(
is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [],
[
'counts_by_change_type' => $countsByChangeType,
],
);
$updatedContext['result'] = [
'findings_total' => count($driftResults),
'findings_upserted' => (int) $upsertResult['processed_count'],
@ -171,21 +253,89 @@ public function handle(
$this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts);
}
private function completeWithCoverageWarning(
OperationRunService $operationRunService,
AuditLogger $auditLogger,
Tenant $tenant,
BaselineProfile $profile,
?User $initiator,
?OperationRun $inventorySyncRun,
bool $coverageProof,
array $effectiveTypes,
array $coveredTypes,
array $uncoveredTypes,
int $errorsRecorded,
): void {
$summaryCounts = [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
'errors_recorded' => max(1, $errorsRecorded),
'high' => 0,
'medium' => 0,
'low' => 0,
'findings_created' => 0,
'findings_reopened' => 0,
'findings_unchanged' => 0,
];
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
summaryCounts: $summaryCounts,
);
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['baseline_compare'] = array_merge(
is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [],
[
'inventory_sync_run_id' => $inventorySyncRun instanceof OperationRun ? (int) $inventorySyncRun->getKey() : null,
'coverage' => [
'effective_types' => array_values($effectiveTypes),
'covered_types' => array_values($coveredTypes),
'uncovered_types' => array_values($uncoveredTypes),
'proof' => $coverageProof,
],
'fidelity' => 'meta',
],
);
$updatedContext['findings'] = array_merge(
is_array($updatedContext['findings'] ?? null) ? $updatedContext['findings'] : [],
[
'counts_by_change_type' => [],
],
);
$updatedContext['result'] = [
'findings_total' => 0,
'findings_upserted' => 0,
'findings_resolved' => 0,
'severity_breakdown' => [],
];
$this->operationRun->update(['context' => $updatedContext]);
$this->auditCompleted($auditLogger, $tenant, $profile, $initiator, $summaryCounts);
}
/**
* Load baseline snapshot items keyed by "policy_type|subject_external_id".
*
* @return array<string, array{subject_type: string, subject_external_id: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>
*/
private function loadBaselineItems(int $snapshotId, BaselineScope $scope): array
private function loadBaselineItems(int $snapshotId, array $policyTypes): array
{
$items = [];
if ($policyTypes === []) {
return $items;
}
$query = BaselineSnapshotItem::query()
->where('baseline_snapshot_id', $snapshotId);
if (! $scope->isEmpty()) {
$query->whereIn('policy_type', $scope->policyTypes);
}
$query->whereIn('policy_type', $policyTypes);
$query
->orderBy('id')
@ -212,7 +362,7 @@ private function loadBaselineItems(int $snapshotId, BaselineScope $scope): array
*/
private function loadCurrentInventory(
Tenant $tenant,
BaselineScope $scope,
array $policyTypes,
BaselineSnapshotIdentity $snapshotIdentity,
?int $latestInventorySyncRunId = null,
): array {
@ -223,10 +373,12 @@ private function loadCurrentInventory(
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
}
if (! $scope->isEmpty()) {
$query->whereIn('policy_type', $scope->policyTypes);
if ($policyTypes === []) {
return [];
}
$query->whereIn('policy_type', $policyTypes);
$items = [];
$query->orderBy('policy_type')
@ -234,7 +386,11 @@ private function loadCurrentInventory(
->chunk(500, function ($inventoryItems) use (&$items, $snapshotIdentity): void {
foreach ($inventoryItems as $inventoryItem) {
$metaJsonb = is_array($inventoryItem->meta_jsonb) ? $inventoryItem->meta_jsonb : [];
$currentHash = $snapshotIdentity->hashItemContent($metaJsonb);
$currentHash = $snapshotIdentity->hashItemContent(
policyType: (string) $inventoryItem->policy_type,
subjectExternalId: (string) $inventoryItem->external_id,
metaJsonb: $metaJsonb,
);
$key = $inventoryItem->policy_type.'|'.$inventoryItem->external_id;
$items[$key] = [
@ -253,7 +409,7 @@ private function loadCurrentInventory(
return $items;
}
private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int
private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
{
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
@ -261,11 +417,9 @@ private function resolveLatestInventorySyncRunId(Tenant $tenant): ?int
->where('status', OperationRunStatus::Completed->value)
->orderByDesc('completed_at')
->orderByDesc('id')
->first(['id']);
->first();
$runId = $run?->getKey();
return is_numeric($runId) ? (int) $runId : null;
return $run instanceof OperationRun ? $run : null;
}
/**
@ -351,9 +505,9 @@ private function computeDrift(array $baselineItems, array $currentItems, array $
* @return array{processed_count: int, created_count: int, reopened_count: int, unchanged_count: int, seen_fingerprints: array<int, string>}
*/
private function upsertFindings(
DriftHasher $driftHasher,
Tenant $tenant,
BaselineProfile $profile,
int $baselineSnapshotId,
string $scopeKey,
array $driftResults,
): array {
@ -366,16 +520,16 @@ private function upsertFindings(
$seenFingerprints = [];
foreach ($driftResults as $driftItem) {
$fingerprint = $driftHasher->fingerprint(
$recurrenceKey = $this->recurrenceKey(
tenantId: $tenantId,
scopeKey: $scopeKey,
subjectType: $driftItem['subject_type'],
baselineSnapshotId: $baselineSnapshotId,
policyType: $driftItem['policy_type'],
subjectExternalId: $driftItem['subject_external_id'],
changeType: $driftItem['change_type'],
baselineHash: $driftItem['baseline_hash'],
currentHash: $driftItem['current_hash'],
);
$fingerprint = $recurrenceKey;
$seenFingerprints[] = $fingerprint;
$finding = Finding::query()
@ -404,6 +558,7 @@ private function upsertFindings(
'subject_external_id' => $driftItem['subject_external_id'],
'severity' => $driftItem['severity'],
'fingerprint' => $fingerprint,
'recurrence_key' => $recurrenceKey,
'evidence_jsonb' => $driftItem['evidence'],
'baseline_operation_run_id' => null,
'current_operation_run_id' => (int) $this->operationRun->getKey(),
@ -471,6 +626,55 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i
}
}
/**
* Stable identity for baseline-compare findings, scoped to a baseline snapshot.
*/
private function recurrenceKey(
int $tenantId,
int $baselineSnapshotId,
string $policyType,
string $subjectExternalId,
string $changeType,
): string {
$parts = [
(string) $tenantId,
(string) $baselineSnapshotId,
$this->normalizeKeyPart($policyType),
$this->normalizeKeyPart($subjectExternalId),
$this->normalizeKeyPart($changeType),
];
return hash('sha256', implode('|', $parts));
}
private function normalizeKeyPart(string $value): string
{
return trim(mb_strtolower($value));
}
/**
* @param array<int, array{change_type: string}> $driftResults
* @return array<string, int>
*/
private function countByChangeType(array $driftResults): array
{
$counts = [];
foreach ($driftResults as $item) {
$changeType = (string) ($item['change_type'] ?? '');
if ($changeType === '') {
continue;
}
$counts[$changeType] = ($counts[$changeType] ?? 0) + 1;
}
ksort($counts);
return $counts;
}
/**
* @param array<int, array{severity: string}> $driftResults
* @return array<string, int>

View File

@ -9,6 +9,8 @@
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService;
use App\Support\Inventory\InventoryCoverage;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes;
@ -70,8 +72,20 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$policyTypes = $context['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : [];
$includeFoundations = (bool) ($context['include_foundations'] ?? false);
$foundationTypes = collect(InventoryPolicyTypeMeta::foundations())
->map(fn (array $row): mixed => $row['type'] ?? null)
->filter(fn (mixed $type): bool => is_string($type) && $type !== '')
->values()
->all();
$attemptedTypes = $includeFoundations
? array_values(array_unique(array_merge($policyTypes, $foundationTypes)))
: array_values(array_diff($policyTypes, $foundationTypes));
$processedPolicyTypes = [];
$coverageStatusByType = [];
$successCount = 0;
$failedCount = 0;
@ -84,8 +98,11 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
$this->operationRun,
$tenant,
$context,
function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$successCount, &$failedCount): void {
function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$coverageStatusByType, &$successCount, &$failedCount): void {
$processedPolicyTypes[] = $policyType;
$coverageStatusByType[$policyType] = $success
? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed;
if ($success) {
$successCount++;
@ -97,7 +114,37 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
},
);
$statusByType = [];
foreach ($attemptedTypes as $type) {
if (! is_string($type) || $type === '') {
continue;
}
$statusByType[$type] = InventoryCoverage::StatusSkipped;
}
foreach ($coverageStatusByType as $type => $status) {
if (! is_string($type) || $type === '') {
continue;
}
$statusByType[$type] = $status;
}
if ((string) ($result['status'] ?? '') === 'skipped') {
foreach ($statusByType as $type => $status) {
$statusByType[$type] = InventoryCoverage::StatusSkipped;
}
}
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['inventory'] = array_merge(
is_array($updatedContext['inventory'] ?? null) ? $updatedContext['inventory'] : [],
[
'coverage' => InventoryCoverage::buildPayload($statusByType, $foundationTypes),
],
);
$updatedContext['result'] = [
'had_errors' => (bool) ($result['had_errors'] ?? true),
'error_codes' => is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [],

View File

@ -1,28 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineScope;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use JsonException;
class BaselineProfile extends Model
{
use HasFactory;
/**
* @deprecated Use BaselineProfileStatus::Draft instead.
*/
public const string STATUS_DRAFT = 'draft';
/**
* @deprecated Use BaselineProfileStatus::Active instead.
*/
public const string STATUS_ACTIVE = 'active';
/**
* @deprecated Use BaselineProfileStatus::Archived instead.
*/
public const string STATUS_ARCHIVED = 'archived';
protected $guarded = [];
protected $casts = [
'scope_jsonb' => 'array',
/** @var list<string> */
protected $fillable = [
'workspace_id',
'name',
'description',
'version_label',
'status',
'scope_jsonb',
'active_snapshot_id',
'created_by_user_id',
];
/**
* @return array<string, mixed>
*/
protected function casts(): array
{
return [
'status' => BaselineProfileStatus::class,
];
}
protected function scopeJsonb(): Attribute
{
return Attribute::make(
get: function (mixed $value): array {
return BaselineScope::fromJsonb(
$this->decodeScopeJsonb($value)
)->toJsonb();
},
set: function (mixed $value): string {
$scope = BaselineScope::fromJsonb(is_array($value) ? $value : null)->toJsonb();
try {
return json_encode($scope, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return '{"policy_types":[],"foundation_types":[]}';
}
},
);
}
/**
* Decode raw scope_jsonb value from the database.
*
* @return array<string, mixed>|null
*/
private function decodeScopeJsonb(mixed $value): ?array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
try {
$decoded = json_decode($value, true, flags: JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : null;
} catch (JsonException) {
return null;
}
}
return null;
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);

View File

@ -10,6 +10,7 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunType;
@ -41,7 +42,7 @@ public function startCapture(
$context = [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $sourceTenant->getKey(),
'effective_scope' => $effectiveScope->toJsonb(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
];
$run = $this->runs->ensureRunWithIdentity(
@ -63,7 +64,7 @@ public function startCapture(
private function validatePreconditions(BaselineProfile $profile, Tenant $sourceTenant): ?string
{
if ($profile->status !== BaselineProfile::STATUS_ACTIVE) {
if ($profile->status !== BaselineProfileStatus::Active) {
return BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE;
}

View File

@ -6,11 +6,13 @@
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\OperationRunType;
@ -27,6 +29,7 @@ public function __construct(
public function startCompare(
Tenant $tenant,
User $initiator,
?int $baselineSnapshotId = null,
): array {
$assignment = BaselineTenantAssignment::query()
->where('workspace_id', $tenant->workspace_id)
@ -43,13 +46,28 @@ public function startCompare(
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
}
$precondition = $this->validatePreconditions($profile);
$hasExplicitSnapshotSelection = is_int($baselineSnapshotId) && $baselineSnapshotId > 0;
$precondition = $this->validatePreconditions($profile, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection);
if ($precondition !== null) {
return ['ok' => false, 'reason_code' => $precondition];
}
$snapshotId = (int) $profile->active_snapshot_id;
$snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0;
if ($snapshotId > 0) {
$snapshot = BaselineSnapshot::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('baseline_profile_id', (int) $profile->getKey())
->whereKey($snapshotId)
->first(['id']);
if (! $snapshot instanceof BaselineSnapshot) {
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT];
}
} else {
$snapshotId = (int) $profile->active_snapshot_id;
}
$profileScope = BaselineScope::fromJsonb(
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
@ -63,7 +81,7 @@ public function startCompare(
$context = [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => $snapshotId,
'effective_scope' => $effectiveScope->toJsonb(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
];
$run = $this->runs->ensureRunWithIdentity(
@ -83,13 +101,13 @@ public function startCompare(
return ['ok' => true, 'run' => $run];
}
private function validatePreconditions(BaselineProfile $profile): ?string
private function validatePreconditions(BaselineProfile $profile, bool $hasExplicitSnapshotSelection = false): ?string
{
if ($profile->status !== BaselineProfile::STATUS_ACTIVE) {
if ($profile->status !== BaselineProfileStatus::Active) {
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
}
if ($profile->active_snapshot_id === null) {
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
}

View File

@ -16,6 +16,7 @@ final class BaselineSnapshotIdentity
{
public function __construct(
private readonly DriftHasher $hasher,
private readonly InventoryMetaContract $metaContract,
) {}
/**
@ -50,10 +51,18 @@ public function computeIdentity(array $items): string
/**
* Compute a stable content hash for a single inventory item's metadata.
*
* Strips volatile OData keys and normalizes for stable comparison.
* Hashes ONLY the Spec 116 meta contract output (not the full meta_jsonb payload).
*
* @param array<string, mixed> $metaJsonb
*/
public function hashItemContent(mixed $metaJsonb): string
public function hashItemContent(string $policyType, string $subjectExternalId, array $metaJsonb): string
{
return $this->hasher->hashNormalized($metaJsonb);
$contract = $this->metaContract->build(
policyType: $policyType,
subjectExternalId: $subjectExternalId,
metaJsonb: $metaJsonb,
);
return $this->hasher->hashNormalized($contract);
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Services\Baselines;
final class InventoryMetaContract
{
public const int VERSION = 1;
/**
* Build the v1 meta contract for hashing and auditability.
*
* Missing signals MUST be represented as null (not omitted) to keep the hash stable across partial inventories.
*
* @param array<string, mixed> $metaJsonb
* @return array{
* version: int,
* policy_type: string,
* subject_external_id: string,
* odata_type: ?string,
* etag: ?string,
* scope_tag_ids: ?list<string>,
* assignment_target_count: ?int
* }
*/
public function build(string $policyType, string $subjectExternalId, array $metaJsonb): array
{
$odataType = $metaJsonb['odata_type'] ?? null;
$odataType = is_string($odataType) ? trim($odataType) : null;
$odataType = $odataType !== '' ? $odataType : null;
$etag = $metaJsonb['etag'] ?? null;
$etag = is_string($etag) ? trim($etag) : null;
$etag = $etag !== '' ? $etag : null;
$scopeTagIds = $metaJsonb['scope_tag_ids'] ?? null;
$scopeTagIds = is_array($scopeTagIds) ? $this->normalizeStringList($scopeTagIds) : null;
$assignmentTargetCount = $metaJsonb['assignment_target_count'] ?? null;
$assignmentTargetCount = is_numeric($assignmentTargetCount) ? (int) $assignmentTargetCount : null;
return [
'version' => self::VERSION,
'policy_type' => trim($policyType),
'subject_external_id' => trim($subjectExternalId),
'odata_type' => $odataType,
'etag' => $etag,
'scope_tag_ids' => $scopeTagIds,
'assignment_target_count' => $assignmentTargetCount,
];
}
/**
* @param array<int, mixed> $values
* @return list<string>
*/
private function normalizeStringList(array $values): array
{
$values = array_values(array_filter($values, 'is_string'));
$values = array_map('trim', $values);
$values = array_values(array_filter($values, fn (string $value): bool => $value !== ''));
$values = array_values(array_unique($values));
sort($values, SORT_STRING);
return $values;
}
}

View File

@ -11,6 +11,7 @@
use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderGateway;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
@ -62,16 +63,41 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
'started_at' => now(),
]);
$result = $this->executeSelection($operationRun, $tenant, $normalizedSelection);
$policyTypes = $normalizedSelection['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? $policyTypes : [];
$foundationTypes = $this->foundationTypes();
$includeFoundations = (bool) ($normalizedSelection['include_foundations'] ?? false);
$attemptedTypes = $includeFoundations
? array_values(array_unique(array_merge($policyTypes, $foundationTypes)))
: array_values(array_diff($policyTypes, $foundationTypes));
$statusByType = [];
foreach ($attemptedTypes as $type) {
if (! is_string($type) || $type === '') {
continue;
}
$statusByType[$type] = InventoryCoverage::StatusSkipped;
}
$result = $this->executeSelection(
$operationRun,
$tenant,
$normalizedSelection,
function (string $policyType, bool $success, ?string $errorCode) use (&$statusByType): void {
$statusByType[$policyType] = $success
? InventoryCoverage::StatusSucceeded
: InventoryCoverage::StatusFailed;
},
);
$status = (string) ($result['status'] ?? 'failed');
$hadErrors = (bool) ($result['had_errors'] ?? true);
$errorCodes = is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [];
$errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null;
$policyTypes = $normalizedSelection['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? $policyTypes : [];
$operationOutcome = match ($status) {
'success' => OperationRunOutcome::Succeeded->value,
'partial' => OperationRunOutcome::PartiallySucceeded->value,
@ -95,6 +121,24 @@ public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
}
$updatedContext = is_array($operationRun->context) ? $operationRun->context : [];
$coverageStatusByType = $statusByType;
if ($status === 'skipped') {
foreach ($coverageStatusByType as $type => $coverageStatus) {
$coverageStatusByType[$type] = InventoryCoverage::StatusSkipped;
}
}
$updatedContext['inventory'] = array_merge(
is_array($updatedContext['inventory'] ?? null) ? $updatedContext['inventory'] : [],
[
'coverage' => InventoryCoverage::buildPayload(
statusByType: $coverageStatusByType,
foundationTypes: $foundationTypes,
),
],
);
$updatedContext['result'] = [
'had_errors' => $hadErrors,
'error_codes' => $errorCodes,

View File

@ -4,22 +4,22 @@
namespace App\Support\Badges\Domains;
use App\Models\BaselineProfile;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Baselines\BaselineProfileStatus;
final class BaselineProfileStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
$enum = BaselineProfileStatus::tryFrom($state);
return match ($state) {
BaselineProfile::STATUS_DRAFT => new BadgeSpec('Draft', 'gray', 'heroicon-m-pencil-square'),
BaselineProfile::STATUS_ACTIVE => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
BaselineProfile::STATUS_ARCHIVED => new BadgeSpec('Archived', 'warning', 'heroicon-m-archive-box'),
default => BadgeSpec::unknown(),
};
if ($enum === null) {
return BadgeSpec::unknown();
}
return new BadgeSpec($enum->label(), $enum->color(), $enum->icon());
}
}

View File

@ -14,6 +14,7 @@ final class BaselineCompareStats
{
/**
* @param array<string, int> $severityCounts
* @param list<string> $uncoveredTypes
*/
private function __construct(
public readonly string $state,
@ -27,6 +28,10 @@ private function __construct(
public readonly ?string $lastComparedHuman,
public readonly ?string $lastComparedIso,
public readonly ?string $failureReason,
public readonly ?string $coverageStatus = null,
public readonly ?int $uncoveredTypesCount = null,
public readonly array $uncoveredTypes = [],
public readonly ?string $fidelity = null,
) {}
public static function forTenant(?Tenant $tenant): self
@ -74,6 +79,8 @@ public static function forTenant(?Tenant $tenant): self
->latest('id')
->first();
[$coverageStatus, $uncoveredTypes, $fidelity] = self::coverageInfoForRun($latestRun);
// Active run (queued/running)
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
return new self(
@ -88,6 +95,10 @@ public static function forTenant(?Tenant $tenant): self
lastComparedHuman: null,
lastComparedIso: null,
failureReason: null,
coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes,
fidelity: $fidelity,
);
}
@ -110,6 +121,10 @@ public static function forTenant(?Tenant $tenant): self
lastComparedHuman: $latestRun->finished_at?->diffForHumans(),
lastComparedIso: $latestRun->finished_at?->toIso8601String(),
failureReason: (string) $failureReason,
coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes,
fidelity: $fidelity,
);
}
@ -154,13 +169,19 @@ public static function forTenant(?Tenant $tenant): self
lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso,
failureReason: null,
coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes,
fidelity: $fidelity,
);
}
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && in_array($latestRun->outcome, ['succeeded', 'partially_succeeded'], true)) {
return new self(
state: 'ready',
message: 'No open drift findings for this baseline comparison. The tenant matches the baseline.',
message: $latestRun->outcome === 'succeeded'
? 'No open drift findings for this baseline comparison. The tenant matches the baseline.'
: 'Comparison completed with warnings. Findings may be incomplete due to missing coverage.',
profileName: $profileName,
profileId: $profileId,
snapshotId: $snapshotId,
@ -170,6 +191,10 @@ public static function forTenant(?Tenant $tenant): self
lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso,
failureReason: null,
coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes,
fidelity: $fidelity,
);
}
@ -185,6 +210,10 @@ public static function forTenant(?Tenant $tenant): self
lastComparedHuman: $lastComparedHuman,
lastComparedIso: $lastComparedIso,
failureReason: null,
coverageStatus: $coverageStatus,
uncoveredTypesCount: $uncoveredTypes !== [] ? count($uncoveredTypes) : 0,
uncoveredTypes: $uncoveredTypes,
fidelity: $fidelity,
);
}
@ -248,6 +277,50 @@ public static function forWidget(?Tenant $tenant): self
);
}
/**
* @return array{0: ?string, 1: list<string>, 2: ?string}
*/
private static function coverageInfoForRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun) {
return [null, [], null];
}
$context = is_array($run->context) ? $run->context : [];
$baselineCompare = $context['baseline_compare'] ?? null;
if (! is_array($baselineCompare)) {
return [null, [], null];
}
$coverage = $baselineCompare['coverage'] ?? null;
$coverage = is_array($coverage) ? $coverage : [];
$proof = $coverage['proof'] ?? null;
$proof = is_bool($proof) ? $proof : null;
$uncoveredTypes = $coverage['uncovered_types'] ?? null;
$uncoveredTypes = is_array($uncoveredTypes) ? array_values(array_filter($uncoveredTypes, 'is_string')) : [];
$uncoveredTypes = array_values(array_unique(array_filter(array_map('trim', $uncoveredTypes), fn (string $type): bool => $type !== '')));
sort($uncoveredTypes, SORT_STRING);
$coverageStatus = null;
if ($proof === false) {
$coverageStatus = 'unproven';
} elseif ($uncoveredTypes !== []) {
$coverageStatus = 'warning';
} elseif ($proof === true) {
$coverageStatus = 'ok';
}
$fidelity = $baselineCompare['fidelity'] ?? null;
$fidelity = is_string($fidelity) ? trim($fidelity) : null;
$fidelity = $fidelity !== '' ? $fidelity : null;
return [$coverageStatus, $uncoveredTypes, $fidelity];
}
private static function empty(
string $state,
?string $message,

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum BaselineProfileStatus: string
{
case Draft = 'draft';
case Active = 'active';
case Archived = 'archived';
public function label(): string
{
return match ($this) {
self::Draft => 'Draft',
self::Active => 'Active',
self::Archived => 'Archived',
};
}
/**
* Filament badge color for this status.
*/
public function color(): string
{
return match ($this) {
self::Draft => 'gray',
self::Active => 'success',
self::Archived => 'warning',
};
}
/**
* Heroicon identifier for this status.
*/
public function icon(): string
{
return match ($this) {
self::Draft => 'heroicon-m-pencil-square',
self::Active => 'heroicon-m-check-circle',
self::Archived => 'heroicon-m-archive-box',
};
}
/**
* Whether this status allows editing the profile.
*/
public function isEditable(): bool
{
return $this !== self::Archived;
}
/**
* Allowed transitions from this status.
*
* @return array<self>
*/
public function allowedTransitions(): array
{
return match ($this) {
self::Draft => [self::Draft, self::Active],
self::Active => [self::Active, self::Archived],
self::Archived => [self::Archived],
};
}
/**
* Status options for a Filament Select field, scoped to allowed transitions.
*
* @return array<string, string>
*/
public function selectOptions(): array
{
return collect($this->allowedTransitions())
->mapWithKeys(fn (self $s): array => [$s->value => $s->label()])
->all();
}
}

View File

@ -21,4 +21,6 @@ final class BaselineReasonCodes
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_INVALID_SNAPSHOT = 'baseline.compare.invalid_snapshot';
}

View File

@ -8,15 +8,20 @@
* Value object for baseline scope resolution.
*
* A scope defines which policy types are included in a baseline profile.
* An empty policy_types array means "all types" (no filter).
*
* Spec 116 semantics:
* - Empty policy_types means "all supported policy types" (excluding foundations).
* - Empty foundation_types means "none".
*/
final class BaselineScope
{
/**
* @param array<string> $policyTypes
* @param array<string> $foundationTypes
*/
public function __construct(
public readonly array $policyTypes = [],
public readonly array $foundationTypes = [],
) {}
/**
@ -31,9 +36,11 @@ public static function fromJsonb(?array $scopeJsonb): self
}
$policyTypes = $scopeJsonb['policy_types'] ?? [];
$foundationTypes = $scopeJsonb['foundation_types'] ?? [];
return new self(
policyTypes: is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
foundationTypes: is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
);
}
@ -41,64 +48,69 @@ public static function fromJsonb(?array $scopeJsonb): self
* Normalize the effective scope by intersecting profile scope with an optional override.
*
* Override can only narrow the profile scope (subset enforcement).
* If the profile scope is empty (all types), the override becomes the effective scope.
* If the override is empty or null, the profile scope is used as-is.
* Empty override means "no override".
*/
public static function effective(self $profileScope, ?self $overrideScope): self
{
$profileScope = $profileScope->expandDefaults();
if ($overrideScope === null || $overrideScope->isEmpty()) {
return $profileScope;
}
if ($profileScope->isEmpty()) {
return $overrideScope;
}
$overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes);
$overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes);
$intersected = array_values(array_intersect($profileScope->policyTypes, $overrideScope->policyTypes));
$effectivePolicyTypes = $overridePolicyTypes !== []
? array_values(array_intersect($profileScope->policyTypes, $overridePolicyTypes))
: $profileScope->policyTypes;
return new self(policyTypes: $intersected);
$effectiveFoundationTypes = $overrideFoundationTypes !== []
? array_values(array_intersect($profileScope->foundationTypes, $overrideFoundationTypes))
: $profileScope->foundationTypes;
return new self(
policyTypes: self::uniqueSorted($effectivePolicyTypes),
foundationTypes: self::uniqueSorted($effectiveFoundationTypes),
);
}
/**
* An empty scope means "all types".
* An empty scope means "no override" (for override_scope semantics).
*/
public function isEmpty(): bool
{
return $this->policyTypes === [];
return $this->policyTypes === [] && $this->foundationTypes === [];
}
/**
* Check if a policy type is included in this scope.
* Apply Spec 116 defaults and filter to supported types.
*/
public function includes(string $policyType): bool
public function expandDefaults(): self
{
if ($this->isEmpty()) {
return true;
}
$policyTypes = $this->policyTypes === []
? self::supportedPolicyTypes()
: self::normalizePolicyTypes($this->policyTypes);
return in_array($policyType, $this->policyTypes, true);
$foundationTypes = self::normalizeFoundationTypes($this->foundationTypes);
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
);
}
/**
* Validate that override is a subset of the profile scope.
* @return list<string>
*/
public static function isValidOverride(self $profileScope, self $overrideScope): bool
public function allTypes(): array
{
if ($overrideScope->isEmpty()) {
return true;
}
$expanded = $this->expandDefaults();
if ($profileScope->isEmpty()) {
return true;
}
foreach ($overrideScope->policyTypes as $type) {
if (! in_array($type, $profileScope->policyTypes, true)) {
return false;
}
}
return true;
return self::uniqueSorted(array_merge(
$expanded->policyTypes,
$expanded->foundationTypes,
));
}
/**
@ -108,6 +120,102 @@ public function toJsonb(): array
{
return [
'policy_types' => $this->policyTypes,
'foundation_types' => $this->foundationTypes,
];
}
/**
* Effective scope payload for OperationRun.context.
*
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
*/
public function toEffectiveScopeContext(): array
{
$expanded = $this->expandDefaults();
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
return [
'policy_types' => $expanded->policyTypes,
'foundation_types' => $expanded->foundationTypes,
'all_types' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [],
];
}
/**
* @return list<string>
*/
private static function supportedPolicyTypes(): array
{
$supported = config('tenantpilot.supported_policy_types', []);
if (! is_array($supported)) {
return [];
}
$types = collect($supported)
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
}
/**
* @return list<string>
*/
private static function supportedFoundationTypes(): array
{
$foundations = config('tenantpilot.foundation_types', []);
if (! is_array($foundations)) {
return [];
}
$types = collect($foundations)
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizePolicyTypes(array $types): array
{
$supported = self::supportedPolicyTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizeFoundationTypes(array $types): array
{
$supported = self::supportedFoundationTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<int, string> $types
* @return list<string>
*/
private static function uniqueSorted(array $types): array
{
$types = array_values(array_unique(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== '')));
sort($types, SORT_STRING);
return $types;
}
}

View File

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Support\Inventory;
final readonly class InventoryCoverage
{
public const string StatusSucceeded = 'succeeded';
public const string StatusFailed = 'failed';
public const string StatusSkipped = 'skipped';
/**
* @param array<string, array{status: string, item_count?: int, error_code?: string|null}> $policyTypes
* @param array<string, array{status: string, item_count?: int, error_code?: string|null}> $foundationTypes
*/
public function __construct(
public array $policyTypes,
public array $foundationTypes,
) {}
public static function fromContext(mixed $context): ?self
{
if (! is_array($context)) {
return null;
}
$inventory = $context['inventory'] ?? null;
if (! is_array($inventory)) {
return null;
}
$coverage = $inventory['coverage'] ?? null;
if (! is_array($coverage)) {
return null;
}
$policyTypes = self::normalizeCoverageMap($coverage['policy_types'] ?? null);
$foundationTypes = self::normalizeCoverageMap($coverage['foundation_types'] ?? null);
if ($policyTypes === [] && $foundationTypes === []) {
return null;
}
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
);
}
/**
* @return list<string>
*/
public function coveredTypes(): array
{
$covered = [];
foreach (array_merge($this->policyTypes, $this->foundationTypes) as $type => $meta) {
if (($meta['status'] ?? null) === self::StatusSucceeded) {
$covered[] = $type;
}
}
sort($covered, SORT_STRING);
return array_values(array_unique($covered));
}
/**
* Build the canonical `inventory.coverage.*` payload for OperationRun.context.
*
* @param array<string, string> $statusByType
* @param list<string> $foundationTypes
* @return array{policy_types: array<string, array{status: string}>, foundation_types: array<string, array{status: string}>}
*/
public static function buildPayload(array $statusByType, array $foundationTypes): array
{
$foundationTypes = array_values(array_unique(array_filter($foundationTypes, fn (mixed $type): bool => is_string($type) && $type !== '')));
$foundationLookup = array_fill_keys($foundationTypes, true);
$policy = [];
$foundations = [];
foreach ($statusByType as $type => $status) {
if (! is_string($type) || $type === '') {
continue;
}
$normalizedStatus = self::normalizeStatus($status);
if ($normalizedStatus === null) {
continue;
}
$row = ['status' => $normalizedStatus];
if (array_key_exists($type, $foundationLookup)) {
$foundations[$type] = $row;
continue;
}
$policy[$type] = $row;
}
ksort($policy);
ksort($foundations);
return [
'policy_types' => $policy,
'foundation_types' => $foundations,
];
}
private static function normalizeStatus(mixed $status): ?string
{
if (! is_string($status)) {
return null;
}
return match ($status) {
self::StatusSucceeded, self::StatusFailed, self::StatusSkipped => $status,
default => null,
};
}
/**
* @return array<string, array{status: string, item_count?: int, error_code?: string|null}>
*/
private static function normalizeCoverageMap(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $type => $meta) {
if (! is_string($type) || $type === '') {
continue;
}
if (! is_array($meta)) {
continue;
}
$status = self::normalizeStatus($meta['status'] ?? null);
if ($status === null) {
continue;
}
$row = ['status' => $status];
if (array_key_exists('item_count', $meta) && is_int($meta['item_count'])) {
$row['item_count'] = $meta['item_count'];
}
if (array_key_exists('error_code', $meta) && (is_string($meta['error_code']) || $meta['error_code'] === null)) {
$row['error_code'] = $meta['error_code'];
}
$normalized[$type] = $row;
}
ksort($normalized);
return $normalized;
}
}

View File

@ -50,6 +50,8 @@ final class UiEnforcement
private bool $preserveExistingVisibility = false;
private bool $preserveExistingDisabled = false;
private function __construct(Action|BulkAction $action)
{
$this->action = $action;
@ -167,6 +169,21 @@ public function preserveVisibility(): self
return $this;
}
/**
* Preserve the action's existing disabled logic.
*
* Use this when the action is disabled for business reasons (e.g. operation
* state) and you still want UiEnforcement to add capability gating on top.
*
* @return $this
*/
public function preserveDisabled(): self
{
$this->preserveExistingDisabled = true;
return $this;
}
/**
* Apply all enforcement rules to the action and return it.
*
@ -316,9 +333,17 @@ private function applyDisabledState(): void
return;
}
$existingDisabled = $this->preserveExistingDisabled
? $this->getExistingDisabledCondition()
: null;
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
$this->action->disabled(function (?Model $record = null) {
$this->action->disabled(function (?Model $record = null) use ($existingDisabled) {
if ($existingDisabled !== null && $this->evaluateDisabledCondition($existingDisabled, $record)) {
return true;
}
if ($this->isBulk && $this->action instanceof BulkAction) {
$user = auth()->user();
@ -371,6 +396,62 @@ private function applyDisabledState(): void
});
}
/**
* Attempt to retrieve the existing disabled condition from the action.
*
* Filament stores this as the protected property `$isDisabled` (bool|Closure)
* on actions via the CanBeDisabled concern.
*/
private function getExistingDisabledCondition(): bool|Closure|null
{
try {
$ref = new ReflectionObject($this->action);
if (! $ref->hasProperty('isDisabled')) {
return null;
}
$property = $ref->getProperty('isDisabled');
$property->setAccessible(true);
/** @var bool|Closure $value */
$value = $property->getValue($this->action);
return $value;
} catch (Throwable) {
return null;
}
}
/**
* Evaluate an existing bool|Closure disabled condition.
*
* This is a best-effort evaluator for business disabled closures.
* If the closure cannot be evaluated safely, we fail closed (return true).
*/
private function evaluateDisabledCondition(bool|Closure $condition, ?Model $record): bool
{
if (is_bool($condition)) {
return $condition;
}
try {
$reflection = new \ReflectionFunction($condition);
$parameters = $reflection->getParameters();
if ($parameters === []) {
return (bool) $condition();
}
if ($record === null) {
return true;
}
return (bool) $condition($record);
} catch (Throwable) {
return true;
}
}
/**
* Add confirmation modal for destructive actions.
*/

View File

@ -5,6 +5,7 @@
use App\Models\BaselineProfile;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Baselines\BaselineProfileStatus;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@ -24,8 +25,8 @@ public function definition(): array
'name' => fake()->unique()->words(3, true),
'description' => fake()->optional()->sentence(),
'version_label' => fake()->optional()->numerify('v#.#'),
'status' => BaselineProfile::STATUS_DRAFT,
'scope_jsonb' => ['policy_types' => []],
'status' => BaselineProfileStatus::Draft->value,
'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
'active_snapshot_id' => null,
'created_by_user_id' => null,
];
@ -34,21 +35,21 @@ public function definition(): array
public function active(): static
{
return $this->state(fn (): array => [
'status' => BaselineProfile::STATUS_ACTIVE,
'status' => BaselineProfileStatus::Active->value,
]);
}
public function archived(): static
{
return $this->state(fn (): array => [
'status' => BaselineProfile::STATUS_ARCHIVED,
'status' => BaselineProfileStatus::Archived->value,
]);
}
public function withScope(array $policyTypes): static
{
return $this->state(fn (): array => [
'scope_jsonb' => ['policy_types' => $policyTypes],
'scope_jsonb' => ['policy_types' => $policyTypes, 'foundation_types' => []],
]);
}

View File

@ -0,0 +1,664 @@
# Golden Master / Baseline Drift — Deep Settings-Drift (Content-Fidelity) Analysis
> Enterprise Research Report for TenantAtlas / TenantPilot
> Date: 2025-07-15
> Scope: Architecture, code evidence, implementation proposal
---
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [System Map — Side-by-Side Comparison](#2-system-map)
3. [Architecture Decision Record (ADR-001): Unify vs Separate](#3-adr-001)
4. [Deep-Dive: Why Settings Changes Don't Produce Baseline Drift](#4-deep-dive)
5. [Code Evidence Table](#5-code-evidence)
6. [Type Coverage Matrix](#6-type-coverage-matrix)
7. [Proposal: Deep Drift Implementation Plan](#7-deep-drift-plan)
8. [Test Plan (Enterprise)](#8-test-plan)
9. [Open Questions / Assumptions](#9-open-questions)
10. [Key Questions Answered (KQ-01 through KQ-06)](#10-key-questions)
---
## 1. Executive Summary
1. **Two parallel drift systems exist**: *Baseline Compare* (meta fidelity, inventory-sourced) and *Backup Drift* (content fidelity, PolicyVersion-sourced). They share `DriftHasher` but are otherwise separate data paths with separate finding generators.
2. **The core gap**: `CompareBaselineToTenantJob` hashes `InventoryMetaContract` v1 — which contains only `odata_type`, `etag`, `scope_tag_ids`, `assignment_target_count` — never actual policy settings. When an admin changes a Wi-Fi password or a compliance threshold in Intune, _none of these meta signals necessarily change_.
3. **Inventory sync uses Graph LIST endpoints**, which return metadata and display fields only. Per-item GET (which fetches settings, assignments, scope tags) is only performed during _Backup_ via `PolicyCaptureOrchestrator`.
4. **`DriftFindingGenerator`** (the backup drift system) _does_ detect settings changes — it normalizes `PolicyVersion.snapshot` via `SettingsNormalizer``PolicyNormalizer::flattenForDiff()` → type-specific normalizers, then hashes with `DriftHasher`.
5. **Spec 116 already designs v2** with a provider precedence chain (`PolicyVersion → Inventory content → Meta fallback`), which is the correct architectural direction. The v1 meta baseline shipped first as a deliberate, safe-to-ship initial milestone.
6. **Unification is recommended** (provider chain approach) — not merging the two jobs, but enabling `CompareBaselineToTenantJob` to optionally consume `PolicyVersion` snapshots as a content-fidelity provider, falling back to InventoryMetaContract when no PolicyVersion is available.
7. **28 supported policy types** are registered in `tenantpilot.php`, plus 3 foundation types. Of these, 10+ have complex hydration (settings catalog, group policy, security baselines, compliance actions) and would benefit most from deep-drift detection.
8. **The `etag` signal** is unreliable as a settings-change proxy: Microsoft Graph etag semantics vary per resource type, and etag may or may not change when settings are modified. It is useful as a _hint_ but not a _guarantee_.
9. **API cost is the primary constraint**: content-fidelity compare requires per-item GET calls (or a recent Backup that already captured PolicyVersions). The hybrid provider chain avoids this by opportunistically _reusing_ existing PolicyVersions without requiring a full backup before every compare.
10. **Coverage Guard is critical for v2**: the baseline system must know _which types have fresh PolicyVersions_ and suppress content-fidelity findings for types where no recent version exists (falling back to meta fidelity).
11. **Risk profile**: Shipping deep-drift for wrong types (without proper per-type normalization) could produce false positives. Type-specific normalizers already exist for the backup drift path; reusing them is safe.
12. **Recommended phasing**: v1.5 (current sprint) = add `content_hash_source` column to `baseline_snapshot_items` + provider chain in compare job. v2.0 = on-demand per-item GET during baseline capture for types lacking recent PolicyVersions.
---
## 2. System Map
### Side-by-Side Comparison Table
| Dimension | System A: Baseline Compare | System B: Backup Drift |
|---|---|---|
| **Entry point** | `CompareBaselineToTenantJob` | `GenerateDriftFindingsJob``DriftFindingGenerator` |
| **Data source (current)** | `InventoryItem` (from LIST sync) | `PolicyVersion` (from per-item GET backup) |
| **Data source (baseline)** | `BaselineSnapshotItem` (captured from inventory) | Earlier `PolicyVersion` from prior `OperationRun` |
| **Hash contract** | `InventoryMetaContract` v1 → `DriftHasher::hashNormalized()` | `SettingsNormalizer``PolicyNormalizer::flattenForDiff()``DriftHasher::hashNormalized()` |
| **Hash inputs** | `version`, `policy_type`, `subject_external_id`, `odata_type`, `etag`, `scope_tag_ids`, `assignment_target_count` | Full `PolicyVersion.snapshot` JSON (with volatile key removal) |
| **Fidelity** | `meta` (persisted as `fidelity='meta'` in snapshot context) | `content` (settings + assignments + scope_tags) |
| **Dimensions detected** | `missing_policy`, `different_version`, `unexpected_policy` | `policy_snapshot` (added/removed/modified), `policy_assignments` (modified), `policy_scope_tags` (modified) |
| **Finding identity** | `recurrence_key = sha256(tenantId\|snapshotId\|policyType\|extId\|changeType)` | `recurrence_key = sha256(drift:tenantId:scopeKey:subjectType:extId:dimension)` |
| **Scope key** | `baseline_profile:{profileId}` | `DriftScopeKey::fromSelectionHash()` |
| **Auto-close** | `BaselineAutoCloseService` (stale finding resolution) | `resolveStaleDriftFindings()` within `DriftFindingGenerator` |
| **Coverage guard** | `InventoryCoverage::fromContext()` → uncovered types → partial outcome | None (trusts backup captured all types) |
| **Graph API calls** | Zero at compare time (reads from DB) | Zero at compare time (reads PolicyVersions from DB) |
| **Graph API calls (capture)** | Zero (inventory sync did LIST) | Per-item GET via `PolicyCaptureOrchestrator` |
| **Normalizer pipeline** | None (meta contract is the normalization) | `SettingsNormalizer``PolicyNormalizer` → type normalizers |
| **Shared components** | `DriftHasher`, `Finding` model | `DriftHasher`, `Finding` model |
| **Trigger** | After inventory sync, on schedule/manual | After backup, on schedule/manual |
### Data Flow Diagrams
```
SYSTEM A — Baseline Compare (Meta Fidelity)
============================================
Graph LIST ──► InventorySyncService ──► InventoryItem (meta_jsonb)
CaptureBaselineSnapshotJob
├─ InventoryMetaContract.build()
├─ DriftHasher.hashNormalized()
└─► BaselineSnapshotItem (baseline_hash)
CompareBaselineToTenantJob
├─ loadCurrentInventory() → InventoryItem
├─ BaselineSnapshotIdentity.hashItemContent()
│ └─ InventoryMetaContract.build()
│ └─ DriftHasher.hashNormalized()
├─ computeDrift() → hash compare
└─ upsertFindings() → Finding records
SYSTEM B — Backup Drift (Content Fidelity)
============================================
Graph GET ──► PolicySnapshotService.fetch() ──► full JSON snapshot
PolicyCaptureOrchestrator.capture()
├─ assignments GET
├─ scope tags resolve
└─► VersionService.captureVersion() ──► PolicyVersion
DriftFindingGenerator.generate()
├─ versionForRun() → baseline/current PV
├─ SettingsNormalizer.normalizeForDiff()
│ └─ PolicyNormalizer.flattenForDiff()
├─ DriftHasher.hashNormalized() × 3
│ (snapshot, assignments, scope_tags)
└─ upsertDriftFinding() → Finding records
```
---
## 3. ADR-001: Unify vs Separate
### Title
ADR-001: Golden Master Baseline Compare — Provider Chain for Content Fidelity
### Status
PROPOSED
### Context
TenantPilot has two drift detection systems that evolved independently:
- **System A (Baseline Compare)**: Designed for "does the tenant still match the golden master?" Use case. Ships with meta-fidelity (v1) — fast, cheap, zero additional Graph calls at compare time. Detects structural drift (policy added/removed/meta-changed) but is blind to _settings_ changes.
- **System B (Backup Drift)**: Designed for "what changed between two backup points?" Use case. Content-fidelity — full PolicyVersion snapshots with per-type normalization. Detects settings, assignments, and scope tag changes.
The two systems cannot be merged into one without fundamentally changing their triggering, scoping, and API cost models. However, System A's accuracy can be dramatically improved by _consuming_ data already produced by System B.
### Decision
**Adopt the Provider Chain pattern** as already designed in Spec 116 v2:
```
ContentProvider = PolicyVersion → InventoryContent → MetaFallback
```
Specifically:
1. `CompareBaselineToTenantJob` gains a `ContentProviderChain` that, for each `(policy_type, external_id)`:
- **First**: Looks for a `PolicyVersion` captured since the last baseline snapshot timestamp. If found, normalizes via `SettingsNormalizer``DriftHasher` → returns `content` fidelity hash.
- **Second (future)**: Looks for enriched inventory content if inventory sync is upgraded to capture settings (v2.0+).
- **Fallback**: Builds `InventoryMetaContract` v1 → `DriftHasher` → returns `meta` fidelity hash.
2. Each baseline snapshot item records its `fidelity` (`meta` | `content`) and `content_hash_source` (`inventory_meta_v1` | `policy_version:{id}` | `inventory_content_v2`).
3. Compare findings carry `fidelity` in evidence, enabling UI to display confidence level.
4. Coverage Guard is extended: a type is `content-covered` only if PolicyVersions exist for ≥N% of items. Below that threshold, fallback to meta fidelity (do not suppress).
### Consequences
- **Positive**: No new Graph API calls needed (reuses existing PolicyVersions from backups). Zero additional infrastructure. Incremental rollout per policy type. Existing meta-fidelity behavior preserved as fallback.
- **Negative**: Content fidelity depends on backup recency. If a tenant hasn't been backed up, only meta fidelity is available. Could create "mixed fidelity" findings within a single compare run.
- **Rejected Alternative**: Full merge of System A and B into a single system. Rejected because they serve different use cases (golden master comparison vs point-in-time drift), have different scoping models (BaselineProfile vs selection_hash), and different triggering models (post-inventory-sync vs post-backup).
- **Rejected Alternative**: Always-GET during baseline compare. Rejected due to API cost (30+ types × 100s of policies = 1000s of GET calls per tenant per compare run).
### Compliance Notes
- Livewire v4.0+ / Filament v5: no UI changes in core ADR; provider chain is purely backend.
- Provider registration: n/a (backend services only).
- No destructive actions.
- Asset strategy: no new assets.
---
## 4. Deep-Dive: Why Settings Changes Don't Produce Baseline Drift
### The Root Cause Chain
**Step 1: Inventory Sync captures only LIST metadata**
`InventorySyncService::executeSelectionUnderLock()` (line ~340-450) calls Graph LIST endpoints. For each policy, it extracts:
- `display_name`, `category`, `platform` (display fields)
- `odata_type`, `etag`, `scope_tag_ids`, `assignment_target_count` (meta signals)
These are stored in `InventoryItem.meta_jsonb`. **No settings values are fetched or stored.**
**Step 2: Baseline Capture hashes only the Meta Contract**
`CaptureBaselineSnapshotJob::collectSnapshotItems()` reads from `InventoryItem`, then calls `BaselineSnapshotIdentity::hashItemContent()`:
```php
// BaselineSnapshotIdentity.php, line 56-67
public function hashItemContent(string $policyType, string $subjectExternalId, array $metaJsonb): string
{
$contract = $this->metaContract->build(
policyType: $policyType,
subjectExternalId: $subjectExternalId,
metaJsonb: $metaJsonb,
);
return $this->hasher->hashNormalized($contract);
}
```
The `InventoryMetaContract::build()` output is:
```php
[
'version' => 1,
'policy_type' => 'settingsCatalogPolicy',
'subject_external_id' => '<guid>',
'odata_type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'etag' => '"abc..."', // ← unreliable change indicator
'scope_tag_ids' => ['0'],
'assignment_target_count' => 3,
]
```
**This is ALL that gets hashed.** Actual policy settings (the Wi-Fi password, the compliance threshold, the firewall rule) are _nowhere_ in this contract.
**Step 3: Baseline Compare re-computes the same meta hash**
`CompareBaselineToTenantJob::loadCurrentInventory()` (line 367-409) reads current `InventoryItem` records and calls the same `BaselineSnapshotIdentity::hashItemContent()` with the same `InventoryMetaContract`, producing the same hash structure.
`computeDrift()` (line 435-500) then compares `baseline_hash` vs `current_hash`:
```php
if ($baselineItem['baseline_hash'] !== $currentItem['current_hash']) {
$drift[] = ['change_type' => 'different_version', ...];
}
```
**If the admin changed a policy setting but the meta signals (etag, scope_tag_ids, assignment_target_count) stayed the same, `baseline_hash === current_hash` and NO drift is detected.**
### Why etag is unreliable
Microsoft Graph etag behavior varies by resource type:
- **Some types** update etag on any property change (including settings)
- **Some types** update etag only on top-level property changes (not nested settings)
- **Settings Catalog policies** may or may not update the parent resource etag when child `settings` are modified (the settings are a separate subresource at `/configurationPolicies/{id}/settings`)
- **Group Policy Configurations** have settings in `definitionValues``presentationValues` (multi-level nesting); etag at root level may not reflect these changes
### The Contrast: How Backup Drift _Does_ Detect Settings Changes
`DriftFindingGenerator::generate()` (line 32-80) operates on `PolicyVersion.snapshot` — the full JSON captured via per-item GET:
```php
$baselineSnapshot = $baselineVersion->snapshot; // Full JSON from Graph GET
$currentSnapshot = $currentVersion->snapshot;
$baselineNormalized = $this->settingsNormalizer->normalizeForDiff($baselineSnapshot, $policyType, $platform);
$currentNormalized = $this->settingsNormalizer->normalizeForDiff($currentSnapshot, $policyType, $platform);
$baselineSnapshotHash = $this->hasher->hashNormalized($baselineNormalized);
$currentSnapshotHash = $this->hasher->hashNormalized($currentNormalized);
if ($baselineSnapshotHash !== $currentSnapshotHash) {
// → Drift finding with change_type = 'modified'
}
```
This pipeline captures actual settings values, normalizes them per policy type, strips volatile metadata, and hashes the result. If a setting changed, the hash changes, and drift is detected.
### Summary Visualization
```
Admin changes Wi-Fi password in Intune
┌─────────────────────────────────┐
│ Graph LIST (inventory sync) │
│ returns: displayName, etag, ... │
│ │
│ etag MAY change, settings NOT │
│ returned by LIST endpoint │
└────────────┬────────────────────┘
┌───────┴────────┐
▼ ▼
InventoryItem PolicyVersion
(meta only) (if backup ran)
│ │
▼ ▼
Meta Contract Full Snapshot
hash unchanged hash CHANGED
│ │
▼ ▼
Baseline Backup Drift:
Compare: "modified" ✅
NO DRIFT ❌
```
---
## 5. Code Evidence Table
| # | Class / File | Lines | Role | Key Finding |
|---|---|---|---|---|
| 1 | `app/Jobs/CompareBaselineToTenantJob.php` | 785 total; L367-409 (loadCurrentInventory), L435-500 (computeDrift) | Core baseline compare job | Reads from `InventoryItem` only; hashes via `InventoryMetaContract`**blind to settings** |
| 2 | `app/Services/Baselines/InventoryMetaContract.php` | 75 total; L30-57 (build) | Meta hash contract builder | Hashes only: version, policy_type, external_id, odata_type, etag, scope_tag_ids, assignment_target_count — **no settings content** |
| 3 | `app/Services/Baselines/BaselineSnapshotIdentity.php` | 73 total; L56-67 (hashItemContent) | Per-item hash via meta contract | Delegates to `InventoryMetaContract.build()``DriftHasher.hashNormalized()` |
| 4 | `app/Jobs/CaptureBaselineSnapshotJob.php` | 305 total | Captures snapshot from inventory | Reads `InventoryItem`, stores `fidelity='meta'` and `source='inventory'` |
| 5 | `app/Services/Drift/DriftFindingGenerator.php` | 484 total; L32-80 (generate), L250-267 (recurrenceKey) | Backup drift finding generator | Uses `PolicyVersion.snapshot` with `SettingsNormalizer`**detects settings changes** |
| 6 | `app/Services/Drift/DriftHasher.php` | 100 total; L13-24 (hashNormalized) | Shared hasher | `sha256(json_encode(normalized))` with volatile key removal. **SHARED by both systems.** |
| 7 | `app/Services/Drift/Normalizers/SettingsNormalizer.php` | 22 total | Thin wrapper | Delegates to `PolicyNormalizer::flattenForDiff()`. Used by System B only. |
| 8 | `app/Services/Intune/PolicyNormalizer.php` | 67 total | Type-specific normalizer router | Routes to per-type normalizers for diff operations |
| 9 | `app/Services/Inventory/InventorySyncService.php` | 652 total; L340-450 (executeSelectionUnderLock) | LIST-based sync | Fetches from Graph LIST endpoints; extracts meta signals only; upserts `InventoryItem` |
| 10 | `app/Services/Intune/BackupService.php` | 438 total | Backup orchestration | Creates `BackupSet`, uses `PolicyCaptureOrchestrator` for per-item GET → PolicyVersion |
| 11 | `app/Services/Intune/PolicyCaptureOrchestrator.php` | 429 total | Per-item GET + hydration | Fetches full snapshot, assignments, scope tags; creates PolicyVersion with all content |
| 12 | `app/Services/Intune/PolicySnapshotService.php` | 852 total | Per-item Graph GET | Type-specific hydration (hydrateConfigurationPolicySettings, hydrateGroupPolicyConfiguration, etc.) |
| 13 | `app/Services/Intune/VersionService.php` | 312 total; L1-150 (captureVersion) | PolicyVersion persistence | Transactional, locking, consecutive version_number |
| 14 | `app/Models/PolicyVersion.php` | Model | PolicyVersion model | Casts: snapshot(array), assignments(array), scope_tags(array), plus hash columns |
| 15 | `app/Models/InventoryItem.php` | Model | Inventory item model | Casts: meta_jsonb(array) — **no settings content** |
| 16 | `app/Models/BaselineSnapshotItem.php` | Model | Snapshot item model | Has `baseline_hash(64)`, `meta_jsonb` |
| 17 | `app/Support/Inventory/InventoryCoverage.php` | 173 total | Coverage parser | `fromContext()` extracts per-type status from sync run context |
| 18 | `app/Services/Drift/DriftRunSelector.php` | ~60 total | Run pair selector | Selects 2 most recent sync runs with same `selection_hash` (System B only) |
| 19 | `app/Jobs/GenerateDriftFindingsJob.php` | ~200 total | Dispatcher for System B | Dispatches `DriftFindingGenerator` for policy-version-based drift |
| 20 | `config/graph_contracts.php` | 867 total | Policy type registry | Defines endpoints, hydration strategies, subresources, type families per policy type |
| 21 | `config/tenantpilot.php` | 385 total; L18-293 (supported_policy_types) | Application config | 28 supported policy types + 3 foundation types |
| 22 | `specs/116-baseline-drift-engine/spec.md` | 237 total | Feature spec | Defines v1 (meta) and v2 (content fidelity) requirements |
| 23 | `specs/116-baseline-drift-engine/research.md` | 200 total | Phase 0 research | 6 key decisions including v2 architecture strategy |
| 24 | `specs/116-baseline-drift-engine/plan.md` | 259 total | Implementation plan | Steps 1-7 for v1; v2 deferred |
---
## 6. Type Coverage Matrix
Coverage assessment for deep-drift feasibility: **which types have per-type normalization and hydration support?**
| # | `policy_type` | Label | Hydration | Subresources | Per-Type Normalizer | Deep-Drift Feasible | Notes |
|---|---|---|---|---|---|---|---|
| 1 | `settingsCatalogPolicy` | Settings Catalog Policy | `configurationPolicies` | `settings` (list) | Yes (via PolicyNormalizer) | **YES** | Most impactful — complex nested settings |
| 2 | `endpointSecurityPolicy` | Endpoint Security Policies | `configurationPolicies` | `settings` (list) | Yes (shared with settings catalog) | **YES** | Same endpoint family as settings catalog |
| 3 | `securityBaselinePolicy` | Security Baselines | `configurationPolicies` | `settings` (list) | Yes (shared) | **YES** | Same pipeline |
| 4 | `groupPolicyConfiguration` | Administrative Templates | `groupPolicyConfigurations` | `definitionValues``presentationValues` | Yes (via PolicyNormalizer) | **YES** | Multi-level nesting; hydration required |
| 5 | `deviceConfiguration` | Device Configuration | `deviceConfigurations` | None (properties on root) | Yes (via PolicyNormalizer) | **YES** | Properties directly on resource |
| 6 | `deviceCompliancePolicy` | Device Compliance | `deviceCompliancePolicies` | `scheduledActionsForRule` (expand) | Yes (via PolicyNormalizer) | **YES** | Actions subresource needs expand |
| 7 | `windowsUpdateRing` | Software Update Ring | `deviceConfigurations` (filtered) | None (properties on root) | Yes (shared with deviceConfig) | **YES** | Subset of deviceConfiguration |
| 8 | `appProtectionPolicy` | App Protection (MAM) | `managedAppPolicies` | None (properties) | Partial (via PolicyNormalizer) | **YES** | Mobile-focused |
| 9 | `conditionalAccessPolicy` | Conditional Access | `identity/conditionalAccess/policies` | None (properties) | Yes (via PolicyNormalizer) | **YES** | High-risk, preview-only restore |
| 10 | `deviceManagementScript` | PowerShell Scripts | `deviceManagementScripts` | None (scriptContent base64) | Partial | **PARTIAL** | Script content is base64 in snapshot |
| 11 | `deviceShellScript` | macOS Shell Scripts | `deviceShellScripts` | None (scriptContent base64) | Partial | **PARTIAL** | Same pattern as PS scripts |
| 12 | `deviceHealthScript` | Proactive Remediations | `deviceHealthScripts` | None | Partial | **PARTIAL** | Detection + remediation scripts |
| 13 | `deviceComplianceScript` | Custom Compliance Scripts | `deviceComplianceScripts` | None | Partial | **PARTIAL** | Script content |
| 14 | `windowsFeatureUpdateProfile` | Feature Updates | `windowsFeatureUpdateProfiles` | None | Yes | **YES** | Simple properties |
| 15 | `windowsQualityUpdateProfile` | Quality Updates | `windowsQualityUpdateProfiles` | None | Yes | **YES** | Simple properties |
| 16 | `windowsDriverUpdateProfile` | Driver Updates | `windowsDriverUpdateProfiles` | None | Yes | **YES** | Simple properties |
| 17 | `mamAppConfiguration` | App Config (MAM) | `targetedManagedAppConfigurations` | None | Partial | **YES** | Properties-based |
| 18 | `managedDeviceAppConfiguration` | App Config (Device) | `mobileAppConfigurations` | None | Partial | **YES** | Properties-based |
| 19 | `windowsAutopilotDeploymentProfile` | Autopilot Profiles | `windowsAutopilotDeploymentProfiles` | None | Minimal | **YES** | Properties-based |
| 20 | `windowsEnrollmentStatusPage` | Enrollment Status Page | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Enrollment types have limited settings |
| 21 | `deviceEnrollmentLimitConfiguration` | Enrollment Limits | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Numeric limit only |
| 22 | `deviceEnrollmentPlatformRestrictionsConfiguration` | Platform Restrictions | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Nested restriction config |
| 23 | `deviceEnrollmentNotificationConfiguration` | Enrollment Notifications | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Template snapshots nested |
| 24 | `enrollmentRestriction` | Enrollment Restrictions | `deviceEnrollmentConfigurations` | None | Minimal | **META-ONLY** | Mixed config type |
| 25 | `termsAndConditions` | Terms & Conditions | `termsAndConditions` | None | Yes | **YES** | bodyText, acceptanceStatement |
| 26 | `endpointSecurityIntent` | Endpoint Security Intents | `intents` | categories/settings (legacy) | Partial | **PARTIAL** | Legacy intent API; migrating to configPolicies |
| 27 | `mobileApp` | Applications | `mobileApps` | None | Minimal | **META-ONLY** | Metadata-only backup per config |
| 28 | `policySet` | Policy Sets | (if supported) | assignments | Minimal | **META-ONLY** | Container for other policies |
**Foundation Types:**
| # | `foundation_type` | Label | Deep-Drift | Notes |
|---|---|---|---|---|
| F1 | `assignmentFilter` | Assignment Filter | **YES** | `rule` property is key content |
| F2 | `roleScopeTag` | Scope Tag | **META-ONLY** | displayName + description only |
| F3 | `notificationMessageTemplate` | Notification Template | **PARTIAL** | Localized messages are subresource |
**Summary:**
- **Full content-fidelity feasible**: 16 types (settingsCatalog, endpointSecurity, securityBaseline, groupPolicy, deviceConfig, compliance, updateRings/profiles, appProtection, conditionalAccess, appConfigs, autopilot, termsAndConditions, assignmentFilter)
- **Partial** (script content / legacy APIs): 5 types
- **Meta-only sufficient**: 7 types (enrollment configs, mobileApp, roleScopeTag)
---
## 7. Proposal: Deep Drift Implementation Plan
### Phase v1.5 — Provider Chain (Opportunistic Content Fidelity)
**Goal**: Enable baseline compare to use existing PolicyVersions for content-fidelity hash when available, with meta-fidelity fallback.
**Estimated effort**: 3-5 days
#### Step 1: ContentHashProvider Interface
```php
// app/Contracts/Baselines/ContentHashProvider.php
interface ContentHashProvider
{
/**
* @return array{hash: string, fidelity: string, source: string}|null
*/
public function resolve(string $policyType, string $externalId, int $tenantId, CarbonImmutable $since): ?array;
}
```
#### Step 2: PolicyVersionContentProvider
```php
// app/Services/Baselines/PolicyVersionContentProvider.php
// Looks up the latest PolicyVersion for (tenant_id, external_id, policy_type)
// captured_at >= $since (baseline snapshot timestamp)
// Returns SettingsNormalizer → DriftHasher hash with fidelity='content'
```
#### Step 3: MetaFallbackProvider (existing logic)
```php
// Wraps InventoryMetaContract → DriftHasher → fidelity='meta'
```
#### Step 4: ContentProviderChain
```php
// Iterates [PolicyVersionContentProvider, MetaFallbackProvider]
// Returns first non-null result
```
#### Step 5: Integration in CompareBaselineToTenantJob
- `loadCurrentInventory()` accepts optional `ContentProviderChain`
- For each item: try chain, record fidelity + source
- `computeDrift()` unchanged (still hash vs hash comparison)
- Finding evidence includes `fidelity` and `content_hash_source`
#### Step 6: CaptureBaselineSnapshotJob enhancement
- Optional: during capture, also try `PolicyVersionContentProvider` to store content-fidelity baseline_hash
- Store `content_hash_source` in `baseline_snapshot_items.meta_jsonb`
- This means: if a backup was taken before baseline capture, the baseline itself is content-fidelity
#### Step 7: Coverage extension
- Add `content_coverage` to compare run context: which types had PolicyVersions, which fell back to meta
- Display in operation detail UI
#### Migration
```sql
-- Optional: add column for source tracking
ALTER TABLE baseline_snapshot_items
ADD COLUMN content_hash_source VARCHAR(255) NULL DEFAULT 'inventory_meta_v1';
```
### Phase v2.0 — On-Demand Content Capture (Future)
**Goal**: For types without recent PolicyVersions, perform targeted per-item GET during baseline capture/compare.
**Estimated effort**: 5-8 days
- Introduce `BaselineContentCaptureJob` that, for a given baseline profile's scope, identifies items lacking recent PolicyVersions and performs targeted GET + PolicyVersion creation.
- Reuses existing `PolicyCaptureOrchestrator` with a new "baseline-triggered" context.
- Adds `capture_mode` to baseline profile: `meta_only` (v1), `opportunistic` (v1.5), `full_content` (v2.0).
- Rate limiting: per-tenant throttle to avoid Graph API quota issues.
- Budget guard: max N items per capture run, with continuation support.
### Phase v2.5 — Inventory Content Enrichment (Future, Optional)
**Goal**: Optionally have inventory sync capture settings content inline during LIST (where type supports `$expand`).
- Some types support `$expand=settings` on LIST (settings catalog, endpoint security).
- This would give "free" content fidelity without per-item GET.
- High complexity: varies per type, may increase LIST payload size significantly.
- Evaluate ROI after v2.0 ships.
---
## 8. Test Plan (Enterprise)
### Unit Tests
| # | Test File | Scope | Key Assertions |
|---|---|---|---|
| U1 | `tests/Unit/Baselines/ContentProviderChainTest.php` | Provider chain resolution | First provider wins; null fallback; fidelity recorded correctly |
| U2 | `tests/Unit/Baselines/PolicyVersionContentProviderTest.php` | PolicyVersion lookup + normalization | Correct hash for known snapshot; returns null when no PV; respects `$since` cutoff |
| U3 | `tests/Unit/Baselines/MetaFallbackProviderTest.php` | Meta contract fallback | Produces `fidelity='meta'`; matches existing `InventoryMetaContract` behavior exactly |
| U4 | `tests/Unit/Baselines/InventoryMetaContractTest.php` | (Existing) contract stability | Null handling, ordering, versioning — extend for edge cases |
### Feature Tests
| # | Test File | Scope | Key Assertions |
|---|---|---|---|
| F1 | `tests/Feature/Baselines/BaselineCompareContentFidelityTest.php` | End-to-end compare with PolicyVersions available | Settings change → `different_version` finding with `fidelity='content'` |
| F2 | `tests/Feature/Baselines/BaselineCompareMixedFidelityTest.php` | Some types have PV, some don't | Mixed `fidelity` values in findings; coverage context records both |
| F3 | `tests/Feature/Baselines/BaselineCompareFallbackTest.php` | No PolicyVersions available | Falls back to meta fidelity; identical behavior to v1 |
| F4 | `tests/Feature/Baselines/BaselineCaptureFidelityTest.php` | Capture with PolicyVersions present | `baseline_hash` uses content fidelity; `content_hash_source` recorded |
| F5 | `tests/Feature/Baselines/BaselineCompareStaleVersionTest.php` | PolicyVersion older than snapshot | Falls back to meta (stale PV not used) |
| F6 | `tests/Feature/Baselines/BaselineCompareCoverageGuardContentTest.php` | Coverage reporting for content types | `content_coverage` in run context shows which types are content-covered |
### Existing Tests to Preserve
| # | Test File | Impact |
|---|---|---|
| E1 | `tests/Feature/Baselines/BaselineCompareFindingsTest.php` | Must still pass — meta fidelity is default when no PV exists |
| E2 | `tests/Feature/Baselines/BaselineComparePreconditionsTest.php` | No change expected |
| E3 | `tests/Feature/Baselines/BaselineCompareStatsTest.php` | Stats remain grouped by scope_key; may need fidelity breakdown |
| E4 | `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php` | Auto-close unaffected by fidelity source |
### Integration / Regression
| # | Test | Scope |
|---|---|---|
| I1 | Content hash stability across serialization | JSON encode/decode round-trip does not change hash |
| I2 | PolicyVersion normalizer alignment | Same snapshot → `SettingsNormalizer` produces same hash in both System A (via provider) and System B (via DriftFindingGenerator) |
| I3 | Hash collision protection | Different settings → different hashes (property-based test with sample data) |
| I4 | Empty snapshot edge case | PolicyVersion with empty/null snapshot → provider returns null → fallback works |
### Performance Tests
| # | Test | Acceptance Criteria |
|---|---|---|
| P1 | Compare job with 500 items, 50% with PolicyVersions | Completes in < 30s (DB-only, no Graph calls) |
| P2 | Provider chain query efficiency | PolicyVersion lookup uses batch query, not N+1 |
---
## 9. Open Questions / Assumptions
### Open Questions
| # | Question | Impact | Proposed Resolution |
|---|---|---|---|
| OQ-1 | **Staleness threshold for PolicyVersions**: How old can a PolicyVersion be before we reject it as a content source? | Determines false-negative risk | Default: PolicyVersion must be captured after the baseline snapshot's `captured_at`. Configurable per workspace. |
| OQ-2 | **Mixed fidelity UX**: How should the UI display findings with different fidelity levels? | User trust and understanding | Badge/icon on finding cards: "High confidence (content)" vs "Structural only (meta)". Filterable in findings table. |
| OQ-3 | **Should baseline capture _force_ a backup** if no recent PolicyVersions exist? | API cost vs accuracy trade-off | No for v1.5 (opportunistic only). Yes for v2.0 as opt-in `capture_mode: full_content`. |
| OQ-4 | **etag as change hint**: Should we use etag changes as a _trigger_ for on-demand PolicyVersion capture? | Could reduce unnecessary GETs | Worth investigating in v2.0. If etag changes during inventory sync, schedule targeted per-item GET for that policy only. |
| OQ-5 | **Settings Catalog `$expand=settings`** on LIST: Does Microsoft Graph support this? | Could give "free" content fidelity for settings catalog types | Needs validation against Graph API. If supported, would eliminate per-item GET for the most impactful type. |
| OQ-6 | **Retention / pruning interaction**: If old PolicyVersions are pruned, does that affect baseline compare? | Could lose content fidelity for old baselines | Baseline compare only needs versions captured _after_ baseline snapshot. Pruning policy should respect active baseline snapshots. |
### Assumptions
| # | Assumption | Risk if Wrong |
|---|---|---|
| A-1 | `DriftHasher::hashNormalized()` is deterministic across PHP serialization boundaries | Hash mismatch → false drift findings. **Validated**: uses `json_encode` with stable flags + `ksort`. |
| A-2 | `SettingsNormalizer` / `PolicyNormalizer` produce the same output for the same input regardless of call context (System A vs System B) | Hash inconsistency between systems. **Low risk**: same code path. |
| A-3 | PolicyVersions from backups contain complete settings (not partial hydration) | Incomplete content → false negatives or incorrect hashes. **Validated**: `PolicySnapshotService` performs full hydration per type. |
| A-4 | The `Finding` model's `fingerprint`/`recurrence_key` identity allows mixed fidelity sources | Identity collision if fidelity changes source. **Safe**: recurrence_key includes snapshot_id, not hash value. |
| A-5 | Graph LIST endpoints do NOT return settings values for any supported policy type | If wrong, inventory sync could capture settings "for free". **Validated**: LIST returns only `$select` fields per `graph_contracts.php`. |
| A-6 | Per-type normalizers in backup drift path handle all 28 supported policy types | If not, some types would produce unstable hashes. **Partially validated**: `PolicyNormalizer` has a fallback for unknown types. |
---
## 10. Key Questions Answered
### KQ-01: Are Baseline Compare and Backup Drift truly separate systems?
**Yes.** They share `DriftHasher` and the `Finding` model, but differ in:
- Data source: `InventoryItem` vs `PolicyVersion`
- Hash contract: `InventoryMetaContract` (7 fields, meta only) vs `SettingsNormalizer → PolicyNormalizer` (full snapshot)
- Finding generator: `CompareBaselineToTenantJob::computeDrift()` vs `DriftFindingGenerator::generate()`
- Finding identity: different recurrence key structures
- Scope model: `BaselineProfile`-scoped vs `selection_hash`-scoped
- Trigger: post-inventory-sync vs post-backup
- Coverage: `InventoryCoverage` guard vs none (trusts backup completeness)
### KQ-02: Should they be unified or remain separate?
**Hybrid approach (Provider Chain)** — as designed in Spec 116 v2. Keep separate triggering and scoping, but let System A _consume_ data produced by System B (PolicyVersions) via a provider chain. This avoids:
- Merging two fundamentally different scoping models
- Introducing new Graph API costs
- Disrupting existing backup drift workflows
### KQ-03: What is the minimal viable "v1.5" to bridge the gap?
Add a `PolicyVersionContentProvider` that checks for recent PolicyVersions as part of baseline compare's hash computation. For types where a PolicyVersion exists (i.e., a backup was taken), the compare immediately gains content-fidelity. For types without, meta-fidelity continues as before. **Net code change: ~200-300 lines** (interface + 2 providers + chain + integration).
### KQ-04: Which types benefit most from content-fidelity drift?
**Top priority** (complex settings, high change frequency):
1. `settingsCatalogPolicy` — most common, deeply nested settings
2. `groupPolicyConfiguration` — multi-level nesting (definitionValues → presentationValues)
3. `deviceCompliancePolicy` — compliance rules + scheduled actions
4. `deviceConfiguration` — broad category, many OData sub-types
5. `endpointSecurityPolicy` — critical security settings
6. `securityBaselinePolicy` — security-critical baselines
7. `conditionalAccessPolicy` — identity security gate
**Medium priority** (simpler settings but still valuable):
8. `appProtectionPolicy`, `windowsUpdateRing`, `windowsFeatureUpdateProfile`, `windowsQualityUpdateProfile`
### KQ-05: How does coverage work and how should it extend for content fidelity?
Currently: `InventoryCoverage::fromContext(latestSyncRun->context)``coveredTypes()` returns types with `status=succeeded`. Uncovered types → findings suppressed, outcome = `partially_succeeded`.
For v1.5: Add `content_coverage` alongside `meta_coverage`:
- `content_covered_types`: types where PolicyVersion exists post-baseline
- `meta_only_types`: types where only meta is available
- `uncovered_types`: types with no coverage at all (findings suppressed)
Finding evidence should include:
```json
{
"fidelity": "content",
"content_hash_source": "policy_version:42",
"note": "Hash computed from PolicyVersion #42 captured 2025-07-14T10:30:00Z"
}
```
### KQ-06: What is the long-term unified architecture?
**Provider precedence chain** with configurable capture modes:
```
BaselineProfile.capture_mode:
'meta_only' → InventoryMetaContract only (v1)
'opportunistic' → PolicyVersion if available → meta fallback (v1.5)
'full_content' → On-demand GET for missing types → PolicyVersion → meta (v2.0)
ContentProviderChain:
1. PolicyVersionContentProvider (checks existing PolicyVersions)
2. InventoryContentProvider (future: if inventory sync enriched)
3. MetaFallbackProvider (InventoryMetaContract v1)
```
The long-term vision is that baseline capture + compare use the **same normalizer pipeline** as backup drift, producing identical hashes for identical content regardless of which system produced the PolicyVersion. This is achievable because `DriftHasher` and `SettingsNormalizer` are already shared code.
---
## Appendix: Database Schema Reference
### `baseline_snapshot_items` (current)
```
id BIGINT PK
baseline_snapshot_id BIGINT FK → baseline_snapshots
subject_type VARCHAR(255) -- 'policy'
subject_external_id VARCHAR(255) -- Graph resource GUID
policy_type VARCHAR(255) -- e.g. 'settingsCatalogPolicy'
baseline_hash VARCHAR(64) -- sha256 of InventoryMetaContract
meta_jsonb JSONB -- {display_name, category, platform, meta_contract: {...}, fidelity, source}
created_at TIMESTAMP
updated_at TIMESTAMP
```
### `inventory_items` (current)
```
id BIGINT PK
tenant_id BIGINT FK → tenants
policy_type VARCHAR(255)
external_id VARCHAR(255)
display_name VARCHAR(255)
category VARCHAR(255) NULL
platform VARCHAR(255) NULL
meta_jsonb JSONB -- {odata_type, etag, scope_tag_ids, assignment_target_count}
last_seen_at TIMESTAMP NULL
last_seen_operation_run_id BIGINT NULL
created_at TIMESTAMP
updated_at TIMESTAMP
```
### `policy_versions` (current)
```
id BIGINT PK
tenant_id BIGINT FK → tenants
policy_id BIGINT FK → policies
version_number INTEGER
policy_type VARCHAR(255)
platform VARCHAR(255) NULL
created_by VARCHAR(255) NULL
captured_at TIMESTAMP
snapshot JSON -- FULL Graph GET response (hydrated)
metadata JSON -- additional metadata
assignments JSON NULL -- full assignments array
scope_tags JSON NULL -- scope tag IDs
assignments_hash VARCHAR(64) NULL
scope_tags_hash VARCHAR(64) NULL
created_at TIMESTAMP
updated_at TIMESTAMP
deleted_at TIMESTAMP NULL -- soft delete
```
### Proposed v1.5 Addition
```sql
ALTER TABLE baseline_snapshot_items
ADD COLUMN content_hash_source VARCHAR(255) NULL DEFAULT 'inventory_meta_v1';
-- Values: 'inventory_meta_v1', 'policy_version:{id}', 'inventory_content_v2'
```

View File

@ -4,6 +4,10 @@
<div wire:poll.5s="refreshStats"></div>
@endif
@php
$hasCoverageWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true);
@endphp
{{-- Row 1: Stats Overview --}}
@if (in_array($state, ['ready', 'idle', 'comparing', 'failed']))
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
@ -12,11 +16,29 @@
<div class="flex flex-col gap-1">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Assigned Baseline</div>
<div class="text-lg font-semibold text-gray-950 dark:text-white">{{ $profileName ?? '—' }}</div>
@if ($snapshotId)
<x-filament::badge color="success" size="sm" class="w-fit">
Snapshot #{{ $snapshotId }}
</x-filament::badge>
@endif
<div class="flex flex-wrap items-center gap-2">
@if ($snapshotId)
<x-filament::badge color="success" size="sm" class="w-fit">
Snapshot #{{ $snapshotId }}
</x-filament::badge>
@endif
@if (filled($coverageStatus))
<x-filament::badge
:color="$coverageStatus === 'ok' ? 'success' : 'warning'"
size="sm"
class="w-fit"
>
Coverage: {{ $coverageStatus === 'ok' ? 'OK' : 'Warnings' }}
</x-filament::badge>
@endif
@if (filled($fidelity))
<x-filament::badge color="gray" size="sm" class="w-fit">
Fidelity: {{ Str::title($fidelity) }}
</x-filament::badge>
@endif
</div>
</div>
</x-filament::section>
@ -27,7 +49,7 @@
@if ($state === 'failed')
<div class="text-lg font-semibold text-danger-600 dark:text-danger-400">Error</div>
@else
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : 'text-success-600 dark:text-success-400' }}">
<div class="text-3xl font-bold {{ ($findingsCount ?? 0) > 0 ? 'text-danger-600 dark:text-danger-400' : ($hasCoverageWarnings ? 'text-warning-600 dark:text-warning-400' : 'text-success-600 dark:text-success-400') }}">
{{ $findingsCount ?? 0 }}
</div>
@endif
@ -36,8 +58,10 @@
<x-filament::loading-indicator class="h-3 w-3" />
Comparing…
</div>
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready')
@elseif (($findingsCount ?? 0) === 0 && $state === 'ready' && ! $hasCoverageWarnings)
<span class="text-sm text-success-600 dark:text-success-400">All clear</span>
@elseif ($state === 'ready' && $hasCoverageWarnings)
<span class="text-sm text-warning-600 dark:text-warning-400">Coverage warnings</span>
@endif
</div>
</x-filament::section>
@ -59,6 +83,47 @@
</div>
@endif
{{-- Coverage warnings banner --}}
@if ($state === 'ready' && $hasCoverageWarnings)
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 dark:border-warning-700 dark:bg-warning-950/40">
<div class="flex items-start gap-3">
<x-heroicon-s-exclamation-triangle class="h-6 w-6 shrink-0 text-warning-600 dark:text-warning-400" />
<div class="flex flex-col gap-1">
<div class="text-base font-semibold text-warning-900 dark:text-warning-200">
Comparison completed with warnings
</div>
<div class="text-sm text-warning-800 dark:text-warning-300">
@if (($coverageStatus ?? null) === 'unproven')
Coverage proof was missing or unreadable for the last comparison run, so findings were suppressed for safety.
@else
Findings were skipped for {{ (int) ($uncoveredTypesCount ?? 0) }} policy {{ Str::plural('type', (int) ($uncoveredTypesCount ?? 0)) }} due to incomplete coverage.
@endif
@if (! empty($uncoveredTypes))
<div class="mt-2 text-xs text-warning-800 dark:text-warning-300">
Uncovered: {{ implode(', ', array_slice($uncoveredTypes, 0, 6)) }}@if (count($uncoveredTypes) > 6)@endif
</div>
@endif
</div>
@if ($this->getRunUrl())
<div class="mt-2">
<x-filament::button
:href="$this->getRunUrl()"
tag="a"
color="warning"
outlined
icon="heroicon-o-queue-list"
size="sm"
>
View run
</x-filament::button>
</div>
@endif
</div>
</div>
</div>
@endif
{{-- Failed run banner --}}
@if ($state === 'failed')
<div class="rounded-lg border border-danger-300 bg-danger-50 p-4 dark:border-danger-700 dark:bg-danger-950/50">
@ -189,7 +254,7 @@
@endif
{{-- Ready: no drift --}}
@if ($state === 'ready' && ($findingsCount ?? 0) === 0)
@if ($state === 'ready' && ($findingsCount ?? 0) === 0 && ! $hasCoverageWarnings)
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-check-circle class="h-12 w-12 text-success-500" />
@ -213,6 +278,31 @@
</x-filament::section>
@endif
{{-- Ready: warnings, no findings --}}
@if ($state === 'ready' && ($findingsCount ?? 0) === 0 && $hasCoverageWarnings)
<x-filament::section>
<div class="flex flex-col items-center justify-center gap-3 py-6 text-center">
<x-heroicon-o-exclamation-triangle class="h-12 w-12 text-warning-500" />
<div class="text-lg font-semibold text-gray-950 dark:text-white">Coverage Warnings</div>
<div class="max-w-md text-sm text-gray-500 dark:text-gray-400">
The last comparison completed with warnings and produced no drift findings. Run Inventory Sync again to establish full coverage before interpreting results.
</div>
@if ($this->getRunUrl())
<x-filament::button
:href="$this->getRunUrl()"
tag="a"
color="gray"
outlined
icon="heroicon-o-queue-list"
size="sm"
>
Review last run
</x-filament::button>
@endif
</div>
</x-filament::section>
@endif
{{-- Idle state --}}
@if ($state === 'idle')
<x-filament::section>

View File

@ -1,4 +1,8 @@
<x-filament::page>
@php
$baselineCompareHasWarnings = in_array(($baselineCompareCoverageStatus ?? null), ['warning', 'unproven'], true);
@endphp
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-sm text-gray-600 dark:text-gray-300">
@ -35,6 +39,51 @@
</div>
@endif
@if ($baselineCompareRunId)
<div class="text-sm text-gray-600 dark:text-gray-300">
Baseline compare
@if ($this->getBaselineCompareRunUrl())
<a class="text-primary-600 hover:underline" href="{{ $this->getBaselineCompareRunUrl() }}">
#{{ $baselineCompareRunId }}
</a>
@else
#{{ $baselineCompareRunId }}
@endif
@if (filled($baselineCompareCoverageStatus))
· Coverage
<x-filament::badge :color="$baselineCompareCoverageStatus === 'ok' ? 'success' : 'warning'" size="sm">
{{ $baselineCompareCoverageStatus === 'ok' ? 'OK' : 'Warnings' }}
</x-filament::badge>
@endif
@if (filled($baselineCompareFidelity))
· Fidelity {{ Str::title($baselineCompareFidelity) }}
@endif
</div>
@endif
@if ($baselineCompareRunId && $baselineCompareHasWarnings)
<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">
@if (($baselineCompareCoverageStatus ?? null) === 'unproven')
Coverage proof was missing or unreadable for the last baseline comparison, so findings were suppressed for safety.
@else
Some policy types were uncovered in the last baseline comparison, so findings may be incomplete.
@endif
</div>
@if (! empty($baselineCompareUncoveredTypes))
<div class="mt-1 text-xs">
Uncovered: {{ implode(', ', array_slice($baselineCompareUncoveredTypes, 0, 6)) }}@if (count($baselineCompareUncoveredTypes) > 6)@endif
</div>
@endif
</div>
</div>
@endif
@if ($state === 'blocked')
<x-filament::badge color="gray">
Blocked

View File

@ -0,0 +1,46 @@
@php
/** @var bool $shouldShow */
/** @var ?string $runUrl */
/** @var ?string $coverageStatus */
/** @var ?string $fidelity */
/** @var int $uncoveredTypesCount */
/** @var list<string> $uncoveredTypes */
$coverageHasWarnings = in_array(($coverageStatus ?? null), ['warning', 'unproven'], true);
@endphp
<div>
@if ($shouldShow && $coverageHasWarnings)
<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">
@if (($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.
@endif
@if (filled($fidelity))
<span class="ml-1 text-xs text-warning-800 dark:text-warning-300">Fidelity: {{ Str::title($fidelity) }}</span>
@endif
</div>
@if (! empty($uncoveredTypes))
<div class="mt-1 text-xs">
Uncovered: {{ implode(', ', array_slice($uncoveredTypes, 0, 6)) }}@if (count($uncoveredTypes) > 6)@endif
</div>
@endif
@if (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
</a>
</div>
@endif
</div>
</div>
@endif
</div>

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Baseline Drift Engine (Final Architecture)
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-01
**Feature**: [specs/116-baseline-drift-engine/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
- This spec uses internal domain terms like “Operation run”, “capability”, and “hash fidelity” intentionally; they are defined in-context and treated as product concepts rather than framework implementation.

View File

@ -0,0 +1,157 @@
openapi: 3.0.3
info:
title: Spec 116 - Baseline Drift Engine
version: 0.1.0
description: |
Minimal contracts for Baseline capture/compare operations and finding summaries.
This repo is primarily Filament-driven; these endpoints represent conceptual contracts
or internal routes/services rather than guaranteed public APIs.
servers:
- url: /
paths:
/internal/baselines/{baselineProfileId}/snapshots:
post:
summary: Capture a baseline snapshot
parameters:
- in: path
name: baselineProfileId
required: true
schema:
type: integer
requestBody:
required: false
responses:
'202':
description: Snapshot capture queued
content:
application/json:
schema:
$ref: '#/components/schemas/OperationQueued'
/internal/baselines/{baselineProfileId}/compare:
post:
summary: Compare baseline snapshot to tenant inventory
parameters:
- in: path
name: baselineProfileId
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/BaselineCompareRequest'
responses:
'202':
description: Compare queued
content:
application/json:
schema:
$ref: '#/components/schemas/OperationQueued'
/internal/tenants/{tenantId}/findings:
get:
summary: List findings for a tenant (filtered)
parameters:
- in: path
name: tenantId
required: true
schema:
type: integer
- in: query
name: scope_key
required: false
schema:
type: string
- in: query
name: status
required: false
schema:
type: string
enum: [open, resolved]
responses:
'200':
description: Findings list
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Finding'
components:
schemas:
BaselineCompareRequest:
type: object
required: [tenant_id]
properties:
tenant_id:
type: integer
baseline_snapshot_id:
type: integer
nullable: true
description: Optional explicit snapshot selection. If omitted, latest successful snapshot is used.
OperationQueued:
type: object
required: [operation_run_id]
properties:
operation_run_id:
type: integer
Finding:
type: object
required: [id, tenant_id, fingerprint, scope_key, created_at]
properties:
id:
type: integer
tenant_id:
type: integer
fingerprint:
type: string
description: Stable identifier; for baseline drift equals recurrence_key.
recurrence_key:
type: string
nullable: true
scope_key:
type: string
change_type:
type: string
nullable: true
policy_type:
type: string
nullable: true
subject_external_id:
type: string
nullable: true
evidence:
type: object
additionalProperties: true
first_seen_at:
type: string
format: date-time
nullable: true
last_seen_at:
type: string
format: date-time
nullable: true
times_seen:
type: integer
nullable: true
resolved_at:
type: string
format: date-time
nullable: true
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time

View File

@ -0,0 +1,123 @@
# Phase 1 — Data Model (Baseline Drift Engine)
This document identifies the data/entities involved in Spec 116 and the minimal schema/config changes needed to implement it in this repository.
## Existing Entities (Confirmed)
### BaselineProfile
Represents a baseline definition.
- Fields (confirmed in migrations): `id`, `workspace_id`, `name`, `description`, `version_label`, `status`, `scope_jsonb` (jsonb), `active_snapshot_id`, `created_by_user_id`, timestamps
- Relationships: has many snapshots; assigned to tenants via `BaselineTenantAssignment`
### BaselineSnapshot
Immutable capture of baseline state at a point in time.
- Fields (confirmed in migrations): `id`, `workspace_id`, `baseline_profile_id`, `snapshot_identity_hash`, `captured_at`, `summary_jsonb` (jsonb), timestamps
- Relationships: has many items; belongs to baseline profile
### BaselineSnapshotItem
One item in a baseline snapshot.
- Fields (confirmed in migrations):
- `id`, `baseline_snapshot_id`
- `subject_type`
- `subject_external_id`
- `policy_type`
- `baseline_hash` (string)
- `meta_jsonb` (jsonb)
- timestamps
### Finding
Generic drift finding storage.
- Fields (confirmed by usage): `tenant_id`, `fingerprint` (unique with tenant), `recurrence_key` (nullable), `scope_key`, lifecycle fields (`first_seen_at`, `last_seen_at`, `times_seen`), evidence (jsonb)
### OperationRun
Tracks long-running operations.
- Fields (by convention): `type`, `status/outcome`, `summary_counts` (numeric map), `context` (jsonb)
## New / Adjusted Data Requirements
### 1) Inventory sync coverage context
**Goal:** Baseline compare must know which policy types were actually processed successfully by inventory sync.
**Where:** `operation_runs.context` for the latest inventory sync run.
**Shape (proposed):**
```json
{
"inventory": {
"coverage": {
"policy_types": {
"deviceConfigurations": {"status": "succeeded", "item_count": 123},
"compliancePolicies": {"status": "failed", "error": "..."}
},
"foundation_types": {
"securityBaselines": {"status": "succeeded", "item_count": 4}
}
}
}
}
```
**Notes:**
- Only `summary_counts` must remain numeric; detailed coverage lists live in `context`.
- For Spec 116 v1, its sufficient to store `policy_types` coverage; adding `foundation_types` coverage at the same time keeps parity with scope rules.
### 2) Baseline scope schema
**Goal:** Support both policy and foundation scope with correct defaults.
**Current:** `policy_types` only.
**Target:**
```json
{
"policy_types": ["deviceConfigurations", "compliancePolicies"],
"foundation_types": ["securityBaselines"]
}
```
**Default semantics:**
- Empty `policy_types` means “all supported policy types excluding foundations”.
- Empty `foundation_types` means “none”.
### 3) Findings recurrence strategy
**Goal:** Stable identity per snapshot and per subject.
- `findings.recurrence_key`: populated for baseline compare findings.
- `findings.fingerprint`: set to the same recurrence key (to satisfy existing uniqueness constraint).
**Recurrence key inputs:**
- `tenant_id`
- `baseline_snapshot_id`
- `policy_type`
- `subject_external_id`
- `change_type`
**Grouping (scope_key):**
- Keep `findings.scope_key = baseline_profile:{baselineProfileId}` for baseline compare findings.
### 4) Inventory meta contract
**Goal:** Explicitly define what is hashed for v1 comparisons.
- Implemented as a dedicated builder class (no schema change required).
- Used by baseline capture to compute `baseline_hash` and by compare to compute `current_hash`.
- Persist the exact contract payload used for hashing to `baseline_snapshot_items.meta_jsonb.meta_contract` (versioned) for auditability/reproducibility.
## Potential Migrations (Likely)
- If `baseline_profiles.scope` is not jsonb or does not include foundation types → migration to adjust structure (jsonb stays the same, but add support in code; DB change may be optional).
- If coverage context needs persistence beyond operation run context → avoid adding tables unless proven necessary; context-based is sufficient for v1.
## Index / Performance Notes
- Findings queries commonly filter by `tenant_id` + `scope_key`; ensure there is an index on `(tenant_id, scope_key)`.
- Baseline snapshot items must be efficiently loaded by `(baseline_snapshot_id, policy_type)`.

View File

@ -0,0 +1,258 @@
# Implementation Plan: 116 — Baseline Drift Engine (Final Architecture)
**Branch**: `116-baseline-drift-engine` | **Date**: 2026-03-01 | **Spec**: `specs/116-baseline-drift-engine/spec.md`
**Input**: Feature specification from `specs/116-baseline-drift-engine/spec.md`
## Summary
Align the existing baseline capture/compare pipeline to Spec 116 by (1) defining an explicit meta-fidelity hash contract, (2) enforcing the “coverage guard” based on the latest inventory sync run, and (3) switching baseline-compare findings to snapshot-scoped stable identities (recurrence keys) while preserving existing baseline-profile grouping for UI/stats and auto-close semantics.
## 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)
**Project Type**: Web application (Laravel)
**Performance Goals**: Compare jobs must remain bounded by scope size; avoid N+1 queries when loading snapshot + current inventory
**Constraints**:
- Ops-UX: OperationRun lifecycle + 3-surface feedback (toast queued-only, progress in widget/run detail, terminal DB notification exactly-once)
- Summary counts numeric-only and keys restricted to `OperationSummaryKeys`
- Tenant/workspace isolation + RBAC deny-as-not-found rules
**Scale/Scope**: Tenant inventories may be large; baseline compare must be efficient on `(tenant_id, policy_type)` filtering
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS — compare uses Inventory as last observed state; baselines are immutable snapshots.
- Read/write separation: PASS — this feature is read-only analysis; no Graph writes.
- Graph contract path: PASS — inventory sync already uses `GraphClientInterface`; baseline compare itself is DB-only at render time.
- Deterministic capabilities: PASS — baseline capability checks use existing registries/policies; no new ad-hoc strings.
- Workspace + tenant isolation: PASS — baseline profiles are workspace-owned; runs/findings are tenant-owned; authorization remains deny-as-not-found for non-members.
- Run observability (OperationRun): PASS — capture/compare already use OperationRun + queued jobs.
- Ops-UX 3-surface feedback: PASS — existing pages use canonical queued toast presenter.
- Ops-UX lifecycle: PASS — transitions must remain inside `OperationRunService`.
- Ops-UX summary counts: PASS — only numeric summary counts using canonical keys.
- Filament UI contract: PASS — only small scope-picker adjustments; no new pages beyond what exists.
- Filament UX-001 layout: PASS — Baseline Profile Create/Edit will be updated to a Main/Aside layout as part of the scope-picker work.
## Project Structure
### Documentation (this feature)
```text
specs/116-baseline-drift-engine/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── openapi.yaml
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/ # Baseline compare landing + run detail links (existing)
│ ├── Resources/ # BaselineProfileResource (existing)
│ └── Widgets/ # Baseline compare widgets (existing)
├── Jobs/
│ ├── CaptureBaselineSnapshotJob.php
│ └── CompareBaselineToTenantJob.php
├── Models/
│ ├── BaselineProfile.php
│ ├── BaselineSnapshot.php
│ ├── BaselineSnapshotItem.php
│ ├── Finding.php
│ ├── InventoryItem.php
│ └── OperationRun.php
├── Services/
│ ├── Baselines/
│ │ ├── BaselineCaptureService.php
│ │ ├── BaselineCompareService.php
│ │ ├── BaselineAutoCloseService.php
│ │ └── BaselineSnapshotIdentity.php
│ ├── Drift/
│ │ ├── DriftFindingGenerator.php
│ │ └── DriftHasher.php
│ ├── Inventory/
│ │ └── InventorySyncService.php
│ └── OperationRunService.php
└── Support/
├── Baselines/BaselineCompareStats.php
└── OpsUx/OperationSummaryKeys.php
tests/
└── Feature/
└── Baselines/
├── BaselineCompareFindingsTest.php
├── BaselineComparePreconditionsTest.php
├── BaselineCompareStatsTest.php
└── BaselineOperabilityAutoCloseTest.php
```
**Structure Decision**: Web application (Laravel 12) — all work stays in existing `app/` services/jobs/models and `tests/Feature`.
## Complexity Tracking
No constitution violations are required for this feature.
## Phase 0 — Outline & Research (DONE)
Outputs:
- `specs/116-baseline-drift-engine/research.md`
Key reconciliations captured:
- Baseline compare finding identity will move to recurrence-key based upsert (snapshot-scoped identity) aligned with the existing `DriftFindingGenerator` pattern.
- Coverage guard requires persisting per-type coverage outcomes into the latest inventory sync run context.
- Scope must include `policy_types` + `foundation_types` with correct empty-default semantics.
## Phase 1 — Design & Contracts (DONE)
Outputs:
- `specs/116-baseline-drift-engine/data-model.md`
- `specs/116-baseline-drift-engine/contracts/openapi.yaml`
- `specs/116-baseline-drift-engine/quickstart.md`
Design highlights:
- Coverage lives in `operation_runs.context` for inventory sync runs (detailed lists), while `summary_counts` remain numeric-only.
- Findings use `recurrence_key` and `fingerprint = recurrence_key` for idempotent upserts.
- Findings remain grouped by `scope_key = baseline_profile:{id}` to preserve existing UI/stats and auto-close behavior.
## Phase 1 — Agent Context Update (REQUIRED)
Run:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Phase 2 — Implementation Plan
### Step 1 — Baseline scope schema + UI picker
Goal: implement FR-116v1-01 and FR-116v1-02.
Changes:
- Update baseline scope handling (`app/Support/Baselines/BaselineScope.php`) to support:
- `policy_types: []` meaning “all supported policy types excluding foundations”
- `foundation_types: []` meaning “none”
- Update `BaselineProfile` form schema (Filament Resource) to show multi-selects for Policy Types and Foundations.
- Document selector-to-config mapping (source of truth for option lists + defaults):
| Selector | Form state path | Options source | Default semantics |
|---|---|---|---|
| Policy Types | `scope_jsonb.policy_types` | `config('tenantpilot.supported_policy_types')` via `App\Support\Inventory\InventoryPolicyTypeMeta::supported()` | Empty ⇒ all supported policy types (**excluding foundations**) |
| Foundations | `scope_jsonb.foundation_types` | `config('tenantpilot.foundation_types')` via `App\Support\Inventory\InventoryPolicyTypeMeta::foundations()` | Empty ⇒ none |
Notes:
- Inventory sync selection uses `App\Services\BackupScheduling\PolicyTypeResolver::supportedPolicyTypes()` for policy types, and `InventorySyncService::foundationTypes()` (derived from `config('tenantpilot.foundation_types')`) when `include_foundations=true`.
Tests:
- Update/add Pest tests around scope expansion defaults (prefer a focused unit-like test if an expansion helper exists).
### Step 2 — Inventory Meta Contract (explicit hash input)
Goal: implement FR-116v1-04, FR-116v1-05, FR-116v1-06, FR-116v1-06a.
Changes:
- Introduce a dedicated contract builder (e.g. `App\Services\Baselines\InventoryMetaContract`) that returns a normalized array for hashing.
- Contract output must be explicitly versioned (e.g., `meta_contract.version = 1`) so future additions do not retroactively change v1 semantics.
- Contract signals are best-effort: missing signals are represented as `null` (not omitted) to keep hashing stable across partial inventories.
- Update baseline capture hashing (`BaselineSnapshotIdentity::hashItemContent()` or the capture service) to hash the contract output only.
- Persist the exact contract payload used for hashing to `baseline_snapshot_items.meta_jsonb.meta_contract` for auditability/reproducibility.
- Persist observation metadata alongside the hash in `baseline_snapshot_items.meta_jsonb` (at minimum: `fidelity`, `source`, `observed_at`; when available: `observed_operation_run_id`).
- Update baseline compare to compute `current_hash` using the same contract builder.
- Current-state `observed_at` is derived from persisted inventory evidence (`inventory_items.last_seen_at`) and MUST NOT require per-item external hydration calls during compare.
- Define “latest successful snapshot” (v1) as `baseline_profiles.active_snapshot_id` and ensure compare start is blocked when it is `null` (no “pick newest captured_at” fallback).
Tests:
- Add a small Pest test for contract normalization stability (ordering, missing fields, nullability) in `tests/Unit/Baselines/InventoryMetaContractTest.php`.
- Update baseline capture/compare tests if they currently assume hashing full `meta_jsonb`.
### Step 3 — Inventory sync coverage recording
Goal: provide coverage for FR-116v1-07.
Changes:
- Extend inventory sync pipeline (in `App\Services\Inventory\InventorySyncService` and/or the job that orchestrates sync) to write a coverage payload into the inventory sync `OperationRun.context`:
- Per policy type: status (`succeeded|failed|skipped`) and optional `item_count`.
- Foundations can be included in the same shape if they are part of selection.
- Ensure this is written even when some types fail, so downstream compare can determine uncovered types.
Tests:
- Add/extend tests around inventory sync operation context writing (mocking Graph calls as needed; keep scope minimal).
### Step 4 — Baseline compare coverage guard + outcome semantics
Goal: implement FR-116v1-07 and align to Ops-UX.
Changes:
- In baseline compare job/service:
- Resolve the latest inventory sync run for the tenant.
- Compute `covered_policy_types` from sync run context.
- Compute `uncovered_policy_types = effective_scope.policy_types - covered_policy_types`.
- Skip emission of *all* finding types for uncovered policy types.
- Record coverage details into the compare run `context` for auditability.
- If uncovered types exist, set compare outcome to `partially_succeeded` via `OperationRunService` and set `summary_counts.errors_recorded = count(uncovered_policy_types)`.
- If effective scope expands to zero types, complete as `partially_succeeded` and set `summary_counts.errors_recorded = 1` so the warning remains visible under numeric-only summary counts.
- If there is no completed inventory sync run (or coverage proof is missing/unreadable), treat coverage as unproven for all effective-scope types (fail-safe): emit zero findings and complete as `partially_succeeded`.
Tests:
- Add a new Pest test in `tests/Feature/Baselines` asserting:
- uncovered types cause partial outcome
- uncovered types produce zero findings (even if snapshot/current data would otherwise create missing/unexpected/different)
- covered types still produce findings
### Step 5 — Snapshot-scoped stable finding identity
Goal: implement FR-116v1-09 and FR-116v1-10.
Changes:
- Replace hash-evidence-based `fingerprint` generation in baseline compare with a stable recurrence key:
- Inputs: `tenant_id`, `baseline_snapshot_id`, `policy_type`, `subject_external_id`, `change_type`
- Persist:
- `findings.recurrence_key = <computed>`
- `findings.fingerprint = <same computed>`
- Keep `scope_key = baseline_profile:{baselineProfileId}`.
- Ensure retry idempotency: do not increment lifecycle counters more than once per run identity.
Tests:
- Update `tests/Feature/Baselines/BaselineCompareFindingsTest.php`:
- Ensure fingerprint no longer depends on baseline/current hash.
- Assert stable identity across re-runs with changed evidence hashes.
- Add coverage for “recapture uses new snapshot id → new finding identity”.
### Step 6 — Auto-close + stats compatibility
Goal: preserve existing operability expectations and keep UI stable.
Changes:
- Ensure `BaselineAutoCloseService` still resolves stale findings after a fully successful compare, even though identities now include snapshot id.
- Confirm `BaselineCompareStats` remains correct for grouping by `scope_key = baseline_profile:{id}`.
Tests:
- Update/keep `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php` passing.
- Update `tests/Feature/Baselines/BaselineCompareStatsTest.php` only if scope semantics change.
### Step 7 — Ops UX + auditability
Goal: implement FR-116v1-03 and FR-116v1-11.
Changes:
- Ensure both capture and compare runs write:
- `effective_scope.*` in run context
- coverage summary and uncovered lists when partial
- numeric summary counts using canonical keys only
- per-change-type finding counts in `operation_runs.context.findings.counts_by_change_type`
- Treat the `operation_runs` record as the canonical audit trail for this feature slice (do not add parallel “audit summary” persistence for the same data).
Tests:
- Add a regression test that asserts `summary_counts` contains only allowed keys and numeric values (where a helper exists).
## Post-design Constitution Re-check
Expected: PASS (no changes introduce new Graph endpoints or bypass services; OperationRun lifecycle + 3-surface feedback remain intact; RBAC deny-as-not-found semantics preserved).

View File

@ -0,0 +1,62 @@
# Quickstart — Spec 116 Baseline Drift Engine
This quickstart is for developers validating the behavior locally.
## Prerequisites
- Sail up: `vendor/bin/sail up -d`
- Install deps (if needed): `vendor/bin/sail composer install`
- Run migrations: `vendor/bin/sail artisan migrate`
## Workflow
### 1) Run an inventory sync (establish coverage)
- Trigger inventory sync for a tenant using the existing UI/command for inventory sync.
- Verify the latest inventory sync `operation_runs.context` contains a coverage payload:
- `inventory.coverage.policy_types.{type}.status = succeeded|failed`
- (optionally) `inventory.coverage.foundation_types.{type}.status = ...`
Expected:
- If some types fail or are not processed, the coverage payload reflects that.
### 2) Capture a baseline snapshot
- Use the Baseline Profile UI to capture a snapshot.
Expected:
- Snapshot items store `baseline_hash` computed from the Inventory Meta Contract.
- Snapshot identity/dedupe follows existing snapshot identity rules, but content hashes come from the explicit contract.
### 3) Compare baseline to tenant
- Trigger “Compare now” from the Baseline Compare landing page.
Expected:
- Compare uses latest successful baseline snapshot by default (or explicit snapshot selection if provided).
- Compare uses the latest **completed** inventory sync run coverage:
- For uncovered policy types, **no findings are emitted**.
- OperationRun outcome becomes “completed with warnings” (partial) when uncovered types exist.
- `summary_counts.errors_recorded = count(uncovered_types)`.
- Edge case: if effective scope expands to zero types, outcome is still partial (warnings) and `summary_counts.errors_recorded = 1`.
- Fail-safe: if there is **no completed inventory sync run** or the coverage payload is missing/unreadable, coverage is treated as **unproven** for all effective types:
- **no findings are emitted**
- outcome is partial (warnings)
- compare run context includes `baseline_compare.coverage.proof = false`
- Findings identity:
- stable `recurrence_key` uses `baseline_snapshot_id` and does **not** include baseline/current hashes.
- `fingerprint == recurrence_key`.
- `scope_key` remains profile-scoped (`baseline_profile:{id}`).
### 4) Validate UI counts
- Verify baseline compare stats remain grouped by the profile scope (`scope_key = baseline_profile:{id}`), consistent with research.md Decision #2.
- Validate that re-capturing (new snapshot) creates a new set of findings due to snapshot-scoped identity (recurrence key includes `baseline_snapshot_id`).
## Minimal smoke test checklist
- Compare with full coverage: produces correct findings; outcome success.
- Compare with partial coverage: produces findings only for covered types; outcome partial; uncovered types listed in context.
- Compare with unproven coverage (no completed sync / missing coverage): emits zero findings; outcome partial; warning visible in UI.
- Re-run compare with no changes: no new findings; `times_seen` increments.
- Re-capture snapshot and compare: findings identity changes (snapshot-scoped).

View File

@ -0,0 +1,104 @@
# Phase 0 — Research (Baseline Drift Engine)
This document resolves the open design/implementation questions needed to produce a concrete implementation plan for Spec 116, grounded in the current codebase.
## Repo Reality Check (What already exists)
- Baseline domain tables exist: `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`.
- Baseline ops exist:
- Capture: `App\Services\Baselines\BaselineCaptureService``App\Jobs\CaptureBaselineSnapshotJob`.
- Compare: `App\Services\Baselines\BaselineCompareService``App\Jobs\CompareBaselineToTenantJob`.
- Findings lifecycle primitives exist (times_seen/first_seen/last_seen) and recurrence support exists (`findings.recurrence_key`).
- An existing recurrence-based drift generator exists: `App\Services\Drift\DriftFindingGenerator` (uses `recurrence_key` and also sets `fingerprint = recurrence_key` to satisfy the unique constraint).
- Inventory sync is OperationRun-based and stamps `inventory_items.last_seen_operation_run_id`.
## Decisions
### 1) Finding identity for baseline compare
**Decision:** Baseline compare findings MUST use a stable recurrence key derived from:
- `tenant_id`
- `baseline_snapshot_id` (not baseline profile id)
- `policy_type`
- `subject_external_id`
- `change_type`
This recurrence key is stored in `findings.recurrence_key` and ALSO used as `findings.fingerprint` (to keep the existing unique constraint `unique(tenant_id, fingerprint)` effective).
**Rationale:**
- Matches Spec 116 (identity tied to `baseline_snapshot_id` and independent of evidence hashes).
- Aligns with existing, proven pattern in `DriftFindingGenerator` (recurrence_key-based upsert; fingerprint reused).
**Alternatives considered:**
- Keep `DriftHasher::fingerprint(...)` with baseline/current hashes included → rejected because it changes identity when evidence changes (violates FR-116v1-09).
- Add a new unique DB constraint on `(tenant_id, recurrence_key)` → possible later hardening; not required initially because fingerprint uniqueness already enforces dedupe when `fingerprint = recurrence_key`.
### 2) Scope key for baseline compare findings
**Decision:** Keep findings grouped by baseline profile using `scope_key = baseline_profile:{baselineProfileId}`.
**Rationale:**
- Spec 116 requires snapshot-scoped *identity* (via `baseline_snapshot_id` in the recurrence key), but does not require snapshot-scoped grouping.
- The repository already has UI widgets/stats and auto-close behavior keyed to `baseline_profile:{id}`; keeping scope_key stable minimizes churn and preserves existing semantics.
- Re-captures still create new finding identities because the recurrence key includes `baseline_snapshot_id`.
**Alternatives considered:**
- Snapshot-scoped `scope_key = baseline_snapshot:{id}` → rejected for v1 because it would require larger refactors to stats, widgets, and auto-close queries, without being mandated by the spec.
### 3) Coverage guard (prevent false missing policies)
**Decision:** Coverage MUST be derived from the latest completed `inventory_sync` OperationRun for the tenant:
- Record per-policy-type processing outcomes into that runs context (coverage payload).
- Baseline compare MUST compute `uncovered_policy_types = effective_scope - covered_policy_types`.
- Baseline compare MUST emit **no findings of any kind** for uncovered policy types.
- The compare OperationRun outcome should be `partially_succeeded` when uncovered types exist ("completed with warnings" in Ops UX), and summary counts should include `errors_recorded = count(uncovered_policy_types)`.
**Rationale:**
- Spec FR-116v1-07 and SC-116-03.
- Current compare logic uses `inventory_items.last_seen_operation_run_id` filter only; without an explicit coverage list, a missing type looks identical to a truly empty tenant.
**Alternatives considered:**
- Infer coverage purely from "were there any inventory items for this policy type in the last sync run" → rejected because a legitimately empty type would be indistinguishable from "not synced".
### 4) Inventory meta contract hashing (v1 fidelity=meta)
**Decision:** Introduce an explicit "Inventory Meta Contract" builder used by BOTH capture and compare in v1.
- Inputs: `policy_type`, `external_id`, and a whitelist of stable signals from inventory/meta (etag, last modified, scope tags, assignment target count, version marker when available).
- Output: a normalized associative array, hashed deterministically.
**Rationale:**
- Spec FR-116v1-04: hashing must be based on a stable contract, not arbitrary meta.
- Current `BaselineSnapshotIdentity::hashItemContent()` hashes the entire `meta_jsonb` (including keys like `etag` which may be noisy and keys that may expand over time).
**Alternatives considered:**
- Keep current hashing of `meta_jsonb` → rejected because it is not an explicit contract and may drift as we add inventory metadata.
### 5) Baseline scope + foundations
**Decision:** Extend baseline scope JSON to include:
- `policy_types: []` (empty means default "all supported policy types excluding foundations")
- `foundation_types: []` (empty means default "none")
Foundations list must be derived from the same canonical foundation list used by inventory sync selection logic.
**Rationale:**
- Spec FR-116v1-01.
- Current `BaselineScope` only supports `policy_types` and treats empty as "all" (including foundations) which conflicts with the spec default.
### 6) v2 architecture strategy (content fidelity)
**Decision:** v2 is implemented as an extension of the same pipeline via a provider precedence chain:
`PolicyVersion (if available) → Inventory content (if available) → Meta contract fallback (degraded)`
The baseline compare engine stores dimension flags on the same finding (no additional finding identities).
**Rationale:**
- Spec FR-116v2-01 and FR-116v2-05.
- There is already a content-normalization + hashing stack in `DriftFindingGenerator` (policy snapshot / assignments / scope tags) which can inform the content fidelity provider.
## Notes / Risks
- Existing baseline compare findings are currently keyed by `fingerprint` that includes baseline/current hashes and uses `scope_key = baseline_profile:{id}`. The v1 migration should plan for “old findings become stale” behavior; do not attempt silent in-place identity rewriting without an explicit migration/backfill plan.
- Coverage persistence must remain numeric-only in `summary_counts` (per Ops-UX). Detailed coverage lists belong in `operation_runs.context`.

View File

@ -0,0 +1,236 @@
# Feature Specification: Baseline Drift Engine (Final Architecture)
**Feature Branch**: `116-baseline-drift-engine`
**Created**: 2026-03-01
**Status**: Draft
**Input**: User description: "Spec 116 — Baseline Drift Engine (Final Architecture)"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace (baseline definition + capture) + tenant (baseline compare monitoring)
- **Primary Routes**:
- Workspace (admin): Baseline Profiles (create/edit scope, capture baseline)
- Tenant-context (admin): Baseline Compare runs (compare now, run detail) and Drift Findings landing
- **Data Ownership**:
- Workspace-owned: Baseline profiles and baseline snapshots
- Tenant-scoped (within a workspace): Operation runs for baseline capture/compare; drift findings produced by compare
- Baseline snapshots are workspace-owned standards captured from a chosen tenant, but snapshot items MUST NOT persist tenant identifiers (e.g., no `tenant_id` column on snapshot items).
- **RBAC**:
- Workspace (Baselines):
- `workspace_baselines.view`: view baseline profiles + snapshots
- `workspace_baselines.manage`: create/edit/archive baseline profiles, start capture runs
- Tenant (Compare):
- `tenant.sync`: start baseline compare runs
- `tenant_findings.view`: view drift findings
- Tenant access is required for tenant-context surfaces, in addition to workspace membership
For canonical-view specs: not applicable (this is not a canonical-view feature).
## Clarifications
### Session 2026-03-01
- Q: Should finding identity be stable across baseline re-captures, or tied to a specific baseline snapshot? → A: Tie finding identity to `baseline_snapshot_id` (stable within a snapshot; re-capture creates new finding identities).
- Q: In v2, should drift dimensions be stored as flags on a single finding, or as separate findings per dimension? → A: Use one finding with dimension flags (no separate findings per dimension).
- Q: When running a compare, which baseline snapshot should be used by default? → A: Default to the baseline profiles `active_snapshot_id` (updated only by successful captures); allow explicitly selecting a snapshot.
- Q: When coverage is missing for a policy type, should compare emit any findings for that type? → A: Skip all finding emission for uncovered types (no `missing_policy`, no `unexpected_policy`, no `different_version`).
## Outcomes
- **O-1 One engine**: There is exactly one baseline drift compare engine; no parallel legacy compare/hash paths.
- **O-2 Stable findings (recurrence)**: The same underlying drift maps to the same finding identity across retries and across runs, with lifecycle counters.
- **O-3 Auditability & operator UX**: Each compare run records scope, coverage, and fidelity; partial coverage produces warnings (not misleading “missing policy” noise).
- **O-4 No legacy logic after v2**: After the v2 extension, there are no “meta compare here / diff there” special cases; all drift flows through the same pipeline.
## Definitions
- **Subject key**: A compare object identity independent of tenant, identified by `(policy_type, external_id)`.
- **Tenant subject**: A subject key within a tenant context, identified by `(tenant_id, policy_type, external_id)`.
- **Policy state**: A normalized representation of a tenant subject, containing a deterministic hash, fidelity, and observation metadata.
- **Fidelity**:
- **meta**: drift signal based on a stable “inventory meta contract” (signal-based fields)
- **content**: drift signal based on canonicalized policy content (semantic)
- **Effective scope**: The expanded set of policy types processed by a run.
- **Coverage**: Which policy types are confirmed to be present/updated in the tenant current state at the time of compare.
## Assumptions
- Baseline drift is sold as “signal-based drift detection” in v1 (meta fidelity), and later upgraded to deep drift (content fidelity) without changing the compare engine semantics.
- The system already has a tenant-scoped inventory sync mechanism capable of recording per-run coverage of which policy types were synced.
- Foundations are treated as opt-in policy types; they are excluded unless explicitly selected.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Capture and compare a baseline with stable findings (Priority: P1)
As a workspace admin, I want to define a baseline scope, capture a baseline snapshot, and compare a tenant against that baseline, so I can reliably detect and track drift over time.
**Why this priority**: This is the core product slice that makes baseline drift sellable: consistent capture, consistent compare, and stable findings.
**Independent Test**: Can be tested by creating a baseline profile with a defined scope, capturing a snapshot, running compare twice, and verifying stable finding identity and lifecycle counters.
**Acceptance Scenarios**:
1. **Given** a baseline profile with scope “all policy types (excluding foundations)”, **When** I capture a baseline snapshot, **Then** the snapshot contains only in-scope policy subjects and each snapshot item records its hash and fidelity.
2. **Given** a captured baseline snapshot and a tenant current state, **When** I run compare twice with the same inputs, **Then** the same drift maps to the same finding identity and lifecycle counters increment at most once per run.
---
### User Story 2 - Coverage warnings prevent misleading missing-policy findings (Priority: P1)
As an operator, I want the compare run to warn when current-state coverage is partial, so that missing policies are not falsely reported when the system simply lacks data.
**Why this priority**: Trust depends on avoiding false negatives/positives; “missing policy” findings on partial sync is unacceptable noise.
**Independent Test**: Can be tested by running compare with an effective scope where some policy types are intentionally marked as not synced, verifying warning outcome and suppression behavior.
**Acceptance Scenarios**:
1. **Given** a compare run where some policy types in effective scope were not synced, **When** compare is executed, **Then** the run completes with warnings and produces no findings at all for those missing-coverage types.
2. **Given** a compare run where coverage is complete, **When** a baseline policy subject is missing in current state for a covered type, **Then** a missing-policy finding is produced.
---
### User Story 3 - Operators can understand scope, coverage, and fidelity in the UI (Priority: P2)
As an operator, I want drift screens to clearly show what was compared (scope), how complete the data was (coverage), and how “deep” the drift signal is (fidelity), so I can interpret findings correctly.
**Why this priority**: Drift findings are only actionable when the operator understands context and limitations.
**Independent Test**: Can be tested by executing a compare run with and without coverage warnings, verifying that run detail and drift landing surfaces render scope counts, coverage badge, and fidelity indicators.
**Acceptance Scenarios**:
1. **Given** a compare run with full coverage, **When** I open run detail, **Then** I see the compared scope and a coverage status of OK.
2. **Given** a compare run with partial coverage, **When** I open the drift landing and run detail, **Then** I see a warning banner and can see which types were missing coverage.
### Edge Cases
- Compare is retried after a transient failure: findings are not duplicated; lifecycle increments happen at most once per run identity.
- Baseline capture is executed with empty scope lists (interpreted as default semantics): policy types means “all supported types excluding foundations”; foundations list means “none”.
- Effective scope expands to zero types (e.g., no supported types): run completes with an explicit warning and produces no findings.
- Policy subjects appear/disappear between inventory sync and compare: handled according to coverage rules; does not create missing-policy noise for uncovered types.
- Two different policy subjects accidentally share an external identifier across types: identity is still unambiguous because `policy_type` is part of the subject key.
## Requirements *(mandatory)*
This feature introduces/extends long-running compare work and uses `OperationRun` for capture and compare runs.
It must comply with:
- **Run observability**: Every capture/compare run must have a visible run identity, scope context, coverage context, and outcome.
- **Safety**: Compare must never claim missing policies for policy types where current-state coverage is not proven.
- **Tenant isolation**: Inventory items, operation runs, and findings are tenant-scoped; cross-tenant access must be deny-as-not-found. Baseline profiles/snapshots are workspace-owned and must not persist tenant identifiers.
### Operational UX Contract (Ops-UX)
- Capture and compare run lifecycle transitions are service-owned (not UI-owned).
- Run summaries provide numeric-only counters using ONLY keys from `app/Support/OpsUx/OperationSummaryKeys.php`.
- Coverage warnings MUST be represented using an existing canonical numeric key (default: `errors_recorded`).
- Warning semantics mapping (canonical):
- Any “completed with warnings” case MUST be represented as `OperationRun.outcome = partially_succeeded`.
- `summary_counts.errors_recorded` MUST be a numeric indicator of warning magnitude.
- Default: number of uncovered policy types in effective scope.
- Edge case (effective scope expands to zero types): `summary_counts.errors_recorded = 1` so the warning remains visible under the numeric-only summary_counts contract.
- Scheduled/system-initiated runs (if any) must not generate user terminal DB notifications; audit is handled via monitoring surfaces.
- Regression guard tests are added/updated to enforce correct run outcome semantics and summary counter rules.
### Authorization Contract (RBAC-UX)
- Workspace membership + capability gates:
- `workspace_baselines.view` is required to view baseline profiles and snapshots.
- `workspace_baselines.manage` is required to create/edit/archive baseline profiles and start capture runs.
- `tenant.sync` is required to start compare runs.
- `tenant_findings.view` is required to view drift findings.
- 404 vs 403 semantics:
- Non-member or not entitled to workspace/tenant scope → 404 (deny-as-not-found)
- Member but missing capability → 403
- Destructive-like actions (e.g., archiving a baseline profile) require an explicit confirmation step.
- At least one positive and one negative authorization test exist for each mutation surface.
### Functional Requirements
#### v1 — Meta-fidelity baseline compare (sellable)
- **FR-116v1-01 Baseline profile scope**: Baseline profiles MUST store a scope object with `policy_types` and `foundation_types` lists.
- Default semantics: `policy_types = []` means all supported policy types excluding foundations; `foundation_types = []` means no foundations.
- Foundations MUST only be included when explicitly selected.
- **FR-116v1-02 UI scope picker**: The UI MUST provide multi-select controls for Policy Types and Foundations and communicate the default semantics (empty selection = default behavior).
- **FR-116v1-03 Effective scope recorded on runs**: Capture and compare runs MUST record expanded effective scope in run context:
- `effective_scope.policy_types[]`, `effective_scope.foundation_types[]`, `effective_scope.all_types[]`, and a boolean `effective_scope.foundations_included`.
- **FR-116v1-04 Inventory meta contract**: The system MUST define and persist a stable “inventory meta contract” (signal-based fields) for drift hashing.
- Minimum required signals: type identifier, version marker (when available), last modified time (when available), scope tags (when available), and assignment target count (when available).
- Drift hashing for v1 MUST be based only on this contract (not arbitrary meta fields).
- Contract outputs MUST be versioned so future additions do not retroactively change v1 semantics (e.g., `meta_contract.version = 1`).
- For baseline snapshot items, the exact contract payload used for hashing MUST be persisted in the snapshot item `meta_jsonb` (e.g., `meta_jsonb.meta_contract`).
- **FR-116v1-05 Provide current-state policy states (meta fidelity)**: For all policy subjects in effective scope, the system MUST produce a normalized policy state for compare, including:
- subject key (policy type + external id), deterministic hash, fidelity=`meta`, source indicator, and observed timestamp.
- In v1, `observed_at` MUST be derived from persisted inventory evidence (`inventory_items.last_seen_at`), not from per-item external hydration calls during compare.
- In v1, `source` MUST indicate the meta-fidelity source (e.g., `inventory_meta_contract:v1`) and MAY include stable provenance (e.g., `inventory_items.last_seen_operation_run_id`) for traceability.
- **FR-116v1-06 Baseline capture stores states (not raw)**: Baseline capture MUST store per-subject snapshot items that include the subject identity and the captured hash + fidelity + source + observed timestamp.
- Baseline snapshots MUST NOT contain out-of-scope items.
- Snapshot items MUST store observation metadata in `baseline_snapshot_items.meta_jsonb` (at minimum: `fidelity`, `source`, `observed_at`; when available: `observed_operation_run_id`).
- **FR-116v1-06a Compare snapshot selection**: Baseline compare MUST, by default, use the latest successful baseline snapshot of the selected baseline profile.
- Definition (v1): “latest successful baseline snapshot” is `baseline_profiles.active_snapshot_id` (updated only after a successful capture run persists a snapshot + items).
- If `active_snapshot_id` is `null`, compare start MUST be blocked with a clear precondition failure (no implicit “pick the newest captured_at” fallback).
- The UI MAY allow selecting a specific snapshot explicitly for historical comparisons.
- **FR-116v1-07 Coverage guard**: Compare MUST check current-state coverage recorded by the most recent inventory sync run.
- If effective scope contains policy types not present in coverage, the compare run MUST complete with warnings.
- For any uncovered policy type, the compare MUST NOT emit findings of any kind for that type (no `missing_policy`, no `unexpected_policy`, no `different_version`).
- Drift findings for types with proven coverage may still be produced.
- If there is no completed inventory sync run (or coverage proof is missing/unreadable), coverage MUST be treated as unproven for all types and the compare MUST produce zero findings (fail-safe) and complete with warnings.
- **FR-116v1-08 Drift rules**: Compare MUST produce drift results per policy subject:
- Baseline-only → `missing_policy` (only when coverage is proven for the subjects type)
- Current-only → `unexpected_policy`
- Both present and hashes differ → `different_version` (with fidelity=`meta`)
- **FR-116v1-09 Stable finding identity**: Findings MUST have a stable identity key derived from: tenant, baseline snapshot, policy type, external id, and change type.
- Hashes are evidence fields and may update without changing identity.
- Finding identity MUST be tied to a specific baseline snapshot (re-capture creates a new baseline snapshot and therefore new finding identities).
- **FR-116v1-10 Finding lifecycle + retry idempotency**: Findings MUST record first seen, last seen, and times seen.
- For a given run identity, lifecycle counters MUST not increment more than once.
- **FR-116v1-11 Auditability**: Each capture and compare run MUST write an audit trail including effective scope counts, coverage warning summary (if any), and finding counts per change type.
- Audit trail storage (canonical):
- Aggregations that do not fit `summary_counts` MUST be stored in `operation_runs.context` (not new summary keys).
- Compare MUST store per-change-type counts in run context under `findings.counts_by_change_type` (e.g., keys: `missing_policy`, `unexpected_policy`, `different_version`).
- For this repository, the canonical audit trail is the `operation_runs` record itself (status/outcome + context + numeric summary_counts); do not introduce parallel “audit summary” persistence for the same data.
- **FR-116v1-12 Drift UI context**: Compare run detail and drift landing MUST surface scope, coverage status, and fidelity (meta-based drift) and show a warning banner when coverage warnings were present.
#### v2 — Content-fidelity extension (deep drift, same engine)
**Deferred / out of scope for this delivery**: The v2 requirements below are intentionally not covered by `specs/116-baseline-drift-engine/tasks.md` and will be implemented in a follow-up spec/milestone.
- **FR-116v2-01 Provider precedence**: Current state MUST be sourced with a precedence chain per policy type: “policy version (if available) → inventory content (if available) → meta fallback (explicitly marked degraded)”.
- **FR-116v2-02 Content hash availability**: The inventory system MUST persist a content hash and capture timestamp for hydrated policy content.
- **FR-116v2-03 Quota-aware hydration**: Content hydration MUST be throttling-safe and resumable, with explicit per-run caps and concurrency limits, and must record hydration coverage in run context.
- **FR-116v2-04 Content normalization rules**: The system MUST define canonicalization rules per policy type, including volatile-field removal and (where needed) redaction hooks.
- **FR-116v2-05 Drift dimensions (optional but final)**: The compare output MAY include dimension flags (content, assignments, scope tags) without changing finding identity.
- If dimension flags are present, they MUST be stored on the same finding record as evidence/flags; the system MUST NOT create separate findings per dimension.
- `change_type` semantics remain compatible with v1 (dimensions refine the “different_version” class rather than multiplying identities).
- **FR-116v2-06 Capture/compare use the same pipeline**: Capture and compare MUST use the same policy state pipeline and hashing semantics; v2 must not introduce special-case compare paths.
- **FR-116v2-07 Coverage/fidelity guard**: If content hydration is incomplete for some types, compare MAY still run but must clearly indicate degraded fidelity and must follow registry-defined behavior for those types.
- **FR-116v2-08 No-legacy guarantee**: After v2 cutover, legacy compare/hash helpers are removed and CI guards prevent re-introduction.
## 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 | Workspace admin | Create Baseline Profile | View action / record inspection (per Action Surface Contract) | Edit, Archive (confirmed) | None | “Create Baseline Profile” | Capture Baseline (compare is tenant-context) | Save, Cancel | Yes | Archive requires confirmation; capture starts OperationRuns and is audited |
| Baseline Capture Run Detail | Workspace admin | None | Linked from runs list | None | None | None | None | N/A | Yes | Shows effective scope + fidelity + counts + warnings |
| Baseline Compare Run Detail | Tenant-context admin | Run Compare (if shown), Re-run Compare (if allowed) | Linked from runs list | None | None | None | None | N/A | Yes | Shows coverage badge and warning banner; uncovered types emit no findings |
| Drift Findings Landing | Tenant-context admin | None | Table filter by change type | View (optional), Acknowledge/Resolve (if workflow exists) | None | None | None | N/A | Yes | Surfaces fidelity + coverage context; no destructive actions required for v1 |
### Key Entities *(include if feature involves data)*
- **Baseline profile**: Defines scope (policy types + opt-in foundations) and is the parent for baseline snapshots.
- **Baseline snapshot item**: Stores per-policy-subject baseline state evidence (hash, fidelity, source, observed timestamp).
- **Compare run**: A recorded operation that compares a tenant current state to a baseline snapshot, including effective scope and coverage warnings.
- **Finding**: A stable, recurring drift finding with lifecycle fields (first seen, last seen, times seen) and evidence (baseline/current hashes, fidelity).
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-116-01 One engine**: All baseline compare and capture runs use exactly one drift pipeline; no alternative compare paths exist in production code.
- **SC-116-02 Stable recurrence**: For a fixed baseline snapshot + tenant + policy subject + change type, repeated compares (including retries) produce at most one finding identity, and lifecycle counters increment at most once per run.
- **SC-116-03 Coverage safety**: When coverage is partial for any effective-scope type, the compare run is visibly marked as “completed with warnings” and produces zero findings for those uncovered types.
- **SC-116-04 Operator clarity**: On the compare run detail screen, operators can see effective scope counts, coverage status, and fidelity within one page load, with a clear warning banner when applicable.
- **SC-116-05 Performance guard (v1)**: Compare runs complete without per-item external hydration calls; runtime scales with number of in-scope subjects via chunking.

View File

@ -0,0 +1,244 @@
---
description: "Executable task breakdown for Spec 116 implementation"
---
# Tasks: 116 — Baseline Drift Engine (Final Architecture)
**Input**: Design documents in `specs/116-baseline-drift-engine/` (`plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `specs/116-baseline-drift-engine/contracts/openapi.yaml`)
**Tests**: REQUIRED (Pest) for all runtime behavior changes.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Ensure local dev + feature artifacts are ready.
- [X] T001 Re-run Speckit prerequisites check via `.specify/scripts/bash/check-prerequisites.sh --json` (references `specs/116-baseline-drift-engine/plan.md`)
- [X] T002 Run required agent context update via `.specify/scripts/bash/update-agent-context.sh copilot` (required by `specs/116-baseline-drift-engine/plan.md`)
- [X] T003 Ensure Sail + migrations are up for local validation (references `vendor/bin/sail`, `docker-compose.yml`, and `database/migrations/`)
- [X] T004 [P] Re-validate Spec 116 UI Action Matrix: confirm “no changes required” OR update the matrix table in `specs/116-baseline-drift-engine/spec.md`
- [X] T005 [P] Document the mapping of “Policy Types” / “Foundations” selectors to config sources in `specs/116-baseline-drift-engine/plan.md` (and adjust `config/tenantpilot.php` / `app/Support/Inventory/InventoryPolicyTypeMeta.php` if mismatched)
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared primitives that all user stories depend on (scope semantics, data defaults, and UI inputs).
**Independent Test**: Baseline Profile can be created with the new scope shape, and scope defaults expand deterministically.
- [X] T006 Update baseline scope schema + default semantics (policy_types excludes foundations by default; foundation_types defaults to none) in `app/Support/Baselines/BaselineScope.php`
- [X] T007 [P] Update BaselineProfile default scope shape to include `foundation_types` in `database/factories/BaselineProfileFactory.php`
- [X] T008 [P] Ensure BaselineProfile scope casting/normalization supports `foundation_types` safely in `app/Models/BaselineProfile.php`
- [X] T009 [P] Create focused tests for scope expansion defaults (empty policy_types => supported excluding foundations; empty foundation_types => none) in `tests/Unit/Baselines/BaselineScopeTest.php`
- [X] T010 Update BaselineProfile Create/Edit form (UX-001 Main/Aside), scope picker (Policy Types + Foundations), and infolist display semantics in `app/Filament/Resources/BaselineProfileResource.php`
- [X] T011 [P] Update create-page scope normalization to persist both `policy_types` and `foundation_types` in `app/Filament/Resources/BaselineProfileResource/Pages/CreateBaselineProfile.php`
- [X] T012 [P] Update edit-page scope normalization to persist both `policy_types` and `foundation_types` in `app/Filament/Resources/BaselineProfileResource/Pages/EditBaselineProfile.php`
- [X] T013 Run focused verification for foundational scope/UI changes with `vendor/bin/sail artisan test --compact tests/Unit/Baselines/BaselineScopeTest.php` (references `tests/Unit/Baselines/BaselineScopeTest.php`)
**Checkpoint**: Scope semantics + scope UI are correct and test-covered.
---
## Phase 3: User Story 1 — Capture and compare a baseline with stable findings (Priority: P1)
**Goal**: Define the v1 meta-fidelity hash contract, use it for capture/compare, and make baseline-compare findings snapshot-scoped stable identities.
**Independent Test**: Capture a snapshot, run compare twice with the same inputs, and verify finding identity stability + lifecycle idempotency.
### Tests for User Story 1 (write first)
- [X] T014 [P] [US1] Update capture tests for effective_scope recording + contract-based hashing in `tests/Feature/Baselines/BaselineCaptureTest.php`
- [X] T015 [P] [US1] Update compare findings tests to assert recurrence-key-based identity (no hashes in fingerprint) + lifecycle idempotency per run + `run.context.findings.counts_by_change_type` is present and accurate in `tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- [X] T016 [P] [US1] Add unit test for InventoryMetaContract normalization stability (ordering, missing fields, nullability) in `tests/Unit/Baselines/InventoryMetaContractTest.php`
- [X] T017 [P] [US1] Add/adjust preconditions tests for default snapshot selection via `baseline_profiles.active_snapshot_id` (“latest successful”) + explicit snapshot override (per `specs/116-baseline-drift-engine/contracts/openapi.yaml`) in `tests/Feature/Baselines/BaselineComparePreconditionsTest.php`
- [X] T018 [P] [US1] Add test that re-capturing (new snapshot id) produces new finding identities (snapshot-scoped) in `tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- [X] T019 [P] [US1] Extend compare-start auth tests to cover: unauthenticated→302, authenticated non-member/not entitled tenant access→404, authenticated member missing `tenant.sync`→403, and success path in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
- [X] T020 [P] [US1] Add capture-start auth tests for Baseline Profile “Capture Snapshot” action to cover: unauthenticated→302, authenticated non-member/not entitled workspace access→404, authenticated member missing `workspace_baselines.manage`→403, and success path in `tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`
- [X] T021 [P] [US1] Confirm baseline profile create/edit mutation surfaces have positive + negative authorization coverage (302/404/403 semantics) and update for new scope shape if needed in `tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
### Implementation for User Story 1
- [X] T022 [US1] Create + implement Inventory Meta Contract builder (normalized whitelist inputs; deterministic ordering) in `app/Services/Baselines/InventoryMetaContract.php`
- [X] T023 [US1] Update snapshot hashing to hash ONLY the meta contract output (not entire meta_jsonb) in `app/Services/Baselines/BaselineSnapshotIdentity.php`
- [X] T024 [US1] Update baseline capture job to compute/store `baseline_hash` via InventoryMetaContract and persist `meta_jsonb` evidence (`meta_contract`, `fidelity`, `source`, `observed_at`, `observed_operation_run_id`) in `app/Jobs/CaptureBaselineSnapshotJob.php`
- [X] T025 [US1] Ensure capture OperationRun context records `effective_scope.*` (policy_types, foundation_types, all_types, foundations_included) in `app/Services/Baselines/BaselineCaptureService.php`
- [X] T026 [US1] Update baseline compare job to compute `current_hash` via InventoryMetaContract consistently with capture in `app/Jobs/CompareBaselineToTenantJob.php`
- [X] T027 [US1] Switch baseline-compare finding identity to recurrence key derived from (tenant, baseline_snapshot_id, policy_type, external_id, change_type) and set `fingerprint == recurrence_key` in `app/Jobs/CompareBaselineToTenantJob.php`
- [X] T028 [US1] Enforce per-run idempotency by using `findings.current_operation_run_id` (and/or evidence) so `times_seen` increments at most once per run identity in `app/Jobs/CompareBaselineToTenantJob.php`
- [X] T029 [US1] Write compare audit context fields (baseline ids + `findings.counts_by_change_type`) onto the compare OperationRun context in `app/Jobs/CompareBaselineToTenantJob.php`
**Checkpoint**: US1 tests pass: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/Baselines/BaselineComparePreconditionsTest.php`.
---
## Phase 4: User Story 2 — Coverage warnings prevent misleading missing-policy findings (Priority: P1)
**Goal**: Persist per-type coverage from inventory sync and enforce the coverage guard in baseline compare (uncovered types emit zero findings; compare outcome is partial with warnings).
**Independent Test**: Run compare with missing coverage for some in-scope types; verify partial outcome + zero findings for uncovered types.
### Tests for User Story 2 (write first)
- [X] T030 [P] [US2] Extend inventory sync tests to assert per-type coverage payload is written to OperationRun context in `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`
- [X] T031 [P] [US2] Create coverage-guard regression test: (a) uncovered types => no findings of any kind; (b) no completed inventory sync run / missing coverage payload => fail-safe zero findings; (c) effective scope expands to zero types => warnings + zero findings; outcome partially_succeeded; covered types still emit findings in `tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php`
### Implementation for User Story 2
- [X] T032 [US2] Persist inventory sync coverage payload into latest inventory sync run context (`inventory.coverage.policy_types` + `inventory.coverage.foundation_types`) in `app/Services/Inventory/InventorySyncService.php`
- [X] T033 [P] [US2] Create a small coverage parser/helper to normalize context payload for downstream consumers in `app/Support/Inventory/InventoryCoverage.php`
- [X] T034 [US2] Update baseline compare to read latest inventory sync run coverage, compute uncovered types, skip emission for uncovered types, and write coverage details into compare run context in `app/Jobs/CompareBaselineToTenantJob.php`
- [X] T035 [US2] Treat missing coverage proof (no completed inventory sync run, or unreadable/missing coverage payload) as uncovered-for-all-types (fail-safe): emit zero findings and mark outcome partially_succeeded (via OperationRunService), setting numeric summary_counts (including errors_recorded) using canonical keys only in `app/Jobs/CompareBaselineToTenantJob.php`
**Note (canonical warning magnitude)**: For (c) “effective scope expands to zero types”, the compare MUST still surface a warning and therefore MUST set `summary_counts.errors_recorded = 1` (even though uncovered-types count is 0), to keep the warning visible under the numeric-only summary_counts contract.
**Checkpoint**: US2 tests pass: `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventorySyncStartSurfaceTest.php tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php`.
---
## Phase 5: User Story 3 — Operators can understand scope, coverage, and fidelity in the UI (Priority: P2)
**Goal**: Surface effective scope, coverage status, and fidelity (meta) in Baseline Compare landing + drift findings surfaces.
**Independent Test**: Execute a compare with and without coverage warnings; verify UI surfaces show badge/banner + scope/coverage/fidelity context.
### Tests for User Story 3 (write first)
- [X] T036 [P] [US3] Update Baseline Compare landing tests to cover warning/coverage state rendering inputs (stats DTO fields) in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
- [X] T037 [P] [US3] Update drift landing comparison-info tests to include coverage/fidelity context when source is baseline compare in `tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php`
### Implementation for User Story 3
- [X] T038 [US3] Extend BaselineCompareStats DTO to include coverage status + uncovered types summary + fidelity indicator sourced from latest compare run context in `app/Support/Baselines/BaselineCompareStats.php`
- [X] T039 [US3] Wire new stats fields into the BaselineCompareLanding Livewire page state in `app/Filament/Pages/BaselineCompareLanding.php`
- [X] T040 [US3] Render coverage badge + warning banner + fidelity label on the landing view in `resources/views/filament/pages/baseline-compare-landing.blade.php`
- [X] T041 [US3] Add a findings-list banner when latest baseline compare run had uncovered types (linking to the run) in `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
- [X] T042 [US3] Ensure run detail already shows context; if needed, add baseline compare “Coverage” summary entry for readability in `app/Filament/Resources/OperationRunResource.php`
**Checkpoint**: US3 tests pass: `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php`.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Preserve operability semantics (auto-close, stats), Ops-UX compliance, and fast regression feedback.
- [X] T043 Confirm baseline compare stats remain profile-grouped via `scope_key = baseline_profile:{id}` after identity change in `app/Support/Baselines/BaselineCompareStats.php`
- [X] T044 Ensure baseline auto-close behavior still works with snapshot-scoped identities (no stale open findings after successful compare) in `app/Services/Baselines/BaselineAutoCloseService.php`
- [X] T045 [P] Update/verify auto-close regression test remains valid after identity change in `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php`
- [X] T046 [P] Add/extend guard test asserting OperationRun summary_counts are numeric-only and keys are limited to `OperationSummaryKeys::all()` for baseline capture/compare runs in `tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- [X] T047 [P] Add Spec 116 “One engine” regression guard to prevent legacy compare/hash paths (no `DriftHasher::fingerprint()` use for baseline compare; capture/compare hashing flows through `InventoryMetaContract`) in `tests/Feature/Guards/Spec116OneEngineGuardTest.php`
- [X] T048 [P] Add Spec 116 performance regression guard (compare is DB-only; no Graph/hydration calls; compare processes snapshot + inventory via chunking) in `tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php`
- [X] T049 [P] Verify OPS-UX-GUARD-001 remains satisfied for Spec 116 code paths; if guard tests fail, fix code (preferred) or update guard expectations intentionally in `tests/Feature/OpsUx/Constitution/DirectStatusTransitionGuardTest.php`, `tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php`, and `tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`
- [X] T050 [P] Create Baseline Profile archive action tests (confirmation required + RBAC 403/404 semantics + success path) in `tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`
- [X] T051 [P] Ensure archive action is declared in Action Surface slots and remains “More” row action only (max 2 visible row actions) in `tests/Feature/Guards/ActionSurfaceContractTest.php`
- [X] T052 Run baseline-focused test pack for Spec 116: `vendor/bin/sail artisan test --compact tests/Feature/Baselines/` (references `tests/Feature/Baselines/`)
- [X] T053 Run Ops-UX guard test pack: `vendor/bin/sail artisan test --compact --group=ops-ux` (references `tests/Feature/OpsUx/Constitution/`)
- [X] T054 Run Pint formatter on changed files: `vendor/bin/sail pint --dirty --format agent` (references `app/` and `tests/`)
- [X] T055 Validate developer quickstart still matches real behavior (update if needed) in `specs/116-baseline-drift-engine/quickstart.md`
---
## Dependencies & Execution Order
### User Story Dependency Graph
```mermaid
graph TD
P1[Phase 1: Setup] --> P2[Phase 2: Foundational]
P2 --> US1[US1: Stable capture/compare + findings]
US1 --> US2[US2: Coverage guard]
US2 --> US3[US3: UI context]
US2 --> P6[Phase 6: Polish]
US3 --> P6
```
### Phase Dependencies
- **Setup (Phase 1)** → blocks nothing, but should be done first.
- **Foundational (Phase 2)** → BLOCKS all user stories.
- **US1 / US2 / US3** → can start after Foundational; in practice US1 then US2 reduces merge conflicts in `app/Jobs/CompareBaselineToTenantJob.php`.
- **Polish (Phase 6)** → after US1/US2 at minimum.
### User Story Dependencies
- **US1 (P1)**: Depends on Phase 2.
- **US2 (P1)**: Depends on Phase 2; touches compare + inventory sync. Strongly recommended after US1 to keep compare changes coherent.
- **US3 (P2)**: Depends on US2 (needs coverage context) and Phase 2.
---
## Parallel Example: User Story 1
```bash
# Tests can be updated in parallel:
Task: "Update capture tests" (tests/Feature/Baselines/BaselineCaptureTest.php)
Task: "Update compare findings tests" (tests/Feature/Baselines/BaselineCompareFindingsTest.php)
Task: "Update compare preconditions tests" (tests/Feature/Baselines/BaselineComparePreconditionsTest.php)
Task: "Update compare-start auth tests" (tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php)
Task: "Add capture-start auth tests" (tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php)
# Implementation can be split with care (different files):
Task: "Implement InventoryMetaContract" (app/Services/Baselines/InventoryMetaContract.php)
Task: "Update CaptureBaselineSnapshotJob hashing" (app/Jobs/CaptureBaselineSnapshotJob.php)
Task: "Update CompareBaselineToTenantJob identity + hashing" (app/Jobs/CompareBaselineToTenantJob.php)
```
---
## Parallel Example: User Story 2
```bash
# Tests can be updated in parallel:
Task: "Assert inventory sync coverage is written" (tests/Feature/Inventory/InventorySyncStartSurfaceTest.php)
Task: "Add coverage-guard regression test" (tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php)
# Implementation can be split (different files):
Task: "Write coverage payload to sync run context" (app/Services/Inventory/InventorySyncService.php)
Task: "Implement coverage parser helper" (app/Support/Inventory/InventoryCoverage.php)
```
---
## Parallel Example: User Story 3
```bash
# Tests can be updated in parallel:
Task: "Update landing test for coverage/fidelity state" (tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php)
Task: "Update drift landing comparison-info test" (tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php)
# Implementation can be split (different files):
Task: "Extend stats DTO + wiring" (app/Support/Baselines/BaselineCompareStats.php)
Task: "Render landing banner/badges" (resources/views/filament/pages/baseline-compare-landing.blade.php)
Task: "Add findings list banner" (app/Filament/Resources/FindingResource/Pages/ListFindings.php)
```
---
## Implementation Strategy
### Suggested MVP Scope
- **MVP**: US1 (stable capture/compare + stable findings).
- **Trust-critical follow-up**: US2 (coverage guard) is also P1 in the spec and should typically ship immediately after MVP.
### Incremental Delivery
1. Phase 1 + Phase 2
2. US1 → validate stable identities + contract hashing
3. US2 → validate coverage guard + partial outcome semantics
4. US3 → validate operator clarity (badges/banners)
5. Phase 6 → ensure operability + guards + formatting
---
## Notes
- [P] tasks = parallelizable (different files, minimal dependency)
- All tasks include explicit file targets for fast handoff
- Destructive actions already require confirmation (Filament actions use `->requiresConfirmation()`); keep that invariant when editing UI surfaces
- Spec 116 includes a v2 section for future work; v2 requirements are explicitly deferred and are not covered by this tasks list

View File

@ -8,8 +8,11 @@
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineProfileStatus;
use Illuminate\Support\Facades\Queue;
// --- T031: Capture enqueue + precondition tests ---
@ -21,7 +24,7 @@
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
/** @var BaselineCaptureService $service */
@ -40,6 +43,13 @@
$context = is_array($run->context) ? $run->context : [];
expect($context['baseline_profile_id'])->toBe((int) $profile->getKey());
expect($context['source_tenant_id'])->toBe((int) $tenant->getKey());
expect($context)->toHaveKey('effective_scope');
$effectiveScope = is_array($context['effective_scope'] ?? null) ? $context['effective_scope'] : [];
expect($effectiveScope['policy_types'])->toBe(['deviceConfiguration']);
expect($effectiveScope['foundation_types'])->toBe([]);
expect($effectiveScope['all_types'])->toBe(['deviceConfiguration']);
expect($effectiveScope['foundations_included'])->toBeFalse();
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
});
@ -51,7 +61,7 @@
$profile = BaselineProfile::factory()->create([
'workspace_id' => $tenant->workspace_id,
'status' => BaselineProfile::STATUS_DRAFT,
'status' => BaselineProfileStatus::Draft->value,
]);
$service = app(BaselineCaptureService::class);
@ -111,7 +121,7 @@
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$service = app(BaselineCaptureService::class);
@ -133,14 +143,29 @@
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
InventoryItem::factory()->count(3)->create([
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-a',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration'],
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-b',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-c',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'],
]);
$opService = app(OperationRunService::class);
@ -151,7 +176,7 @@
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
@ -159,6 +184,7 @@
$job = new CaptureBaselineSnapshotJob($run);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$opService,
);
@ -178,6 +204,39 @@
expect($snapshot)->not->toBeNull();
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(3);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$items = BaselineSnapshotItem::query()
->where('baseline_snapshot_id', $snapshot->getKey())
->orderBy('subject_external_id')
->get();
expect($items->pluck('subject_external_id')->all())->toBe(['policy-a', 'policy-b', 'policy-c']);
foreach ($items as $item) {
/** @var BaselineSnapshotItem $item */
$inventory = InventoryItem::query()
->where('tenant_id', $tenant->getKey())
->where('policy_type', $item->policy_type)
->where('external_id', $item->subject_external_id)
->first();
expect($inventory)->not->toBeNull();
$contract = $builder->build(
policyType: (string) $inventory->policy_type,
subjectExternalId: (string) $inventory->external_id,
metaJsonb: is_array($inventory->meta_jsonb) ? $inventory->meta_jsonb : [],
);
expect($item->baseline_hash)->toBe($hasher->hashNormalized($contract));
$meta = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
expect($meta)->toHaveKey('meta_contract');
expect($meta['meta_contract'])->toBe($contract);
}
$profile->refresh();
expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
});
@ -187,7 +246,7 @@
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
InventoryItem::factory()->count(2)->create([
@ -199,6 +258,7 @@
$opService = app(OperationRunService::class);
$idService = app(BaselineSnapshotIdentity::class);
$metaContract = app(InventoryMetaContract::class);
$auditLogger = app(AuditLogger::class);
$run1 = $opService->ensureRunWithIdentity(
@ -208,13 +268,13 @@
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
$job1 = new CaptureBaselineSnapshotJob($run1);
$job1->handle($idService, $auditLogger, $opService);
$job1->handle($idService, $metaContract, $auditLogger, $opService);
$snapshotCountAfterFirst = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey())
@ -230,16 +290,16 @@
'type' => 'baseline_capture',
'status' => 'queued',
'outcome' => 'pending',
'run_identity_hash' => hash('sha256', 'second-run-' . now()->timestamp),
'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
]);
$job2 = new CaptureBaselineSnapshotJob($run2);
$job2->handle($idService, $auditLogger, $opService);
$job2->handle($idService, $metaContract, $auditLogger, $opService);
$snapshotCountAfterSecond = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey())
@ -255,7 +315,7 @@
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType']],
'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []],
]);
$opService = app(OperationRunService::class);
@ -266,7 +326,7 @@
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['nonExistentPolicyType']],
'effective_scope' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []],
],
initiator: $user,
);
@ -274,6 +334,7 @@
$job = new CaptureBaselineSnapshotJob($run);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$opService,
);
@ -299,7 +360,7 @@
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => []],
'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
]);
InventoryItem::factory()->create([
@ -311,7 +372,14 @@
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'policy_type' => 'compliancePolicy',
'policy_type' => 'deviceCompliancePolicy',
]);
// Foundation types are excluded by default (unless foundation_types is selected).
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'policy_type' => 'assignmentFilter',
]);
$opService = app(OperationRunService::class);
@ -322,7 +390,7 @@
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => []],
'effective_scope' => ['policy_types' => [], 'foundation_types' => []],
],
initiator: $user,
);
@ -330,6 +398,7 @@
$job = new CaptureBaselineSnapshotJob($run);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$opService,
);

View File

@ -0,0 +1,354 @@
<?php
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
it('skips findings for uncovered types and marks compare partially_succeeded', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
'foundation_types' => [],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$coveredContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'covered-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'covered-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $hasher->hashNormalized($coveredContract),
'meta_jsonb' => ['display_name' => 'Covered Policy'],
]);
$uncoveredContract = $builder->build(
policyType: 'deviceCompliancePolicy',
subjectExternalId: 'uncovered-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceCompliancePolicy', 'etag' => 'E_BASELINE'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'uncovered-uuid',
'policy_type' => 'deviceCompliancePolicy',
'baseline_hash' => $hasher->hashNormalized($uncoveredContract),
'meta_jsonb' => ['display_name' => 'Uncovered Policy'],
]);
$inventorySyncRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now(),
'context' => [
'inventory' => [
'coverage' => [
'policy_types' => [
'deviceConfiguration' => ['status' => 'succeeded'],
'deviceCompliancePolicy' => ['status' => 'failed'],
],
'foundation_types' => [],
],
],
],
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'covered-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
'display_name' => 'Covered Policy Changed',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$operationRuns = app(OperationRunService::class);
$compareRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
'foundation_types' => [],
],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$compareRun->refresh();
expect($compareRun->status)->toBe('completed');
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
$counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : [];
expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1);
$findings = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->get();
expect($findings)->toHaveCount(1);
expect((string) data_get($findings->first(), 'evidence_jsonb.change_type'))->toBe('different_version');
});
it('emits zero findings when there is no completed inventory sync run (fail-safe)', 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()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$contract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $hasher->hashNormalized($contract),
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
'display_name' => 'Policy Changed',
]);
$operationRuns = app(OperationRunService::class);
$compareRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$compareRun->refresh();
expect($compareRun->status)->toBe('completed');
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
$counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : [];
expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1);
expect((int) ($counts['total'] ?? -1))->toBe(0);
expect(
Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->count()
)->toBe(0);
});
it('emits zero findings when coverage payload is missing (fail-safe)', 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()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'selection_hash' => 'latest',
],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline'),
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
'display_name' => 'Policy Changed',
]);
$operationRuns = app(OperationRunService::class);
$compareRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$compareRun->refresh();
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
$counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : [];
expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1);
expect((int) ($counts['total'] ?? -1))->toBe(0);
expect(
Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->count()
)->toBe(0);
});
it('emits a warning and zero findings when effective scope expands to zero types', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => [
'policy_types' => ['unsupported_type'],
'foundation_types' => [],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$operationRuns = app(OperationRunService::class);
$compareRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => [
'policy_types' => ['unsupported_type'],
'foundation_types' => [],
],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$compareRun->refresh();
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
$counts = is_array($compareRun->summary_counts) ? $compareRun->summary_counts : [];
expect((int) ($counts['errors_recorded'] ?? 0))->toBe(1);
expect((int) ($counts['total'] ?? -1))->toBe(0);
expect(
Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('source', 'baseline.compare')
->count()
)->toBe(0);
});

View File

@ -1,5 +1,6 @@
<?php
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
@ -9,13 +10,13 @@
use App\Models\OperationRun;
use App\Models\WorkspaceSetting;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationSummaryKeys;
use function Pest\Laravel\mock;
@ -26,7 +27,7 @@
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
@ -36,6 +37,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
// Baseline has policyA and policyB
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
@ -62,6 +68,8 @@
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['different_content' => true],
'display_name' => 'Policy A modified',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
@ -70,6 +78,8 @@
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['new_policy' => true],
'display_name' => 'Policy C unexpected',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
@ -80,14 +90,13 @@
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
@ -97,6 +106,14 @@
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
$context = is_array($run->context) ? $run->context : [];
$countsByChangeType = $context['findings']['counts_by_change_type'] ?? null;
expect($countsByChangeType)->toBe([
'different_version' => 1,
'missing_policy' => 1,
'unexpected_policy' => 1,
]);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$findings = Finding::query()
@ -107,6 +124,7 @@
// policyB missing (high), policyA different (medium), policyC unexpected (low) = 3 findings
expect($findings->count())->toBe(3);
expect($findings->every(fn (Finding $finding): bool => filled($finding->recurrence_key) && $finding->recurrence_key === $finding->fingerprint))->toBeTrue();
// Lifecycle v2 fields must be initialized for new findings.
expect($findings->pluck('first_seen_at')->filter()->count())->toBe($findings->count());
@ -134,6 +152,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
@ -158,6 +181,8 @@
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['different_content' => true],
'display_name' => 'Policy A modified',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
@ -181,7 +206,6 @@
$baselineAutoCloseService = new \App\Services\Baselines\BaselineAutoCloseService($settingsResolver);
(new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
@ -222,6 +246,26 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$olderInventoryRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['settingsCatalogPolicy' => 'succeeded'],
attributes: [
'selection_hash' => 'older',
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
'finished_at' => now()->subMinutes(5),
],
);
createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['settingsCatalogPolicy' => 'succeeded'],
attributes: [
'selection_hash' => 'latest',
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
'finished_at' => now(),
],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
@ -231,19 +275,6 @@
'meta_jsonb' => ['display_name' => 'Settings Catalog A'],
]);
$olderInventoryRun = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subMinutes(5),
'context' => [
'policy_types' => ['settingsCatalogPolicy'],
'selection_hash' => 'older',
],
]);
// Inventory item exists, but it was NOT observed in the latest sync run.
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
@ -256,19 +287,6 @@
'last_seen_at' => now()->subMinutes(5),
]);
OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'policy_types' => ['settingsCatalogPolicy'],
'selection_hash' => 'latest',
],
]);
$opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity(
tenant: $tenant,
@ -283,7 +301,6 @@
);
(new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
@ -305,12 +322,12 @@
expect($findings->first()?->evidence_jsonb['change_type'] ?? null)->toBe('missing_policy');
});
it('produces idempotent fingerprints so re-running compare updates existing findings', function () {
it('uses recurrence-key identity (no hashes in fingerprint) and increments times_seen at most once per run', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
@ -320,19 +337,42 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$baselineContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-x-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-content'),
'baseline_hash' => $hasher->hashNormalized($baselineContract),
'meta_jsonb' => ['display_name' => 'Policy X'],
]);
// Tenant does NOT have policy-x → missing_policy finding
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'],
'display_name' => 'Policy X modified',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
// First run
$run1 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
@ -340,31 +380,54 @@
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
$job1 = new CompareBaselineToTenantJob($run1);
$job1->handle(
app(DriftHasher::class),
$job = new CompareBaselineToTenantJob($run1);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$countAfterFirst = Finding::query()
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->count();
->sole();
expect($countAfterFirst)->toBe(1);
expect($finding->recurrence_key)->not->toBeNull();
expect($finding->fingerprint)->toBe($finding->recurrence_key);
expect($finding->times_seen)->toBe(1);
// Second run - new OperationRun so we can dispatch again
// Mark first run as completed so ensureRunWithIdentity creates a new one
$run1->update(['status' => 'completed', 'completed_at' => now()]);
$fingerprint = (string) $finding->fingerprint;
$currentHash1 = (string) ($finding->evidence_jsonb['current_hash'] ?? '');
expect($currentHash1)->not->toBe('');
// Retry the same run ID (job retry): times_seen MUST NOT increment twice for the same run.
$job->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$finding->refresh();
expect($finding->times_seen)->toBe(1);
expect((string) $finding->fingerprint)->toBe($fingerprint);
expect((string) ($finding->evidence_jsonb['current_hash'] ?? ''))->toBe($currentHash1);
// Change inventory evidence (hash changes) and run compare again with a new OperationRun.
InventoryItem::query()
->where('tenant_id', $tenant->getKey())
->where('policy_type', 'deviceConfiguration')
->where('external_id', 'policy-x-uuid')
->update([
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_2'],
]);
$run2 = $opService->ensureRunWithIdentity(
tenant: $tenant,
@ -373,27 +436,139 @@
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
$job2 = new CompareBaselineToTenantJob($run2);
$job2->handle(
app(DriftHasher::class),
(new CompareBaselineToTenantJob($run2))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$countAfterSecond = Finding::query()
$finding->refresh();
expect((string) $finding->fingerprint)->toBe($fingerprint);
expect($finding->times_seen)->toBe(2);
expect((string) ($finding->evidence_jsonb['current_hash'] ?? ''))->not->toBe($currentHash1);
});
it('creates new finding identities when a new snapshot is captured (snapshot-scoped recurrence)', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$baselineContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-x-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
$baselineHash = $hasher->hashNormalized($baselineContract);
$snapshot1 = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot1->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $baselineHash,
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
$run1 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot1->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run1))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$fingerprint1 = (string) Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->count();
->orderBy('id')
->firstOrFail()
->fingerprint;
// Same fingerprint → same finding updated, not duplicated
expect($countAfterSecond)->toBe(1);
$snapshot2 = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot2->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $baselineHash,
]);
$run2 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot2->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run2))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->orderBy('id')
->get();
expect($findings)->toHaveCount(2);
expect($findings->pluck('fingerprint')->unique()->count())->toBe(2);
expect($findings->pluck('fingerprint')->all())->toContain($fingerprint1);
});
it('creates zero findings when baseline matches tenant inventory exactly', function () {
@ -401,7 +576,7 @@
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
@ -411,10 +586,26 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
// Baseline item
$metaContent = ['policy_key' => 'value123'];
$metaContent = [
'odata_type' => '#microsoft.graph.deviceConfiguration',
'etag' => 'E1',
'scope_tag_ids' => ['scope-2', 'scope-1'],
'assignment_target_count' => 3,
];
$builder = app(InventoryMetaContract::class);
$driftHasher = app(DriftHasher::class);
$contentHash = $driftHasher->hashNormalized($metaContent);
$contentHash = $driftHasher->hashNormalized($builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'matching-uuid',
metaJsonb: $metaContent,
));
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
@ -433,6 +624,8 @@
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => $metaContent,
'display_name' => 'Matching Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
@ -443,14 +636,13 @@
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
@ -476,7 +668,7 @@
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
@ -486,9 +678,25 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$metaContent = ['policy_key' => 'value123'];
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$metaContent = [
'odata_type' => '#microsoft.graph.deviceConfiguration',
'etag' => 'E1',
'scope_tag_ids' => ['scope-2', 'scope-1'],
'assignment_target_count' => 3,
];
$builder = app(InventoryMetaContract::class);
$driftHasher = app(DriftHasher::class);
$contentHash = $driftHasher->hashNormalized($metaContent);
$contentHash = $driftHasher->hashNormalized($builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'matching-uuid',
metaJsonb: $metaContent,
));
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
@ -515,6 +723,8 @@
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => $metaContent,
'display_name' => 'Matching Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
InventoryItem::factory()->create([
@ -524,6 +734,8 @@
'policy_type' => 'notificationMessageTemplate',
'meta_jsonb' => ['some' => 'value'],
'display_name' => 'Foundation Template',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
@ -534,13 +746,12 @@
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration']],
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
@ -578,6 +789,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
// 2 baseline items: one will be missing (high), one will be different (medium)
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
@ -604,6 +820,8 @@
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['modified_content' => true],
'display_name' => 'Changed Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
@ -612,6 +830,8 @@
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['extra_content' => true],
'display_name' => 'Extra Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
@ -629,7 +849,6 @@
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
@ -659,6 +878,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
// One missing policy
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
@ -683,7 +907,6 @@
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
@ -715,6 +938,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
@ -739,7 +967,6 @@
);
(new CompareBaselineToTenantJob($firstRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
@ -771,7 +998,6 @@
);
(new CompareBaselineToTenantJob($secondRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
@ -799,6 +1025,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
@ -823,7 +1054,6 @@
);
(new CompareBaselineToTenantJob($firstRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
@ -854,7 +1084,6 @@
);
(new CompareBaselineToTenantJob($secondRun))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
@ -892,6 +1121,11 @@
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
@ -916,6 +1150,8 @@
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['different_content' => true],
'display_name' => 'Different Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
@ -924,6 +1160,8 @@
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['unexpected' => true],
'display_name' => 'Unexpected Policy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$operationRuns = app(OperationRunService::class);
@ -940,7 +1178,6 @@
);
(new CompareBaselineToTenantJob($run))->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
@ -956,3 +1193,88 @@
expect($findings['different_version']->severity)->toBe(Finding::SEVERITY_LOW);
expect($findings['unexpected_policy']->severity)->toBe(Finding::SEVERITY_MEDIUM);
});
it('writes numeric-only summary_counts with whitelisted keys for capture and compare runs', 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' => []],
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-a',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
]);
$operationRuns = app(OperationRunService::class);
$captureRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CaptureBaselineSnapshotJob($captureRun))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$operationRuns,
);
$captureRun->refresh();
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
InventoryItem::query()
->where('tenant_id', (int) $tenant->getKey())
->update([
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$snapshotId = (int) ($profile->fresh()?->active_snapshot_id ?? 0);
expect($snapshotId)->toBeGreaterThan(0);
$compareRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => $snapshotId,
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
$allowedKeys = OperationSummaryKeys::all();
foreach ([$captureRun->fresh(), $compareRun->fresh()] as $run) {
$counts = is_array($run?->summary_counts) ? $run->summary_counts : [];
foreach ($counts as $key => $value) {
expect($allowedKeys)->toContain((string) $key);
expect(is_array($value))->toBeFalse();
expect(is_numeric($value))->toBeTrue();
}
}
});

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
it('runs baseline compare without outbound HTTP and uses chunking', function (): void {
bindFailHardGraphClient();
[$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()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$baselineContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $hasher->hashNormalized($baselineContract),
'meta_jsonb' => ['display_name' => 'Policy'],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT'],
'display_name' => 'Policy Changed',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$operationRuns = app(OperationRunService::class);
$compareRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => [],
],
],
initiator: $user,
);
assertNoOutboundHttp(function () use ($compareRun, $operationRuns): void {
(new CompareBaselineToTenantJob($compareRun))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,
);
});
$compareRun->refresh();
expect($compareRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
$code = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
expect($code)->toBeString();
expect($code)->toContain('->chunk(');
});

View File

@ -6,6 +6,7 @@
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue;
@ -34,7 +35,7 @@
$profile = BaselineProfile::factory()->create([
'workspace_id' => $tenant->workspace_id,
'status' => BaselineProfile::STATUS_DRAFT,
'status' => BaselineProfileStatus::Draft->value,
]);
BaselineTenantAssignment::create([
@ -111,7 +112,7 @@
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration']],
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
@ -145,6 +146,48 @@
Queue::assertPushed(CompareBaselineToTenantJob::class);
});
it('uses an explicit snapshot override instead of baseline_profiles.active_snapshot_id when provided', 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' => []],
]);
$activeSnapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$overrideSnapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $activeSnapshot->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) $overrideSnapshot->getKey());
expect($result['ok'])->toBeTrue();
/** @var OperationRun $run */
$run = $result['run'];
$context = is_array($run->context) ? $run->context : [];
expect($context['baseline_snapshot_id'])->toBe((int) $overrideSnapshot->getKey());
Queue::assertPushed(CompareBaselineToTenantJob::class);
});
// --- EC-004: Concurrent compare reuses active run ---
it('reuses an existing active run for the same profile/tenant instead of creating a new one [EC-004]', function () {

View File

@ -11,7 +11,6 @@
use App\Models\WorkspaceSetting;
use App\Services\Baselines\BaselineAutoCloseService;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Drift\DriftHasher;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
@ -46,6 +45,11 @@ function runBaselineCompareForSnapshot(
BaselineProfile $profile,
BaselineSnapshot $snapshot,
): OperationRun {
createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$operationRuns = app(OperationRunService::class);
$run = $operationRuns->ensureRunWithIdentity(
@ -62,7 +66,6 @@ function runBaselineCompareForSnapshot(
$job = new CompareBaselineToTenantJob($run);
$job->handle(
app(DriftHasher::class),
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$operationRuns,

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles;
use App\Models\BaselineProfile;
use App\Models\Workspace;
use App\Support\Auth\UiTooltips;
use App\Support\Baselines\BaselineProfileStatus;
use Filament\Actions\Action;
use Livewire\Livewire;
it('requires confirmation for the archive table action', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
Livewire::actingAs($user)
->test(ListBaselineProfiles::class)
->assertTableActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired(), $profile);
});
it('disables archive for workspace members missing workspace_baselines.manage', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
Livewire::actingAs($user)
->test(ListBaselineProfiles::class)
->assertTableActionVisible('archive', $profile)
->assertTableActionDisabled('archive', $profile)
->assertTableActionExists('archive', fn (Action $action): bool => $action->getTooltip() === UiTooltips::insufficientPermission(), $profile)
->callTableAction('archive', $profile);
$profile->refresh();
expect($profile->status)->toBe(BaselineProfileStatus::Active);
});
it('archives baseline profiles for authorized workspace members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
Livewire::actingAs($user)
->test(ListBaselineProfiles::class)
->assertTableActionVisible('archive', $profile)
->assertTableActionEnabled('archive', $profile)
->callTableAction('archive', $profile)
->assertHasNoTableActionErrors()
->assertTableActionHidden('archive', $profile->fresh());
$profile->refresh();
expect($profile->status)->toBe(BaselineProfileStatus::Archived);
});
it('does not show workspace-owned baseline profiles from other workspaces in the list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$otherWorkspace = Workspace::factory()->create();
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $otherWorkspace->getKey(),
]);
Livewire::actingAs($user)
->test(ListBaselineProfiles::class)
->assertCanNotSeeTableRecords([$profile]);
});

View File

@ -1,6 +1,13 @@
<?php
use App\Filament\Pages\DriftLanding;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Filament\Facades\Filament;
use Livewire\Livewire;
@ -30,3 +37,68 @@
->assertSet('baselineFinishedAt', $baseline->finished_at->toDateTimeString())
->assertSet('currentFinishedAt', $current->finished_at->toDateTimeString());
});
test('drift landing exposes baseline compare coverage + fidelity context when available', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$scopeKey = hash('sha256', 'scope-landing-comparison-info');
createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey,
'status' => 'success',
'finished_at' => now()->subDays(2),
]);
createInventorySyncOperationRun($tenant, [
'selection_hash' => $scopeKey,
'status' => 'success',
'finished_at' => now()->subDay(),
]);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->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(),
]);
$compareRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now()->subMinutes(5),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => [],
'uncovered_types' => ['deviceConfiguration'],
'proof' => false,
],
'fidelity' => 'meta',
],
],
]);
Livewire::test(DriftLanding::class)
->assertSet('baselineCompareRunId', (int) $compareRun->getKey())
->assertSet('baselineCompareCoverageStatus', 'unproven')
->assertSet('baselineCompareFidelity', 'meta')
->assertSet('baselineCompareUncoveredTypesCount', 1);
});

View File

@ -7,11 +7,65 @@
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('redirects unauthenticated users (302)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->get(BaselineCompareLanding::getUrl(tenant: $tenant, panel: 'tenant'))
->assertStatus(302);
});
it('returns 404 for authenticated users not entitled to the tenant', function (): void {
[$member, $tenant] = createUserWithTenant(role: 'owner');
$nonMember = \App\Models\User::factory()->create();
$this->actingAs($nonMember)
->get(BaselineCompareLanding::getUrl(tenant: $tenant, panel: 'tenant'))
->assertNotFound();
});
it('does not start baseline compare for members missing tenant.sync', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->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(),
]);
Livewire::test(BaselineCompareLanding::class)
->assertActionVisible('compareNow')
->assertActionDisabled('compareNow')
->callAction('compareNow')
->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
});
it('dispatches ops-ux run-enqueued after starting baseline compare', function (): void {
Queue::fake();
@ -65,3 +119,112 @@
->call('refreshStats')
->assertStatus(200);
});
it('exposes full coverage + fidelity context in stats', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->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(),
]);
$compareRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
'fidelity' => 'meta',
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->call('refreshStats')
->assertSet('operationRunId', (int) $compareRun->getKey())
->assertSet('coverageStatus', 'ok')
->assertSet('uncoveredTypesCount', 0)
->assertSet('fidelity', 'meta');
});
it('exposes coverage warning context in stats', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->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(),
]);
$compareRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'baseline_compare' => [
'coverage' => [
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => ['deviceCompliancePolicy'],
'proof' => true,
],
'fidelity' => 'meta',
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->call('refreshStats')
->assertSet('operationRunId', (int) $compareRun->getKey())
->assertSet('coverageStatus', 'warning')
->assertSet('uncoveredTypesCount', 1)
->assertSet('uncoveredTypes', ['deviceCompliancePolicy'])
->assertSet('fidelity', 'meta');
});

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Models\BaselineProfile;
use App\Models\OperationRun;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$this->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
->assertStatus(302);
});
it('returns 404 for authenticated users accessing a baseline profile from another workspace', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
[$otherUser, $otherTenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $otherTenant->workspace_id);
$this->actingAs($otherUser)
->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
->assertNotFound();
});
it('does not start capture for workspace members missing workspace_baselines.manage', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionVisible('capture')
->assertActionDisabled('capture')
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
->assertStatus(200);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
});
it('starts capture successfully for authorized workspace members', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionVisible('capture')
->assertActionEnabled('capture')
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
->assertStatus(200);
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'baseline_capture')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued');
});

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Filament\Resources\OperationRunResource;
@ -10,6 +11,7 @@
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\SyncPoliciesJob;
use App\Models\BaselineProfile;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
@ -18,6 +20,7 @@
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
@ -92,6 +95,54 @@
}
});
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$declaration = BaselineProfileResource::actionSurfaceDeclaration();
$details = $declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details;
expect($details)->toBeString();
expect($details)->toContain('archive');
$this->actingAs($user);
$livewire = Livewire::test(ListBaselineProfiles::class)
->assertCanSeeTableRecords([$profile]);
$table = $livewire->instance()->getTable();
$rowActions = $table->getActions();
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
expect($moreGroup)->toBeInstanceOf(ActionGroup::class);
expect($moreGroup?->getLabel())->toBe('More');
$primaryRowActionNames = collect($rowActions)
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn ($action): ?string => $action->getName())
->filter()
->values()
->all();
expect($primaryRowActionNames)->toContain('view');
expect($primaryRowActionNames)->not->toContain('archive');
$primaryRowActionCount = count($primaryRowActionNames);
expect($primaryRowActionCount)->toBeLessThanOrEqual(2);
$moreActionNames = collect($moreGroup?->getActions())
->map(static fn ($action): ?string => $action->getName())
->filter()
->values()
->all();
expect($moreActionNames)->toContain('archive');
});
it('ensures representative declarations satisfy required slots', function (): void {
$profiles = new ActionSurfaceProfileDefinition;

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
it('keeps baseline capture/compare hashing on the InventoryMetaContract engine', function (): void {
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
expect($compareJob)->toBeString();
expect($compareJob)->toContain('hashItemContent');
expect($compareJob)->not->toContain('->fingerprint(');
expect($compareJob)->not->toContain('::fingerprint(');
$captureJob = file_get_contents(base_path('app/Jobs/CaptureBaselineSnapshotJob.php'));
expect($captureJob)->toBeString();
expect($captureJob)->toContain('InventoryMetaContract');
expect($captureJob)->toContain('hashItemContent');
expect($captureJob)->not->toContain('->fingerprint(');
expect($captureJob)->not->toContain('::fingerprint(');
$identity = file_get_contents(base_path('app/Services/Baselines/BaselineSnapshotIdentity.php'));
expect($identity)->toBeString();
expect($identity)->toContain('InventoryMetaContract');
expect($identity)->toContain('hashNormalized');
expect($identity)->not->toContain('fingerprint(');
});

View File

@ -29,10 +29,14 @@
Filament::setTenant($tenant, true);
$sync = app(InventorySyncService::class);
$policyTypes = $sync->defaultSelectionPayload()['policy_types'] ?? [];
$policyTypes = array_slice($sync->defaultSelectionPayload()['policy_types'] ?? [], 0, 2);
Livewire::test(ListInventoryItems::class)
->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]);
->callAction('run_inventory_sync', data: [
'policy_types' => $policyTypes,
'include_foundations' => false,
'include_dependencies' => false,
]);
$opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
@ -49,10 +53,52 @@
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($opRun, $tenant));
Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use ($tenant, $user, $opRun): bool {
$capturedJob = null;
Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use (&$capturedJob, $tenant, $user, $opRun): bool {
$capturedJob = $job;
return $job->tenantId === (int) $tenant->getKey()
&& $job->userId === (int) $user->getKey()
&& $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
});
expect($capturedJob)->toBeInstanceOf(RunInventorySyncJob::class);
$mockSync = \Mockery::mock(InventorySyncService::class);
$mockSync
->shouldReceive('executeSelection')
->once()
->andReturnUsing(function (OperationRun $operationRun, $tenant, array $selectionPayload, ?callable $onPolicyTypeProcessed): array {
$policyTypes = $selectionPayload['policy_types'] ?? [];
$policyTypes = is_array($policyTypes) ? array_values(array_filter(array_map('strval', $policyTypes))) : [];
foreach ($policyTypes as $policyType) {
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null);
}
return [
'status' => 'success',
'had_errors' => false,
'error_codes' => [],
'error_context' => [],
'errors_count' => 0,
'items_observed_count' => 0,
'items_upserted_count' => 0,
'processed_policy_types' => $policyTypes,
'failed_policy_types' => [],
'skipped_policy_types' => [],
];
});
$capturedJob->handle($mockSync, app(\App\Services\Intune\AuditLogger::class), app(\App\Services\OperationRunService::class));
$opRun->refresh();
$context = is_array($opRun->context) ? $opRun->context : [];
$coverage = $context['inventory']['coverage']['policy_types'] ?? null;
expect($coverage)->toBeArray();
expect(array_keys($coverage))->toEqualCanonicalizing($policyTypes);
});

View File

@ -200,6 +200,41 @@ function createInventorySyncOperationRun(Tenant $tenant, array $attributes = [])
->create($attributes);
}
/**
* Create a completed inventory sync run with coverage proof in context.
*
* @param array<string, string> $statusByType Example: ['deviceConfiguration' => 'succeeded']
* @param list<string> $foundationTypes
* @param array<string, mixed> $attributes
*/
function createInventorySyncOperationRunWithCoverage(
Tenant $tenant,
array $statusByType,
array $foundationTypes = [],
array $attributes = [],
): \App\Models\OperationRun {
$context = is_array($attributes['context'] ?? null) ? $attributes['context'] : [];
$inventory = is_array($context['inventory'] ?? null) ? $context['inventory'] : [];
$inventory['coverage'] = \App\Support\Inventory\InventoryCoverage::buildPayload(
statusByType: $statusByType,
foundationTypes: $foundationTypes,
);
$context['inventory'] = $inventory;
$attributes['context'] = $context;
if (! array_key_exists('finished_at', $attributes) && ! array_key_exists('completed_at', $attributes)) {
$attributes['finished_at'] = now();
}
$attributes['type'] ??= \App\Support\OperationRunType::InventorySync->value;
$attributes['status'] ??= \App\Support\OperationRunStatus::Completed->value;
$attributes['outcome'] ??= \App\Support\OperationRunOutcome::Succeeded->value;
return createInventorySyncOperationRun($tenant, $attributes);
}
/**
* @return array{0: User, 1: Tenant}
*/

View File

@ -0,0 +1,49 @@
<?php
use App\Support\Baselines\BaselineScope;
it('expands empty policy_types to supported policy types (excluding foundations) and defaults foundation_types to none', function () {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
]);
config()->set('tenantpilot.foundation_types', [
['type' => 'assignmentFilter', 'label' => 'Assignment Filter'],
]);
$scope = BaselineScope::fromJsonb([
'policy_types' => [],
'foundation_types' => [],
])->expandDefaults();
expect($scope->policyTypes)->toBe([
'deviceCompliancePolicy',
'deviceConfiguration',
]);
expect($scope->foundationTypes)->toBe([]);
expect($scope->allTypes())->toBe([
'deviceCompliancePolicy',
'deviceConfiguration',
]);
});
it('filters unknown types and does not allow foundations inside policy_types', function () {
config()->set('tenantpilot.supported_policy_types', [
['type' => 'deviceConfiguration'],
]);
config()->set('tenantpilot.foundation_types', [
['type' => 'assignmentFilter'],
]);
$scope = BaselineScope::fromJsonb([
'policy_types' => ['deviceConfiguration', 'assignmentFilter', 'unknown'],
'foundation_types' => ['assignmentFilter', 'unknown'],
])->expandDefaults();
expect($scope->policyTypes)->toBe(['deviceConfiguration']);
expect($scope->foundationTypes)->toBe(['assignmentFilter']);
expect($scope->allTypes())->toBe(['assignmentFilter', 'deviceConfiguration']);
});

View File

@ -0,0 +1,59 @@
<?php
use App\Services\Baselines\InventoryMetaContract;
it('builds a deterministic v1 contract regardless of input ordering', function () {
$builder = app(InventoryMetaContract::class);
$a = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-a',
metaJsonb: [
'etag' => 'E1',
'odata_type' => '#microsoft.graph.deviceConfiguration',
'scope_tag_ids' => ['2', '1'],
'assignment_target_count' => 3,
],
);
$b = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-a',
metaJsonb: [
'assignment_target_count' => 3,
'scope_tag_ids' => ['1', '2'],
'odata_type' => '#microsoft.graph.deviceConfiguration',
'etag' => 'E1',
],
);
expect($a)->toBe($b);
});
it('represents missing signals as null (not omitted)', function () {
$builder = app(InventoryMetaContract::class);
$contract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-a',
metaJsonb: [],
);
expect($contract)->toHaveKeys([
'version',
'policy_type',
'subject_external_id',
'odata_type',
'etag',
'scope_tag_ids',
'assignment_target_count',
]);
expect($contract['version'])->toBe(1);
expect($contract['policy_type'])->toBe('deviceConfiguration');
expect($contract['subject_external_id'])->toBe('policy-a');
expect($contract['odata_type'])->toBeNull();
expect($contract['etag'])->toBeNull();
expect($contract['scope_tag_ids'])->toBeNull();
expect($contract['assignment_target_count'])->toBeNull();
});