Compare commits

...

2 Commits

Author SHA1 Message Date
c17255f854 feat: implement baseline subject resolution semantics (#193)
## Summary
- add the structured subject-resolution foundation for baseline compare and baseline capture, including capability guards, subject descriptors, resolution outcomes, and operator action categories
- persist structured evidence-gap subject records and update compare/capture surfaces, landing projections, and cleanup tooling to use the new contract
- add Spec 163 artifacts and focused Pest coverage for classification, determinism, cleanup, and DB-only rendering

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`

## Notes
- verified locally that a fresh post-restart baseline compare run now writes structured `baseline_compare.evidence_gaps.subjects` records instead of the legacy broad payload shape
- excluded the separate `docs/product/spec-candidates.md` worktree change from this branch commit and PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #193
2026-03-25 12:40:45 +00:00
7d4d607475 feat: add baseline gap details surfaces (#192)
## Summary
- add baseline compare evidence gap detail modeling and a dedicated Livewire table surface
- extend baseline compare landing and operation run detail surfaces to expose evidence gap details and stats
- add spec artifacts for feature 162 and expand feature coverage with focused Filament and baseline tests

## Notes
- branch: `162-baseline-gap-details`
- commit: `a92dd812`
- working tree was clean after push

## Validation
- tests were not run in this step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #192
2026-03-24 19:05:23 +00:00
71 changed files with 7037 additions and 84 deletions

View File

@ -103,6 +103,9 @@ ## Active Technologies
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -122,8 +125,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 161-operator-explanation-layer: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 160-operation-lifecycle-guarantees: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages
- 159-baseline-snapshot-truth: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 163-baseline-subject-resolution: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 162-baseline-gap-details: Added PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\OperationRun;
use App\Models\Tenant;
use Illuminate\Console\Command;
class PurgeLegacyBaselineGapRuns extends Command
{
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
{--workspace=* : Limit cleanup to workspace ids}
{--limit=500 : Maximum candidate runs to inspect}
{--force : Actually delete matched legacy runs}';
protected $description = 'Purge development-only baseline compare/capture runs that still use legacy broad gap payloads.';
public function handle(): int
{
if (! app()->environment(['local', 'testing'])) {
$this->error('This cleanup command is limited to local and testing environments.');
return self::FAILURE;
}
$types = $this->normalizedTypes();
$workspaceIds = array_values(array_filter(
array_map(
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
(array) $this->option('workspace'),
),
static fn (int $workspaceId): bool => $workspaceId > 0,
));
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
$limit = max(1, (int) $this->option('limit'));
$dryRun = ! (bool) $this->option('force');
$query = OperationRun::query()
->whereIn('type', $types)
->orderBy('id')
->limit($limit);
if ($workspaceIds !== []) {
$query->whereIn('workspace_id', $workspaceIds);
}
if ($tenantIds !== []) {
$query->whereIn('tenant_id', $tenantIds);
}
$candidates = $query->get();
$matched = $candidates
->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload())
->values();
if ($matched->isEmpty()) {
$this->info('No legacy baseline gap runs matched the current filters.');
return self::SUCCESS;
}
$this->table(
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
$matched
->map(fn (OperationRun $run): array => [
'Run' => (string) $run->getKey(),
'Type' => (string) $run->type,
'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—',
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
'Legacy signal' => $this->legacySignal($run),
])
->all(),
);
if ($dryRun) {
$this->warn(sprintf(
'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.',
$matched->count(),
));
return self::SUCCESS;
}
OperationRun::query()
->whereKey($matched->modelKeys())
->delete();
$this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count()));
return self::SUCCESS;
}
/**
* @return array<int, string>
*/
private function normalizedTypes(): array
{
$types = array_values(array_unique(array_filter(
array_map(
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
(array) $this->option('type'),
),
)));
if ($types === []) {
return ['baseline_compare', 'baseline_capture'];
}
return array_values(array_filter(
$types,
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
));
}
/**
* @param array<int, string> $tenantIdentifiers
* @return array<int, int>
*/
private function resolveTenantIds(array $tenantIdentifiers): array
{
if ($tenantIdentifiers === []) {
return [];
}
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()->forTenant($identifier)->first();
if ($tenant instanceof Tenant) {
$tenantIds[] = (int) $tenant->getKey();
}
}
return array_values(array_unique($tenantIds));
}
private function legacySignal(OperationRun $run): string
{
$byReason = $run->baselineGapEnvelope()['by_reason'] ?? null;
$byReason = is_array($byReason) ? $byReason : [];
if (array_key_exists('policy_not_found', $byReason)) {
return 'legacy_reason_code';
}
return 'legacy_subject_shape';
}
}

View File

@ -13,6 +13,7 @@
use App\Services\Baselines\BaselineCompareService;
use App\Support\Auth\Capabilities;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Baselines\BaselineCompareStats;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
@ -59,6 +60,8 @@ class BaselineCompareLanding extends Page
public ?int $duplicateNamePoliciesCount = null;
public ?int $duplicateNameSubjectsCount = null;
public ?int $operationRunId = null;
public ?int $findingsCount = null;
@ -86,6 +89,15 @@ class BaselineCompareLanding extends Page
/** @var array<string, int>|null */
public ?array $evidenceGapsTopReasons = null;
/** @var array<string, mixed>|null */
public ?array $evidenceGapSummary = null;
/** @var list<array<string, mixed>>|null */
public ?array $evidenceGapBuckets = null;
/** @var array<string, mixed>|null */
public ?array $baselineCompareDiagnostics = null;
/** @var array<string, int>|null */
public ?array $rbacRoleDefinitionSummary = null;
@ -126,6 +138,7 @@ public function refreshStats(): void
$this->profileId = $stats->profileId;
$this->snapshotId = $stats->snapshotId;
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
$this->operationRunId = $stats->operationRunId;
$this->findingsCount = $stats->findingsCount;
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
@ -142,6 +155,15 @@ public function refreshStats(): void
$this->evidenceGapsCount = $stats->evidenceGapsCount;
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
$this->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
? $stats->evidenceGapDetails['summary']
: null;
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
? $stats->evidenceGapDetails['buckets']
: null;
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
? $stats->baselineCompareDiagnostics
: null;
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
}
@ -156,26 +178,32 @@ public function refreshStats(): void
*/
protected function getViewData(): array
{
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
? (int) $evidenceGapSummary['count']
: (int) ($this->evidenceGapsCount ?? 0);
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
? (string) $evidenceGapSummary['detail_state']
: 'no_gaps';
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
$evidenceGapsSummary = null;
$evidenceGapsTooltip = null;
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
$parts = [];
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
continue;
}
$parts[] = $reason.' ('.((int) $count).')';
}
if ($hasEvidenceGaps) {
$parts = array_map(
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
BaselineCompareEvidenceGapDetails::topReasons(
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
5,
),
);
if ($parts !== []) {
$evidenceGapsSummary = implode(', ', $parts);
@ -211,6 +239,9 @@ protected function getViewData(): array
'hasEvidenceGaps' => $hasEvidenceGaps,
'hasWarnings' => $hasWarnings,
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
'evidenceGapDetailState' => $evidenceGapDetailState,
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
'evidenceGapsSummary' => $evidenceGapsSummary,
'evidenceGapsTooltip' => $evidenceGapsTooltip,
'findingsColorClass' => $findingsColorClass,

View File

@ -371,9 +371,9 @@ private static function applyLifecycleFilter(Builder $query, mixed $value): Buil
private static function gapCountExpression(Builder $query): string
{
return match ($query->getConnection()->getDriverName()) {
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.throttled') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.capture_failed') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS INTEGER), 0)",
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_evidence}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_role_definition_version_reference}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,invalid_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,duplicate_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,policy_not_found}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,throttled}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,capture_failed}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,budget_exhausted}')::int, 0)",
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.throttled') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.capture_failed') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS UNSIGNED), 0)",
'sqlite' => "MAX(0, COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0) - COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS INTEGER), 0))",
'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 0))",
default => "GREATEST(0, COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS SIGNED), 0) - COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS SIGNED), 0))",
};
}

View File

@ -11,6 +11,7 @@
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
@ -457,6 +458,9 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
if ((string) $record->type === 'baseline_compare') {
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
if ($baselineCompareFacts !== []) {
$builder->addSection(
@ -469,6 +473,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
);
}
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_gap_details',
kind: 'operational_context',
title: 'Evidence gap details',
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
view: 'filament.infolists.entries.evidence-gap-subjects',
viewData: [
'summary' => $gapSummary,
'buckets' => $gapBuckets,
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
],
collapsible: true,
collapsed: false,
),
);
}
if ($baselineCompareEvidence !== []) {
$builder->addSection(
$factory->viewSection(
@ -593,6 +616,8 @@ private static function baselineCompareFacts(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
): array {
$context = is_array($record->context) ? $record->context : [];
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
$facts = [];
$fidelity = data_get($context, 'baseline_compare.fidelity');
@ -624,6 +649,30 @@ private static function baselineCompareFacts(
);
}
if ((int) ($gapSummary['count'] ?? 0) > 0) {
$facts[] = $factory->keyFact(
'Evidence gap detail',
match ($gapSummary['detail_state'] ?? 'no_gaps') {
'structured_details_recorded' => 'Structured subject details available',
'details_not_recorded' => 'Detailed rows were not recorded',
'legacy_broad_reason' => 'Legacy development payload should be regenerated',
default => 'No evidence gaps recorded',
},
);
}
if ((int) ($gapSummary['structural_count'] ?? 0) > 0) {
$facts[] = $factory->keyFact('Structural gaps', (string) (int) $gapSummary['structural_count']);
}
if ((int) ($gapSummary['operational_count'] ?? 0) > 0) {
$facts[] = $factory->keyFact('Operational gaps', (string) (int) $gapSummary['operational_count']);
}
if ((int) ($gapSummary['transient_count'] ?? 0) > 0) {
$facts[] = $factory->keyFact('Transient gaps', (string) (int) $gapSummary['transient_count']);
}
if ($uncoveredTypes !== []) {
sort($uncoveredTypes, SORT_STRING);
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));

View File

@ -108,6 +108,7 @@ public function handle(
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$truthfulTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode
@ -127,6 +128,7 @@ public function handle(
scope: $effectiveScope,
identity: $identity,
latestInventorySyncRunId: $latestInventorySyncRunId,
policyTypes: $truthfulTypes,
);
$subjects = $inventoryResult['subjects'];
@ -262,6 +264,9 @@ public function handle(
'gaps' => [
'count' => $gapsCount,
'by_reason' => $gapsByReason,
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
? array_values($phaseResult['gap_subjects'])
: null,
],
'resume_token' => $resumeToken,
],
@ -296,7 +301,7 @@ public function handle(
/**
* @return array{
* subjects_total: int,
* subjects: list<array{policy_type: string, subject_external_id: string}>,
* subjects: list<array{policy_type: string, subject_external_id: string, subject_key: string}>,
* inventory_by_key: array<string, array{
* tenant_subject_external_id: string,
* workspace_subject_external_id: string,
@ -317,6 +322,7 @@ private function collectInventorySubjects(
BaselineScope $scope,
BaselineSnapshotIdentity $identity,
?int $latestInventorySyncRunId = null,
?array $policyTypes = null,
): array {
$query = InventoryItem::query()
->where('tenant_id', $sourceTenant->getKey());
@ -325,7 +331,7 @@ private function collectInventorySubjects(
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
}
$query->whereIn('policy_type', $scope->allTypes());
$query->whereIn('policy_type', is_array($policyTypes) && $policyTypes !== [] ? $policyTypes : $scope->allTypes());
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
$inventoryByKey = [];
@ -413,6 +419,7 @@ private function collectInventorySubjects(
static fn (array $item): array => [
'policy_type' => (string) $item['policy_type'],
'subject_external_id' => (string) $item['tenant_subject_external_id'],
'subject_key' => (string) $item['subject_key'],
],
$inventoryByKey,
));
@ -425,6 +432,27 @@ private function collectInventorySubjects(
];
}
/**
* @param array<string, mixed> $context
* @return list<string>
*/
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
{
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
if (is_array($truthfulTypes)) {
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
if ($truthfulTypes !== []) {
sort($truthfulTypes, SORT_STRING);
return $truthfulTypes;
}
}
return $effectiveScope->allTypes();
}
/**
* @param array<string, array{
* tenant_subject_external_id: string,

View File

@ -43,6 +43,7 @@
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Baselines\SubjectResolver;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
@ -144,7 +145,7 @@ public function handle(
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$effectiveTypes = $effectiveScope->allTypes();
$effectiveTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
@ -363,6 +364,7 @@ public function handle(
static fn (array $item): array => [
'policy_type' => (string) $item['policy_type'],
'subject_external_id' => (string) $item['subject_external_id'],
'subject_key' => (string) $item['subject_key'],
],
$currentItems,
));
@ -388,6 +390,7 @@ public function handle(
];
$phaseResult = [];
$phaseGaps = [];
$phaseGapSubjects = [];
$resumeToken = null;
if ($captureMode === BaselineCaptureMode::FullContent) {
@ -416,6 +419,7 @@ public function handle(
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
$phaseGapSubjects = is_array($phaseResult['gap_subjects'] ?? null) ? $phaseResult['gap_subjects'] : [];
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
}
@ -495,6 +499,12 @@ public function handle(
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
$gapsCount = array_sum($gapsByReason);
$gapSubjects = $this->collectGapSubjects(
ambiguousKeys: $ambiguousKeys,
phaseGapSubjects: $phaseGapSubjects ?? [],
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
);
$summaryCounts = [
'total' => count($driftResults),
'processed' => count($driftResults),
@ -572,6 +582,7 @@ public function handle(
'count' => $gapsCount,
'by_reason' => $gapsByReason,
...$gapsByReason,
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
],
'resume_token' => $resumeToken,
'coverage' => [
@ -1102,6 +1113,27 @@ private function snapshotBlockedMessage(string $reasonCode): string
};
}
/**
* @param array<string, mixed> $context
* @return list<string>
*/
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
{
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
if (is_array($truthfulTypes)) {
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
if ($truthfulTypes !== []) {
sort($truthfulTypes, SORT_STRING);
return $truthfulTypes;
}
}
return $effectiveScope->allTypes();
}
/**
* Compare baseline items vs current inventory and produce drift results.
*
@ -1134,6 +1166,7 @@ private function computeDrift(
): array {
$drift = [];
$evidenceGaps = [];
$evidenceGapSubjects = [];
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
@ -1175,6 +1208,7 @@ private function computeDrift(
if (! is_array($currentItem)) {
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
continue;
}
@ -1239,6 +1273,7 @@ private function computeDrift(
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
@ -1255,12 +1290,14 @@ private function computeDrift(
if ($isRbacRoleDefinition) {
if ($baselinePolicyVersionId === null) {
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
continue;
}
if ($currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
continue;
}
@ -1274,6 +1311,7 @@ private function computeDrift(
if ($roleDefinitionDiff === null) {
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
continue;
}
@ -1354,6 +1392,7 @@ private function computeDrift(
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
@ -1369,6 +1408,7 @@ private function computeDrift(
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
continue;
}
@ -1428,6 +1468,7 @@ private function computeDrift(
return [
'drift' => $drift,
'evidence_gaps' => $evidenceGaps,
'evidence_gap_subjects' => $evidenceGapSubjects,
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
];
}
@ -1939,6 +1980,163 @@ private function mergeGapCounts(array ...$gaps): array
return $merged;
}
private const GAP_SUBJECTS_LIMIT = 50;
/**
* @param list<string> $ambiguousKeys
* @return list<array<string, mixed>>
*/
private function collectGapSubjects(array $ambiguousKeys, mixed $phaseGapSubjects, mixed $driftGapSubjects): array
{
$subjects = [];
$seen = [];
if ($ambiguousKeys !== []) {
foreach (array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT) as $ambiguousKey) {
if (! is_string($ambiguousKey) || $ambiguousKey === '') {
continue;
}
[$policyType, $subjectKey] = $this->splitGapSubjectKey($ambiguousKey);
if ($policyType === null || $subjectKey === null) {
continue;
}
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
$record = array_merge($descriptor->toArray(), $this->subjectResolver()->ambiguousMatch($descriptor)->toArray());
$fingerprint = md5(json_encode([$record['policy_type'], $record['subject_key'], $record['reason_code']]));
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
$subjects[] = $record;
}
}
foreach ($this->normalizeStructuredGapSubjects($phaseGapSubjects) as $record) {
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
$subjects[] = $record;
}
foreach ($this->normalizeLegacyGapSubjects($driftGapSubjects) as $record) {
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
$subjects[] = $record;
}
return array_slice($subjects, 0, self::GAP_SUBJECTS_LIMIT);
}
/**
* @return list<array<string, mixed>>
*/
private function normalizeStructuredGapSubjects(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$subjects = [];
foreach ($value as $record) {
if (! is_array($record)) {
continue;
}
if (! is_string($record['policy_type'] ?? null) || ! is_string($record['subject_key'] ?? null) || ! is_string($record['reason_code'] ?? null)) {
continue;
}
$subjects[] = $record;
}
return $subjects;
}
/**
* @return list<array<string, mixed>>
*/
private function normalizeLegacyGapSubjects(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$subjects = [];
foreach ($value as $reasonCode => $keys) {
if (! is_string($reasonCode) || ! is_array($keys)) {
continue;
}
foreach ($keys as $key) {
if (! is_string($key) || $key === '') {
continue;
}
[$policyType, $subjectKey] = $this->splitGapSubjectKey($key);
if ($policyType === null || $subjectKey === null) {
continue;
}
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
$outcome = match ($reasonCode) {
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
default => $this->subjectResolver()->captureFailed($descriptor),
};
$record = array_merge($descriptor->toArray(), $outcome->toArray());
$record['reason_code'] = $reasonCode;
$subjects[] = $record;
}
}
return $subjects;
}
/**
* @return array{0: ?string, 1: ?string}
*/
private function splitGapSubjectKey(string $value): array
{
$parts = explode('|', $value, 2);
if (count($parts) !== 2) {
return [null, null];
}
[$policyType, $subjectKey] = $parts;
$policyType = trim($policyType);
$subjectKey = trim($subjectKey);
if ($policyType === '' || $subjectKey === '') {
return [null, null];
}
return [$policyType, $subjectKey];
}
private function subjectResolver(): SubjectResolver
{
return app(SubjectResolver::class);
}
/**
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence

View File

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

View File

@ -193,4 +193,81 @@ public function freshnessState(): OperationRunFreshnessState
{
return OperationRunFreshnessState::forRun($this);
}
/**
* @return array<string, mixed>
*/
public function baselineGapEnvelope(): array
{
$context = is_array($this->context) ? $this->context : [];
return match ((string) $this->type) {
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
? data_get($context, 'baseline_compare.evidence_gaps')
: [],
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
? data_get($context, 'baseline_capture.gaps')
: [],
default => [],
};
}
public function hasStructuredBaselineGapPayload(): bool
{
$subjects = $this->baselineGapEnvelope()['subjects'] ?? null;
if (! is_array($subjects) || ! array_is_list($subjects) || $subjects === []) {
return false;
}
foreach ($subjects as $subject) {
if (! is_array($subject)) {
return false;
}
foreach ([
'policy_type',
'subject_key',
'subject_class',
'resolution_path',
'resolution_outcome',
'reason_code',
'operator_action_category',
'structural',
'retryable',
] as $key) {
if (! array_key_exists($key, $subject)) {
return false;
}
}
}
return true;
}
public function hasLegacyBaselineGapPayload(): bool
{
$envelope = $this->baselineGapEnvelope();
$byReason = is_array($envelope['by_reason'] ?? null) ? $envelope['by_reason'] : [];
if (array_key_exists('policy_not_found', $byReason)) {
return true;
}
$subjects = $envelope['subjects'] ?? null;
if (! is_array($subjects)) {
return false;
}
if (! array_is_list($subjects)) {
return $subjects !== [];
}
if ($subjects === []) {
return false;
}
return ! $this->hasStructuredBaselineGapPayload();
}
}

View File

@ -15,6 +15,7 @@
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\OperationRunType;
final class BaselineCaptureService
@ -22,6 +23,7 @@ final class BaselineCaptureService
public function __construct(
private readonly OperationRunService $runs,
private readonly BaselineFullContentRolloutGate $rolloutGate,
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
) {}
/**
@ -53,7 +55,7 @@ public function startCapture(
],
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $sourceTenant->getKey(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
'capture_mode' => $captureMode->value,
];

View File

@ -17,6 +17,7 @@
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter;
@ -26,6 +27,7 @@ public function __construct(
private readonly OperationRunService $runs,
private readonly BaselineFullContentRolloutGate $rolloutGate,
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
) {}
/**
@ -101,7 +103,7 @@ public function startCompare(
],
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => $snapshotId,
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
'capture_mode' => $captureMode->value,
];

View File

@ -10,22 +10,28 @@
use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Support\Baselines\BaselineEvidenceResumeToken;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Baselines\ResolutionOutcomeRecord;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectDescriptor;
use App\Support\Baselines\SubjectResolver;
use Throwable;
final class BaselineContentCapturePhase
{
public function __construct(
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
private readonly ?SubjectResolver $subjectResolver = null,
) {}
/**
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
*
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
* @param list<array{policy_type: string, subject_external_id: string, subject_key?: string}> $subjects
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
* @return array{
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
* gaps: array<string, int>,
* gap_subjects: list<array<string, mixed>>,
* resume_token: ?string,
* captured_versions: array<string, array{
* policy_type: string,
@ -76,6 +82,8 @@ public function capture(
/** @var array<string, int> $gaps */
$gaps = [];
/** @var list<array<string, mixed>> $gapSubjects */
$gapSubjects = [];
$capturedVersions = [];
/**
@ -87,24 +95,40 @@ public function capture(
foreach ($chunk as $subject) {
$policyType = trim((string) ($subject['policy_type'] ?? ''));
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
$subjectKey = trim((string) ($subject['subject_key'] ?? ''));
$descriptor = $this->resolver()->describeForCapture(
$policyType !== '' ? $policyType : 'unknown',
$externalId !== '' ? $externalId : null,
$subjectKey !== '' ? $subjectKey : null,
);
if ($policyType === '' || $externalId === '') {
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor));
$stats['failed']++;
continue;
}
$subjectKey = $policyType.'|'.$externalId;
$captureKey = $policyType.'|'.$externalId;
if (isset($seen[$subjectKey])) {
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
if (isset($seen[$captureKey])) {
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor));
$stats['skipped']++;
continue;
}
$seen[$subjectKey] = true;
$seen[$captureKey] = true;
if (
$descriptor->resolutionPath === ResolutionPath::FoundationInventory
|| $descriptor->resolutionPath === ResolutionPath::Inventory
) {
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->structuralInventoryOnly($descriptor));
$stats['skipped']++;
continue;
}
$policy = Policy::query()
->where('tenant_id', (int) $tenant->getKey())
@ -113,7 +137,7 @@ public function capture(
->first();
if (! $policy instanceof Policy) {
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->missingExpectedRecord($descriptor));
$stats['failed']++;
continue;
@ -152,7 +176,7 @@ public function capture(
$version = $result['version'] ?? null;
if ($version instanceof PolicyVersion) {
$capturedVersions[$subjectKey] = [
$capturedVersions[$captureKey] = [
'policy_type' => $policyType,
'subject_external_id' => $externalId,
'version' => $version,
@ -178,10 +202,10 @@ public function capture(
}
if ($isThrottled) {
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor));
$stats['throttled']++;
} else {
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor));
$stats['failed']++;
}
@ -201,7 +225,22 @@ public function capture(
$remainingCount = max(0, count($subjects) - $processed);
if ($remainingCount > 0) {
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
foreach (array_slice($subjects, $processed) as $remainingSubject) {
$remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? ''));
$remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? ''));
$remainingSubjectKey = trim((string) ($remainingSubject['subject_key'] ?? ''));
if ($remainingPolicyType === '' || $remainingExternalId === '') {
continue;
}
$remainingDescriptor = $this->resolver()->describeForCapture(
$remainingPolicyType,
$remainingExternalId,
$remainingSubjectKey !== '' ? $remainingSubjectKey : null,
);
$this->recordGap($gaps, $gapSubjects, $remainingDescriptor, $this->resolver()->budgetExhausted($remainingDescriptor));
}
}
}
@ -210,11 +249,27 @@ public function capture(
return [
'stats' => $stats,
'gaps' => $gaps,
'gap_subjects' => $gapSubjects,
'resume_token' => $resumeTokenOut,
'captured_versions' => $capturedVersions,
];
}
/**
* @param array<string, int> $gaps
* @param list<array<string, mixed>> $gapSubjects
*/
private function recordGap(array &$gaps, array &$gapSubjects, SubjectDescriptor $descriptor, ResolutionOutcomeRecord $outcome): void
{
$gaps[$outcome->reasonCode] = ($gaps[$outcome->reasonCode] ?? 0) + 1;
$gapSubjects[] = array_merge($descriptor->toArray(), $outcome->toArray());
}
private function resolver(): SubjectResolver
{
return $this->subjectResolver ?? app(SubjectResolver::class);
}
private function retryDelayMs(int $attempt): int
{
$attempt = max(0, $attempt);

View File

@ -0,0 +1,661 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Models\OperationRun;
use Illuminate\Support\Str;
final class BaselineCompareEvidenceGapDetails
{
public static function fromOperationRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun || ! is_array($run->context)) {
return self::empty();
}
return self::fromContext($run->context);
}
/**
* @param array<string, mixed> $context
*/
public static function fromContext(array $context): array
{
$baselineCompare = $context['baseline_compare'] ?? null;
if (! is_array($baselineCompare)) {
return self::empty();
}
return self::fromBaselineCompare($baselineCompare);
}
/**
* @param array<string, mixed> $baselineCompare
*/
public static function fromBaselineCompare(array $baselineCompare): array
{
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
$normalizedSubjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
foreach ($normalizedSubjects['subjects'] as $reasonCode => $subjects) {
if (! array_key_exists($reasonCode, $byReason)) {
$byReason[$reasonCode] = count($subjects);
}
}
$count = self::normalizeTotalCount(
$evidenceGaps['count'] ?? null,
$byReason,
$normalizedSubjects['subjects'],
);
$detailState = self::detailState($count, $normalizedSubjects);
$buckets = [];
foreach (self::orderedReasons($byReason, $normalizedSubjects['subjects']) as $reasonCode) {
$rows = $detailState === 'structured_details_recorded'
? array_map(
static fn (array $subject): array => self::projectSubjectRow($subject),
$normalizedSubjects['subjects'][$reasonCode] ?? [],
)
: [];
$reasonCount = $byReason[$reasonCode] ?? count($rows);
if ($reasonCount <= 0 && $rows === []) {
continue;
}
$recordedCount = count($rows);
$structuralCount = count(array_filter(
$rows,
static fn (array $row): bool => (bool) ($row['structural'] ?? false),
));
$transientCount = count(array_filter(
$rows,
static fn (array $row): bool => (bool) ($row['retryable'] ?? false),
));
$operationalCount = max(0, $recordedCount - $structuralCount - $transientCount);
$searchText = trim(implode(' ', array_filter([
Str::lower($reasonCode),
Str::lower(self::reasonLabel($reasonCode)),
...array_map(
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
$rows,
),
])));
$buckets[] = [
'reason_code' => $reasonCode,
'reason_label' => self::reasonLabel($reasonCode),
'count' => $reasonCount,
'recorded_count' => $recordedCount,
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
'structural_count' => $structuralCount,
'operational_count' => $operationalCount,
'transient_count' => $transientCount,
'detail_state' => self::bucketDetailState($detailState, $recordedCount),
'search_text' => $searchText,
'rows' => $rows,
];
}
$recordedSubjectsTotal = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
$buckets,
));
$structuralCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['structural_count'] ?? 0),
$buckets,
));
$operationalCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['operational_count'] ?? 0),
$buckets,
));
$transientCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['transient_count'] ?? 0),
$buckets,
));
$legacyMode = $detailState === 'legacy_broad_reason';
return [
'summary' => [
'count' => $count,
'by_reason' => $byReason,
'detail_state' => $detailState,
'recorded_subjects_total' => $recordedSubjectsTotal,
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
'structural_count' => $structuralCount,
'operational_count' => $operationalCount,
'transient_count' => $transientCount,
'legacy_mode' => $legacyMode,
'requires_regeneration' => $legacyMode,
],
'buckets' => $buckets,
];
}
/**
* @param array<string, mixed> $baselineCompare
* @return array<string, mixed>
*/
public static function diagnosticsPayload(array $baselineCompare): array
{
return array_filter([
'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null),
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
], static fn (mixed $value): bool => $value !== null && $value !== []);
}
public static function reasonLabel(string $reason): string
{
$reason = trim($reason);
return match ($reason) {
'ambiguous_match' => 'Ambiguous inventory match',
'policy_record_missing' => 'Policy record missing',
'inventory_record_missing' => 'Inventory record missing',
'foundation_not_policy_backed' => 'Foundation not policy-backed',
'invalid_subject' => 'Invalid subject',
'duplicate_subject' => 'Duplicate subject',
'capture_failed' => 'Evidence capture failed',
'retryable_capture_failure' => 'Retryable evidence capture failure',
'budget_exhausted' => 'Capture budget exhausted',
'throttled' => 'Graph throttled',
'invalid_support_config' => 'Invalid support configuration',
'missing_current' => 'Missing current evidence',
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
'rollout_disabled' => 'Rollout disabled',
'policy_not_found' => 'Legacy policy not found',
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
};
}
public static function subjectClassLabel(string $subjectClass): string
{
return match (trim($subjectClass)) {
SubjectClass::PolicyBacked->value => 'Policy-backed',
SubjectClass::InventoryBacked->value => 'Inventory-backed',
SubjectClass::FoundationBacked->value => 'Foundation-backed',
default => 'Derived',
};
}
public static function resolutionOutcomeLabel(string $resolutionOutcome): string
{
return match (trim($resolutionOutcome)) {
ResolutionOutcome::ResolvedPolicy->value => 'Resolved policy',
ResolutionOutcome::ResolvedInventory->value => 'Resolved inventory',
ResolutionOutcome::PolicyRecordMissing->value => 'Policy record missing',
ResolutionOutcome::InventoryRecordMissing->value => 'Inventory record missing',
ResolutionOutcome::FoundationInventoryOnly->value => 'Foundation inventory only',
ResolutionOutcome::InvalidSubject->value => 'Invalid subject',
ResolutionOutcome::DuplicateSubject->value => 'Duplicate subject',
ResolutionOutcome::AmbiguousMatch->value => 'Ambiguous match',
ResolutionOutcome::InvalidSupportConfig->value => 'Invalid support configuration',
ResolutionOutcome::Throttled->value => 'Graph throttled',
ResolutionOutcome::CaptureFailed->value => 'Capture failed',
ResolutionOutcome::RetryableCaptureFailure->value => 'Retryable capture failure',
ResolutionOutcome::BudgetExhausted->value => 'Budget exhausted',
};
}
public static function operatorActionCategoryLabel(string $operatorActionCategory): string
{
return match (trim($operatorActionCategory)) {
OperatorActionCategory::Retry->value => 'Retry',
OperatorActionCategory::RunInventorySync->value => 'Run inventory sync',
OperatorActionCategory::RunPolicySyncOrBackup->value => 'Run policy sync or backup',
OperatorActionCategory::ReviewPermissions->value => 'Review permissions',
OperatorActionCategory::InspectSubjectMapping->value => 'Inspect subject mapping',
OperatorActionCategory::ProductFollowUp->value => 'Product follow-up',
default => 'No action',
};
}
/**
* @param array<string, int> $byReason
* @return list<array{reason_code: string, reason_label: string, count: int}>
*/
public static function topReasons(array $byReason, int $limit = 5): array
{
$normalized = self::normalizeCounts($byReason);
arsort($normalized);
return array_map(
static fn (string $reason, int $count): array => [
'reason_code' => $reason,
'reason_label' => self::reasonLabel($reason),
'count' => $count,
],
array_slice(array_keys($normalized), 0, $limit),
array_slice(array_values($normalized), 0, $limit),
);
}
/**
* @param list<array<string, mixed>> $buckets
* @return list<array<string, mixed>>
*/
public static function tableRows(array $buckets): array
{
$rows = [];
foreach ($buckets as $bucket) {
if (! is_array($bucket)) {
continue;
}
$bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
foreach ($bucketRows as $row) {
if (! is_array($row)) {
continue;
}
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
$policyType = self::stringOrNull($row['policy_type'] ?? null);
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
$subjectClass = self::stringOrNull($row['subject_class'] ?? null);
$resolutionOutcome = self::stringOrNull($row['resolution_outcome'] ?? null);
$operatorActionCategory = self::stringOrNull($row['operator_action_category'] ?? null);
if ($reasonCode === null || $policyType === null || $subjectKey === null || $subjectClass === null || $resolutionOutcome === null || $operatorActionCategory === null) {
continue;
}
$rows[] = [
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey, $resolutionOutcome])),
'reason_code' => $reasonCode,
'reason_label' => self::reasonLabel($reasonCode),
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'subject_class' => $subjectClass,
'subject_class_label' => self::subjectClassLabel($subjectClass),
'resolution_path' => self::stringOrNull($row['resolution_path'] ?? null),
'resolution_outcome' => $resolutionOutcome,
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
'operator_action_category' => $operatorActionCategory,
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
'structural' => (bool) ($row['structural'] ?? false),
'retryable' => (bool) ($row['retryable'] ?? false),
'search_text' => self::stringOrNull($row['search_text'] ?? null) ?? '',
];
}
}
return $rows;
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function reasonFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['reason_code'] => (string) $row['reason_label'],
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function policyTypeFilterOptions(array $rows): array
{
return collect($rows)
->pluck('policy_type')
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->mapWithKeys(fn (string $value): array => [$value => $value])
->sortKeysUsing('strnatcasecmp')
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function subjectClassFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['subject_class'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['subject_class'] => (string) ($row['subject_class_label'] ?? self::subjectClassLabel((string) $row['subject_class'])),
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function actionCategoryFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['operator_action_category'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['operator_action_category'] => (string) ($row['operator_action_category_label'] ?? self::operatorActionCategoryLabel((string) $row['operator_action_category'])),
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
private static function empty(): array
{
return [
'summary' => [
'count' => 0,
'by_reason' => [],
'detail_state' => 'no_gaps',
'recorded_subjects_total' => 0,
'missing_detail_count' => 0,
'structural_count' => 0,
'operational_count' => 0,
'transient_count' => 0,
'legacy_mode' => false,
'requires_regeneration' => false,
],
'buckets' => [],
];
}
/**
* @return array<string, int>
*/
private static function normalizeCounts(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $reason => $count) {
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) {
continue;
}
$intCount = (int) $count;
if ($intCount <= 0) {
continue;
}
$normalized[trim($reason)] = $intCount;
}
arsort($normalized);
return $normalized;
}
/**
* @return array{
* subjects: array<string, list<array<string, mixed>>>,
* legacy_mode: bool
* }
*/
private static function normalizeSubjects(mixed $value): array
{
if ($value === null) {
return [
'subjects' => [],
'legacy_mode' => false,
];
}
if (! is_array($value)) {
return [
'subjects' => [],
'legacy_mode' => true,
];
}
if (! array_is_list($value)) {
return [
'subjects' => [],
'legacy_mode' => true,
];
}
$subjects = [];
foreach ($value as $item) {
$normalized = self::normalizeStructuredSubject($item);
if ($normalized === null) {
return [
'subjects' => [],
'legacy_mode' => true,
];
}
$subjects[$normalized['reason_code']][] = $normalized;
}
foreach ($subjects as &$bucket) {
usort($bucket, static function (array $left, array $right): int {
return [$left['policy_type'], $left['subject_key'], $left['resolution_outcome']]
<=> [$right['policy_type'], $right['subject_key'], $right['resolution_outcome']];
});
}
unset($bucket);
ksort($subjects);
return [
'subjects' => $subjects,
'legacy_mode' => false,
];
}
/**
* @return array<string, mixed>|null
*/
private static function normalizeStructuredSubject(mixed $value): ?array
{
if (! is_array($value)) {
return null;
}
$policyType = self::stringOrNull($value['policy_type'] ?? null);
$subjectKey = self::stringOrNull($value['subject_key'] ?? null);
$subjectClass = self::stringOrNull($value['subject_class'] ?? null);
$resolutionPath = self::stringOrNull($value['resolution_path'] ?? null);
$resolutionOutcome = self::stringOrNull($value['resolution_outcome'] ?? null);
$reasonCode = self::stringOrNull($value['reason_code'] ?? null);
$operatorActionCategory = self::stringOrNull($value['operator_action_category'] ?? null);
if ($policyType === null
|| $subjectKey === null
|| $subjectClass === null
|| $resolutionPath === null
|| $resolutionOutcome === null
|| $reasonCode === null
|| $operatorActionCategory === null) {
return null;
}
if (! SubjectClass::tryFrom($subjectClass) instanceof SubjectClass
|| ! ResolutionPath::tryFrom($resolutionPath) instanceof ResolutionPath
|| ! ResolutionOutcome::tryFrom($resolutionOutcome) instanceof ResolutionOutcome
|| ! OperatorActionCategory::tryFrom($operatorActionCategory) instanceof OperatorActionCategory) {
return null;
}
$sourceModelExpected = self::stringOrNull($value['source_model_expected'] ?? null);
$sourceModelExpected = in_array($sourceModelExpected, ['policy', 'inventory', 'derived'], true) ? $sourceModelExpected : null;
$sourceModelFound = self::stringOrNull($value['source_model_found'] ?? null);
$sourceModelFound = in_array($sourceModelFound, ['policy', 'inventory', 'derived'], true) ? $sourceModelFound : null;
return [
'policy_type' => $policyType,
'subject_external_id' => self::stringOrNull($value['subject_external_id'] ?? null),
'subject_key' => $subjectKey,
'subject_class' => $subjectClass,
'resolution_path' => $resolutionPath,
'resolution_outcome' => $resolutionOutcome,
'reason_code' => $reasonCode,
'operator_action_category' => $operatorActionCategory,
'structural' => self::boolOrFalse($value['structural'] ?? null),
'retryable' => self::boolOrFalse($value['retryable'] ?? null),
'source_model_expected' => $sourceModelExpected,
'source_model_found' => $sourceModelFound,
'legacy_reason_code' => self::stringOrNull($value['legacy_reason_code'] ?? null),
];
}
/**
* @param array<string, int> $byReason
* @param array<string, list<array<string, mixed>>> $subjects
* @return list<string>
*/
private static function orderedReasons(array $byReason, array $subjects): array
{
$reasons = array_keys($byReason);
foreach (array_keys($subjects) as $reason) {
if (! in_array($reason, $reasons, true)) {
$reasons[] = $reason;
}
}
return $reasons;
}
/**
* @param array<string, int> $byReason
* @param array<string, list<array<string, mixed>>> $subjects
*/
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
{
if (is_numeric($count)) {
$intCount = (int) $count;
if ($intCount >= 0) {
return $intCount;
}
}
$byReasonCount = array_sum($byReason);
if ($byReasonCount > 0) {
return $byReasonCount;
}
return array_sum(array_map(
static fn (array $rows): int => count($rows),
$subjects,
));
}
/**
* @param array{subjects: array<string, list<array<string, mixed>>>, legacy_mode: bool} $subjects
*/
private static function detailState(int $count, array $subjects): string
{
if ($count <= 0) {
return 'no_gaps';
}
if ($subjects['legacy_mode']) {
return 'legacy_broad_reason';
}
return $subjects['subjects'] !== [] ? 'structured_details_recorded' : 'details_not_recorded';
}
private static function bucketDetailState(string $detailState, int $recordedCount): string
{
if ($detailState === 'legacy_broad_reason') {
return 'legacy_broad_reason';
}
if ($recordedCount > 0) {
return 'structured_details_recorded';
}
return 'details_not_recorded';
}
/**
* @param array<string, mixed> $subject
* @return array<string, mixed>
*/
private static function projectSubjectRow(array $subject): array
{
$reasonCode = (string) $subject['reason_code'];
$subjectClass = (string) $subject['subject_class'];
$resolutionOutcome = (string) $subject['resolution_outcome'];
$operatorActionCategory = (string) $subject['operator_action_category'];
return array_merge($subject, [
'reason_label' => self::reasonLabel($reasonCode),
'subject_class_label' => self::subjectClassLabel($subjectClass),
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
'search_text' => Str::lower(trim(implode(' ', array_filter([
$reasonCode,
self::reasonLabel($reasonCode),
(string) ($subject['policy_type'] ?? ''),
(string) ($subject['subject_key'] ?? ''),
$subjectClass,
self::subjectClassLabel($subjectClass),
(string) ($subject['resolution_path'] ?? ''),
$resolutionOutcome,
self::resolutionOutcomeLabel($resolutionOutcome),
$operatorActionCategory,
self::operatorActionCategoryLabel($operatorActionCategory),
(string) ($subject['subject_external_id'] ?? ''),
])))),
]);
}
private static function stringOrNull(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
private static function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private static function boolOrFalse(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value) || is_string($value)) {
return filter_var($value, FILTER_VALIDATE_BOOL);
}
return false;
}
}

View File

@ -181,6 +181,36 @@ private function countDescriptors(
);
}
if ($stats->evidenceGapStructuralCount !== null && $stats->evidenceGapStructuralCount > 0) {
$descriptors[] = new CountDescriptor(
label: 'Structural gaps',
value: (int) $stats->evidenceGapStructuralCount,
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: 'product or support limit',
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
);
}
if ($stats->evidenceGapOperationalCount !== null && $stats->evidenceGapOperationalCount > 0) {
$descriptors[] = new CountDescriptor(
label: 'Operational gaps',
value: (int) $stats->evidenceGapOperationalCount,
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: 'local evidence missing',
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
);
}
if ($stats->evidenceGapTransientCount !== null && $stats->evidenceGapTransientCount > 0) {
$descriptors[] = new CountDescriptor(
label: 'Transient gaps',
value: (int) $stats->evidenceGapTransientCount,
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: 'retry may help',
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
);
}
if ($stats->severityCounts !== []) {
foreach (['high' => 'High severity', 'medium' => 'Medium severity', 'low' => 'Low severity'] as $key => $label) {
$value = (int) ($stats->severityCounts[$key] ?? 0);

View File

@ -24,6 +24,32 @@ final class BaselineCompareStats
* @param array<string, int> $severityCounts
* @param list<string> $uncoveredTypes
* @param array<string, int> $evidenceGapsTopReasons
* @param array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* } $evidenceGapDetails
* @param array<string, mixed> $baselineCompareDiagnostics
*/
private function __construct(
public readonly string $state,
@ -32,6 +58,7 @@ private function __construct(
public readonly ?int $profileId,
public readonly ?int $snapshotId,
public readonly ?int $duplicateNamePoliciesCount,
public readonly ?int $duplicateNameSubjectsCount,
public readonly ?int $operationRunId,
public readonly ?int $findingsCount,
public readonly array $severityCounts,
@ -47,6 +74,12 @@ private function __construct(
public readonly ?int $evidenceGapsCount = null,
public readonly array $evidenceGapsTopReasons = [],
public readonly ?array $rbacRoleDefinitionSummary = null,
public readonly array $evidenceGapDetails = [],
public readonly array $baselineCompareDiagnostics = [],
public readonly ?int $evidenceGapStructuralCount = null,
public readonly ?int $evidenceGapOperationalCount = null,
public readonly ?int $evidenceGapTransientCount = null,
public readonly ?bool $evidenceGapLegacyMode = null,
) {}
public static function forTenant(?Tenant $tenant): self
@ -91,7 +124,9 @@ public static function forTenant(?Tenant $tenant): self
: null;
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
$duplicateNameStats = self::duplicateNameStats($tenant, $effectiveScope);
$duplicateNamePoliciesCount = $duplicateNameStats['policy_count'];
$duplicateNameSubjectsCount = $duplicateNameStats['subject_count'];
if ($snapshotId === null) {
return new self(
@ -101,6 +136,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: null,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: null,
findingsCount: null,
severityCounts: [],
@ -122,6 +158,21 @@ public static function forTenant(?Tenant $tenant): self
[$evidenceGapsCount, $evidenceGapsTopReasons] = self::evidenceGapSummaryForRun($latestRun);
[$reasonCode, $reasonMessage] = self::reasonInfoForRun($latestRun);
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
$evidenceGapSummary = is_array($evidenceGapDetails['summary'] ?? null) ? $evidenceGapDetails['summary'] : [];
$evidenceGapStructuralCount = is_numeric($evidenceGapSummary['structural_count'] ?? null)
? (int) $evidenceGapSummary['structural_count']
: null;
$evidenceGapOperationalCount = is_numeric($evidenceGapSummary['operational_count'] ?? null)
? (int) $evidenceGapSummary['operational_count']
: null;
$evidenceGapTransientCount = is_numeric($evidenceGapSummary['transient_count'] ?? null)
? (int) $evidenceGapSummary['transient_count']
: null;
$evidenceGapLegacyMode = is_bool($evidenceGapSummary['legacy_mode'] ?? null)
? (bool) $evidenceGapSummary['legacy_mode']
: null;
// Active run (queued/running)
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
@ -132,6 +183,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: (int) $latestRun->getKey(),
findingsCount: null,
severityCounts: [],
@ -147,6 +199,12 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -164,6 +222,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: (int) $latestRun->getKey(),
findingsCount: null,
severityCounts: [],
@ -179,6 +238,12 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -218,6 +283,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings,
severityCounts: $severityCounts,
@ -233,6 +299,12 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -246,6 +318,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: (int) $latestRun->getKey(),
findingsCount: 0,
severityCounts: $severityCounts,
@ -261,6 +334,12 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -271,6 +350,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: null,
findingsCount: null,
severityCounts: $severityCounts,
@ -286,6 +366,12 @@ public static function forTenant(?Tenant $tenant): self
evidenceGapsCount: $evidenceGapsCount,
evidenceGapsTopReasons: $evidenceGapsTopReasons,
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -342,6 +428,7 @@ public static function forWidget(?Tenant $tenant): self
profileId: (int) $profile->getKey(),
snapshotId: $snapshotId,
duplicateNamePoliciesCount: null,
duplicateNameSubjectsCount: null,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings,
severityCounts: [
@ -357,17 +444,23 @@ public static function forWidget(?Tenant $tenant): self
);
}
private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int
/**
* @return array{policy_count: int, subject_count: int}
*/
private static function duplicateNameStats(Tenant $tenant, BaselineScope $effectiveScope): array
{
$policyTypes = $effectiveScope->allTypes();
if ($policyTypes === []) {
return 0;
return [
'policy_count' => 0,
'subject_count' => 0,
];
}
$latestInventorySyncRunId = self::latestInventorySyncRunId($tenant);
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): int {
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): array {
/**
* @var array<string, int> $countsByKey
*/
@ -400,14 +493,19 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
});
$duplicatePolicies = 0;
$duplicateSubjects = 0;
foreach ($countsByKey as $count) {
if ($count > 1) {
$duplicateSubjects++;
$duplicatePolicies += $count;
}
}
return $duplicatePolicies;
return [
'policy_count' => $duplicatePolicies,
'subject_count' => $duplicateSubjects,
];
};
if (app()->environment('testing')) {
@ -421,7 +519,10 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
$latestInventorySyncRunId ?? 'all',
);
return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute);
/** @var array{policy_count: int, subject_count: int} $stats */
$stats = Cache::remember($cacheKey, now()->addSeconds(60), $compute);
return $stats;
}
private static function latestInventorySyncRunId(Tenant $tenant): ?int
@ -515,48 +616,67 @@ private static function evidenceGapSummaryForRun(?OperationRun $run): array
return [null, []];
}
$details = self::evidenceGapDetailsForRun($run);
$summary = is_array($details['summary'] ?? null) ? $details['summary'] : [];
$count = is_numeric($summary['count'] ?? null) ? (int) $summary['count'] : null;
$byReason = is_array($summary['by_reason'] ?? null) ? $summary['by_reason'] : [];
return [$count, array_slice($byReason, 0, 6, true)];
}
/**
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
private static function evidenceGapDetailsForRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun) {
return BaselineCompareEvidenceGapDetails::fromContext([]);
}
return BaselineCompareEvidenceGapDetails::fromOperationRun($run);
}
/**
* @return array<string, mixed>
*/
private static function baselineCompareDiagnosticsForRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun) {
return [];
}
$context = is_array($run->context) ? $run->context : [];
$baselineCompare = $context['baseline_compare'] ?? null;
if (! is_array($baselineCompare)) {
return [null, []];
return [];
}
$gaps = $baselineCompare['evidence_gaps'] ?? null;
if (! is_array($gaps)) {
return [null, []];
}
$count = $gaps['count'] ?? null;
$count = is_numeric($count) ? (int) $count : null;
$byReason = $gaps['by_reason'] ?? null;
$byReason = is_array($byReason) ? $byReason : [];
$normalized = [];
foreach ($byReason as $reason => $value) {
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($value)) {
continue;
}
$intValue = (int) $value;
if ($intValue <= 0) {
continue;
}
$normalized[trim($reason)] = $intValue;
}
if ($count === null) {
$count = array_sum($normalized);
}
arsort($normalized);
return [$count, array_slice($normalized, 0, 6, true)];
return BaselineCompareEvidenceGapDetails::diagnosticsPayload($baselineCompare);
}
/**
@ -616,6 +736,7 @@ private static function empty(
?string $profileName = null,
?int $profileId = null,
?int $duplicateNamePoliciesCount = null,
?int $duplicateNameSubjectsCount = null,
): self {
return new self(
state: $state,
@ -624,6 +745,7 @@ private static function empty(
profileId: $profileId,
snapshotId: null,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: null,
findingsCount: null,
severityCounts: [],

View File

@ -118,6 +118,17 @@ public function allTypes(): array
));
}
/**
* @return list<string>
*/
public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
{
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
return $guardResult['allowed_types'];
}
/**
* @return array<string, mixed>
*/
@ -134,17 +145,32 @@ public function toJsonb(): array
*
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
*/
public function toEffectiveScopeContext(): array
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
{
$expanded = $this->expandDefaults();
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
return [
$context = [
'policy_types' => $expanded->policyTypes,
'foundation_types' => $expanded->foundationTypes,
'all_types' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [],
];
if (! is_string($operation) || $operation === '') {
return $context;
}
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($allTypes, $operation);
return array_merge($context, [
'truthful_types' => $guardResult['allowed_types'],
'limited_types' => $guardResult['limited_types'],
'unsupported_types' => $guardResult['unsupported_types'],
'invalid_support_types' => $guardResult['invalid_support_types'],
'capabilities' => $guardResult['capabilities'],
]);
}
/**

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
final class BaselineSupportCapabilityGuard
{
public function __construct(
private readonly SubjectResolver $resolver,
) {}
public function inspectType(string $policyType): SupportCapabilityRecord
{
return $this->resolver->capability($policyType);
}
/**
* @param list<string> $policyTypes
* @return array{
* allowed_types: list<string>,
* limited_types: list<string>,
* unsupported_types: list<string>,
* invalid_support_types: list<string>,
* capabilities: array<string, array<string, mixed>>
* }
*/
public function guardTypes(array $policyTypes, string $operation): array
{
$allowedTypes = [];
$limitedTypes = [];
$unsupportedTypes = [];
$invalidSupportTypes = [];
$capabilities = [];
foreach (array_values(array_unique(array_filter($policyTypes, 'is_string'))) as $policyType) {
$record = $this->inspectType($policyType);
$mode = $record->supportModeFor($operation);
$capabilities[$policyType] = array_merge(
$record->toArray(),
['support_mode' => $mode],
);
if ($mode === 'invalid_support_config') {
$invalidSupportTypes[] = $policyType;
$unsupportedTypes[] = $policyType;
continue;
}
if ($record->allows($operation)) {
$allowedTypes[] = $policyType;
if ($mode === 'limited') {
$limitedTypes[] = $policyType;
}
continue;
}
$unsupportedTypes[] = $policyType;
}
sort($allowedTypes, SORT_STRING);
sort($limitedTypes, SORT_STRING);
sort($unsupportedTypes, SORT_STRING);
sort($invalidSupportTypes, SORT_STRING);
ksort($capabilities);
return [
'allowed_types' => $allowedTypes,
'limited_types' => $limitedTypes,
'unsupported_types' => $unsupportedTypes,
'invalid_support_types' => $invalidSupportTypes,
'capabilities' => $capabilities,
];
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum OperatorActionCategory: string
{
case None = 'none';
case Retry = 'retry';
case RunInventorySync = 'run_inventory_sync';
case RunPolicySyncOrBackup = 'run_policy_sync_or_backup';
case ReviewPermissions = 'review_permissions';
case InspectSubjectMapping = 'inspect_subject_mapping';
case ProductFollowUp = 'product_follow_up';
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum ResolutionOutcome: string
{
case ResolvedPolicy = 'resolved_policy';
case ResolvedInventory = 'resolved_inventory';
case PolicyRecordMissing = 'policy_record_missing';
case InventoryRecordMissing = 'inventory_record_missing';
case FoundationInventoryOnly = 'foundation_inventory_only';
case ResolutionTypeMismatch = 'resolution_type_mismatch';
case UnresolvableSubject = 'unresolvable_subject';
case InvalidSupportConfig = 'invalid_support_config';
case PermissionOrScopeBlocked = 'permission_or_scope_blocked';
case AmbiguousMatch = 'ambiguous_match';
case InvalidSubject = 'invalid_subject';
case DuplicateSubject = 'duplicate_subject';
case RetryableCaptureFailure = 'retryable_capture_failure';
case CaptureFailed = 'capture_failed';
case Throttled = 'throttled';
case BudgetExhausted = 'budget_exhausted';
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
final class ResolutionOutcomeRecord
{
/**
* @param non-empty-string $reasonCode
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
* @param 'policy'|'inventory'|'derived'|null $sourceModelFound
*/
public function __construct(
public readonly ResolutionOutcome $resolutionOutcome,
public readonly string $reasonCode,
public readonly OperatorActionCategory $operatorActionCategory,
public readonly bool $structural,
public readonly bool $retryable,
public readonly ?string $sourceModelExpected = null,
public readonly ?string $sourceModelFound = null,
) {}
public function toArray(): array
{
return [
'resolution_outcome' => $this->resolutionOutcome->value,
'reason_code' => $this->reasonCode,
'operator_action_category' => $this->operatorActionCategory->value,
'structural' => $this->structural,
'retryable' => $this->retryable,
'source_model_expected' => $this->sourceModelExpected,
'source_model_found' => $this->sourceModelFound,
];
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum ResolutionPath: string
{
case Policy = 'policy';
case Inventory = 'inventory';
case FoundationPolicy = 'foundation_policy';
case FoundationInventory = 'foundation_inventory';
case Derived = 'derived';
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum SubjectClass: string
{
case PolicyBacked = 'policy_backed';
case InventoryBacked = 'inventory_backed';
case FoundationBacked = 'foundation_backed';
case Derived = 'derived';
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
final class SubjectDescriptor
{
/**
* @param non-empty-string $policyType
* @param non-empty-string $subjectKey
* @param 'supported'|'limited'|'excluded'|'invalid_support_config' $supportMode
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
*/
public function __construct(
public readonly string $policyType,
public readonly ?string $subjectExternalId,
public readonly string $subjectKey,
public readonly SubjectClass $subjectClass,
public readonly ResolutionPath $resolutionPath,
public readonly string $supportMode,
public readonly ?string $sourceModelExpected,
) {}
public function expectsPolicy(): bool
{
return $this->sourceModelExpected === 'policy';
}
public function expectsInventory(): bool
{
return $this->sourceModelExpected === 'inventory';
}
public function toArray(): array
{
return [
'policy_type' => $this->policyType,
'subject_external_id' => $this->subjectExternalId,
'subject_key' => $this->subjectKey,
'subject_class' => $this->subjectClass->value,
'resolution_path' => $this->resolutionPath->value,
'support_mode' => $this->supportMode,
'source_model_expected' => $this->sourceModelExpected,
];
}
}

View File

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Support\Inventory\InventoryPolicyTypeMeta;
final class SubjectResolver
{
public function capability(string $policyType): SupportCapabilityRecord
{
$contract = InventoryPolicyTypeMeta::baselineSupportContract($policyType);
return new SupportCapabilityRecord(
policyType: $policyType,
subjectClass: SubjectClass::from($contract['subject_class']),
compareCapability: $contract['compare_capability'],
captureCapability: $contract['capture_capability'],
resolutionPath: ResolutionPath::from($contract['resolution_path']),
configSupported: (bool) $contract['config_supported'],
runtimeValid: (bool) $contract['runtime_valid'],
sourceModelExpected: $contract['source_model_expected'],
);
}
public function describeForCompare(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
{
return $this->describe(operation: 'compare', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
}
public function describeForCapture(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
{
return $this->describe(operation: 'capture', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
}
public function resolved(SubjectDescriptor $descriptor, ?string $sourceModelFound = null): ResolutionOutcomeRecord
{
$outcome = $descriptor->expectsPolicy()
? ResolutionOutcome::ResolvedPolicy
: ResolutionOutcome::ResolvedInventory;
return new ResolutionOutcomeRecord(
resolutionOutcome: $outcome,
reasonCode: $outcome->value,
operatorActionCategory: OperatorActionCategory::None,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
sourceModelFound: $sourceModelFound ?? $descriptor->sourceModelExpected,
);
}
public function missingExpectedRecord(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
$expectsPolicy = $descriptor->expectsPolicy();
return new ResolutionOutcomeRecord(
resolutionOutcome: $expectsPolicy ? ResolutionOutcome::PolicyRecordMissing : ResolutionOutcome::InventoryRecordMissing,
reasonCode: $expectsPolicy ? 'policy_record_missing' : 'inventory_record_missing',
operatorActionCategory: $expectsPolicy ? OperatorActionCategory::RunPolicySyncOrBackup : OperatorActionCategory::RunInventorySync,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function structuralInventoryOnly(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::FoundationInventoryOnly,
reasonCode: 'foundation_not_policy_backed',
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
structural: true,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
sourceModelFound: 'inventory',
);
}
public function invalidSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::InvalidSubject,
reasonCode: 'invalid_subject',
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function duplicateSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::DuplicateSubject,
reasonCode: 'duplicate_subject',
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function ambiguousMatch(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::AmbiguousMatch,
reasonCode: 'ambiguous_match',
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function invalidSupportConfiguration(SupportCapabilityRecord $capability): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::InvalidSupportConfig,
reasonCode: 'invalid_support_config',
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
structural: true,
retryable: false,
sourceModelExpected: $capability->sourceModelExpected,
);
}
public function throttled(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::Throttled,
reasonCode: 'throttled',
operatorActionCategory: OperatorActionCategory::Retry,
structural: false,
retryable: true,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function captureFailed(SubjectDescriptor $descriptor, bool $retryable = false): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: $retryable ? ResolutionOutcome::RetryableCaptureFailure : ResolutionOutcome::CaptureFailed,
reasonCode: $retryable ? 'retryable_capture_failure' : 'capture_failed',
operatorActionCategory: OperatorActionCategory::Retry,
structural: false,
retryable: $retryable,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function budgetExhausted(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::BudgetExhausted,
reasonCode: 'budget_exhausted',
operatorActionCategory: OperatorActionCategory::Retry,
structural: false,
retryable: true,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
private function describe(string $operation, string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
{
$capability = $this->capability($policyType);
$resolvedSubjectKey = $this->normalizeSubjectKey($policyType, $subjectExternalId, $subjectKey);
return new SubjectDescriptor(
policyType: $policyType,
subjectExternalId: $subjectExternalId !== null && trim($subjectExternalId) !== '' ? trim($subjectExternalId) : null,
subjectKey: $resolvedSubjectKey,
subjectClass: $capability->subjectClass,
resolutionPath: $capability->resolutionPath,
supportMode: $capability->supportModeFor($operation),
sourceModelExpected: $capability->sourceModelExpected,
);
}
private function normalizeSubjectKey(string $policyType, ?string $subjectExternalId, ?string $subjectKey): string
{
$trimmedSubjectKey = is_string($subjectKey) ? trim($subjectKey) : '';
if ($trimmedSubjectKey !== '') {
return $trimmedSubjectKey;
}
$generated = BaselineSubjectKey::forPolicy($policyType, subjectExternalId: $subjectExternalId);
if (is_string($generated) && $generated !== '') {
return $generated;
}
$fallbackExternalId = is_string($subjectExternalId) && trim($subjectExternalId) !== ''
? trim($subjectExternalId)
: 'unknown';
return trim($policyType).'|'.$fallbackExternalId;
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use InvalidArgumentException;
final class SupportCapabilityRecord
{
/**
* @param non-empty-string $policyType
* @param 'supported'|'limited'|'unsupported' $compareCapability
* @param 'supported'|'limited'|'unsupported' $captureCapability
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
*/
public function __construct(
public readonly string $policyType,
public readonly SubjectClass $subjectClass,
public readonly string $compareCapability,
public readonly string $captureCapability,
public readonly ResolutionPath $resolutionPath,
public readonly bool $configSupported,
public readonly bool $runtimeValid,
public readonly ?string $sourceModelExpected = null,
) {}
/**
* @return 'supported'|'limited'|'excluded'|'invalid_support_config'
*/
public function supportModeFor(string $operation): string
{
$capability = match ($operation) {
'compare' => $this->compareCapability,
'capture' => $this->captureCapability,
default => throw new InvalidArgumentException('Unsupported operation ['.$operation.'].'),
};
if ($this->configSupported && ! $this->runtimeValid) {
return 'invalid_support_config';
}
return match ($capability) {
'supported', 'limited' => $capability,
default => 'excluded',
};
}
public function allows(string $operation): bool
{
return in_array($this->supportModeFor($operation), ['supported', 'limited'], true);
}
public function toArray(): array
{
return [
'policy_type' => $this->policyType,
'subject_class' => $this->subjectClass->value,
'compare_capability' => $this->compareCapability,
'capture_capability' => $this->captureCapability,
'resolution_path' => $this->resolutionPath->value,
'config_supported' => $this->configSupported,
'runtime_valid' => $this->runtimeValid,
'source_model_expected' => $this->sourceModelExpected,
];
}
}

View File

@ -4,6 +4,9 @@
namespace App\Support\Inventory;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
class InventoryPolicyTypeMeta
{
/**
@ -175,4 +178,141 @@ public static function baselineCompareLabel(?string $type): ?string
return static::label($type);
}
/**
* @return array{
* config_supported: bool,
* runtime_valid: bool,
* subject_class: string,
* resolution_path: string,
* compare_capability: string,
* capture_capability: string,
* source_model_expected: 'policy'|'inventory'|'derived'|null
* }
*/
public static function baselineSupportContract(?string $type): array
{
$contract = static::defaultBaselineSupportContract($type);
$resolution = static::baselineCompareMeta($type)['resolution'] ?? null;
if (is_array($resolution)) {
$contract = array_replace($contract, array_filter([
'subject_class' => is_string($resolution['subject_class'] ?? null) ? $resolution['subject_class'] : null,
'resolution_path' => is_string($resolution['resolution_path'] ?? null) ? $resolution['resolution_path'] : null,
'compare_capability' => is_string($resolution['compare_capability'] ?? null) ? $resolution['compare_capability'] : null,
'capture_capability' => is_string($resolution['capture_capability'] ?? null) ? $resolution['capture_capability'] : null,
'source_model_expected' => is_string($resolution['source_model_expected'] ?? null) ? $resolution['source_model_expected'] : null,
], static fn (mixed $value): bool => $value !== null));
}
$subjectClass = SubjectClass::tryFrom((string) ($contract['subject_class'] ?? ''));
$resolutionPath = ResolutionPath::tryFrom((string) ($contract['resolution_path'] ?? ''));
$compareCapability = in_array($contract['compare_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
? (string) $contract['compare_capability']
: 'unsupported';
$captureCapability = in_array($contract['capture_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
? (string) $contract['capture_capability']
: 'unsupported';
$sourceModelExpected = in_array($contract['source_model_expected'] ?? null, ['policy', 'inventory', 'derived'], true)
? (string) $contract['source_model_expected']
: null;
$runtimeValid = $subjectClass instanceof SubjectClass
&& $resolutionPath instanceof ResolutionPath
&& static::pathMatchesSubjectClass($subjectClass, $resolutionPath)
&& static::pathMatchesExpectedSource($resolutionPath, $sourceModelExpected);
if (! $runtimeValid) {
$compareCapability = 'unsupported';
$captureCapability = 'unsupported';
}
return [
'config_supported' => (bool) ($contract['config_supported'] ?? false),
'runtime_valid' => $runtimeValid,
'subject_class' => ($subjectClass ?? SubjectClass::Derived)->value,
'resolution_path' => ($resolutionPath ?? ResolutionPath::Derived)->value,
'compare_capability' => $compareCapability,
'capture_capability' => $captureCapability,
'source_model_expected' => $sourceModelExpected,
];
}
/**
* @return array{
* config_supported: bool,
* subject_class: string,
* resolution_path: string,
* compare_capability: string,
* capture_capability: string,
* source_model_expected: 'policy'|'inventory'|'derived'|null
* }
*/
private static function defaultBaselineSupportContract(?string $type): array
{
if (filled($type) && ! static::isFoundation($type) && static::metaFor($type) !== []) {
return [
'config_supported' => true,
'subject_class' => SubjectClass::PolicyBacked->value,
'resolution_path' => ResolutionPath::Policy->value,
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'policy',
];
}
if (static::isFoundation($type)) {
$supported = (bool) (static::baselineCompareMeta($type)['supported'] ?? false);
$identityStrategy = static::baselineCompareIdentityStrategy($type);
$usesPolicyPath = $identityStrategy === 'external_id';
return [
'config_supported' => $supported,
'subject_class' => SubjectClass::FoundationBacked->value,
'resolution_path' => $usesPolicyPath
? ResolutionPath::FoundationPolicy->value
: ResolutionPath::FoundationInventory->value,
'compare_capability' => ! $supported
? 'unsupported'
: ($usesPolicyPath ? 'supported' : 'limited'),
'capture_capability' => ! $supported
? 'unsupported'
: ($usesPolicyPath ? 'supported' : 'limited'),
'source_model_expected' => $usesPolicyPath ? 'policy' : 'inventory',
];
}
return [
'config_supported' => false,
'subject_class' => SubjectClass::Derived->value,
'resolution_path' => ResolutionPath::Derived->value,
'compare_capability' => 'unsupported',
'capture_capability' => 'unsupported',
'source_model_expected' => 'derived',
];
}
private static function pathMatchesSubjectClass(SubjectClass $subjectClass, ResolutionPath $resolutionPath): bool
{
return match ($subjectClass) {
SubjectClass::PolicyBacked => $resolutionPath === ResolutionPath::Policy,
SubjectClass::InventoryBacked => $resolutionPath === ResolutionPath::Inventory,
SubjectClass::FoundationBacked => in_array($resolutionPath, [
ResolutionPath::FoundationInventory,
ResolutionPath::FoundationPolicy,
], true),
SubjectClass::Derived => $resolutionPath === ResolutionPath::Derived,
};
}
private static function pathMatchesExpectedSource(ResolutionPath $resolutionPath, ?string $sourceModelExpected): bool
{
return match ($resolutionPath) {
ResolutionPath::Policy,
ResolutionPath::FoundationPolicy => $sourceModelExpected === 'policy',
ResolutionPath::Inventory,
ResolutionPath::FoundationInventory => $sourceModelExpected === 'inventory',
ResolutionPath::Derived => $sourceModelExpected === 'derived',
};
}
}

View File

@ -412,6 +412,13 @@
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'display_name',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_inventory',
'compare_capability' => 'limited',
'capture_capability' => 'limited',
'source_model_expected' => 'inventory',
],
],
],
[
@ -426,6 +433,13 @@
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'display_name',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_inventory',
'compare_capability' => 'limited',
'capture_capability' => 'limited',
'source_model_expected' => 'inventory',
],
],
],
[
@ -440,6 +454,13 @@
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'external_id',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'policy',
],
],
],
[
@ -454,6 +475,13 @@
'baseline_compare' => [
'supported' => false,
'identity_strategy' => 'external_id',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
'compare_capability' => 'unsupported',
'capture_capability' => 'unsupported',
'source_model_expected' => 'policy',
],
],
],
[
@ -468,6 +496,13 @@
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'display_name',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_inventory',
'compare_capability' => 'limited',
'capture_capability' => 'limited',
'source_model_expected' => 'inventory',
],
],
],
],

View File

@ -5,7 +5,7 @@ # Spec Candidates
>
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
**Last reviewed**: 2026-03-24 (added Operation Run Active-State Visibility & Stale Escalation candidate)
**Last reviewed**: 2026-03-24 (added Baseline Compare Scope Guardrails & Ambiguity Guidance candidate)
---
@ -359,6 +359,50 @@ ### Baseline Snapshot Fidelity Semantics
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, Structured Snapshot Rendering (Spec 130)
- **Priority**: high
### Baseline Compare Scope Guardrails & Ambiguity Guidance
- **Type**: hardening
- **Source**: product/operator-trust analysis 2026-03-24 — baseline compare ambiguity and scope communication review
- **Vehicle**: new standalone candidate
- **Problem**: Baseline Compare currently produces confusing results when the baseline snapshot contains generic Microsoft/Intune default objects or subjects with non-unique display names. Not-uniquely-matchable subjects are surfaced as "duplicates" in the UI, implying operator error even when the root cause is provider-side generic naming. Separate truth dimensions — identity confidence (could subjects be matched uniquely?), evidence fidelity (how deep was the compare?), and result trust (how reliable is the overall outcome?) — are collapsed into ambiguous operator signals such as `No Drift Detected` + `Limited confidence` + `Fidelity: Meta` without explaining whether the issue is baseline scope, generic names, limited compare capability, or actual tenant drift.
- **Why it matters**: Operators reading compare results cannot distinguish between "everything is fine" and "we couldn't compare reliably." False reassurance (`No Drift Detected` at limited confidence) and false blame ("rename your duplicates" when subjects are provider-managed defaults) erode trust in the product's core governance promise. MSP operators managing baselines for multiple tenants need clear signals about what they can rely on and what requires scope curation — not academic-sounding fidelity labels next to misleading all-clear verdicts.
- **Product decision**: Baseline Compare in V1 is designed for uniquely identifiable, intentionally curated governance policies — not for arbitrary tenant-wide default/enrollment/generic standard objects. When compare subjects cannot be reliably matched due to generic names or weak identity, TenantPilot treats this primarily as a scope/suitability problem of the current baseline content and a transparency/guidance topic in the product — not as an occasion for building a large identity classification engine.
- **Proposed direction**:
- **Compare wording correction**: replace pausal "rename your duplicates" messaging with neutral, scope-aware language explaining that some subjects cannot be matched uniquely by the current compare strategy, that this can happen with generic or provider-managed display names, and that the visible result is therefore only partially reliable
- **Scope guidance on compare surfaces**: make explicit that Baseline Compare is for curated governance-scope policies, not for every tenant content. Baseline/capture surfaces must frame Golden Master as a deliberate governance scope, not an unfiltered tenant full-extract
- **Actionable next-step guidance**: when ambiguity is detected, direct operators to review baseline profile scope, remove non-uniquely-identifiable subjects from governance scope, and re-run compare after scope cleanup — not to pauschal rename everything
- **Meta-fidelity and limited-confidence separation**: separate identity-matchability, evidence/compare-depth, and overall result trustworthiness in the communication so operators can tell which dimension is limited and why
- **Conservative framing for problematic V1 domains**: for known generically-named compare domains, allow conservative copy such as "not ideal for baseline compare," "limited compare confidence," "review scope before relying on result" — without introducing deep system-managed default detection
- **Evidence/snapshot surface consistency**: terms like `Missing input`, `Not collected yet`, `Limited confidence` must not read as runtime errors when the actual issue is scope suitability
- **Scope boundaries**:
- **In scope**: compare result warning copy, limited-confidence explanation, next-step guidance, baseline profile/capture scope framing, conservative guardrail copy for problematic domains, evidence/snapshot surface term consistency
- **Out of scope**: comprehensive Microsoft default policy detection, new global identity strategy engine, object-class-based system-managed vs user-managed classification, new deep fidelity matrix for all policy types, automatic exclusion or repair of problematic baseline items, compare engine architecture redesign
- **UX direction**:
- **Bad (current)**: "32 policies share the same display name" / "Please rename the duplicates" / `No Drift Detected` despite `Limited confidence`
- **Good (target)**: neutral, honest, operator-actionable — e.g. "Some policies in the current baseline scope cannot be matched uniquely by the current compare strategy. This often happens with generic or provider-managed display names. Review your baseline scope and keep only uniquely identifiable governance policies before relying on this result."
- **Acceptance criteria**:
- AC1: ambiguous-match UI no longer pauschal blames operators for duplicates without explaining scope/generic-name context
- AC2: limited-trust compare results are visually and linguistically distinguishable from fully reliable results; operators can tell the result is technically complete but content-wise only partially reliable
- AC3: primary V1 guidance directs operators to baseline-scope review/cleanup and re-compare — not to pauschal rename or assume tenant misconfiguration
- AC4: baseline/compare surfaces convey that Golden Master is a curated governance scope
- AC5: `No Drift Detected` at `Limited confidence` is understandable as not-fully-trustworthy, not as definitive all-clear
- **Tests / validation**:
- Warning text for ambiguous matches uses neutral scope/matchability wording
- Next-step guidance points to baseline scope review, not pauschal rename
- `Limited confidence` + `No Drift Detected` is not presented as unambiguous all-clear
- Baseline/compare surfaces include governance-scope hint
- Known compare gaps do not produce misleading "user named everything wrong" messaging
- Existing compare status/outcome logic remains intact
- No new provider-specific special classification logic required for consistent UI
- **Risks**:
- R1: pure copy changes alone might address the symptom too weakly → mitigation: include scope/guidance framing, not just single-sentence edits
- R2: too much guidance without technical guardrails might let operators keep building bad baselines → mitigation: conservative framing and later evolution via real usage data
- R3: team reads this spec as a starting point for large identity architecture → mitigation: non-goals are explicitly and strictly scoped
- **Roadmap fit**: Aligns directly with Release 1 — Golden Master Governance (R1.1 BaselineProfile, R1.3 baseline.compare, R1.4 Drift UI: Soll vs Ist). Improves V1 sellability without domain-model expansion: less confusing drift communication, clearer Golden Master story, no false operator blame, better trust basis for compare results.
- **Dependencies**: Baseline drift engine stable (Specs 116119), Baseline Snapshot Fidelity Semantics candidate (complementary — snapshot-level fidelity clarity), Operator Explanation Layer candidate (consumes explanation patterns), Governance Operator Outcome Compression candidate (complementary — governance artifact presentation)
- **Related specs / candidates**: Specs 116119 (baseline drift engine), Spec 101 (baseline governance), Baseline Capture Truthful Outcomes candidate, Baseline Snapshot Fidelity Semantics candidate, Operator Explanation Layer candidate, Governance Operator Outcome Compression candidate
- **Recommendation**: Treat before any large matching/identity extension. Small enough for V1, reduces real operator confusion, protects against scope creep, and sharpens the product message: TenantPilot compares curated governance baselines — not blindly every generic tenant default content.
- **Priority**: high
### Restore Lifecycle Semantic Clarity
- **Type**: hardening
- **Source**: semantic clarity & operator-language audit 2026-03-21
@ -379,6 +423,70 @@ ### Inventory, Provider & Operability Semantics
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation, provider connection vocabulary/cutover work, onboarding and verification spec family
- **Priority**: medium
### Tenant Operational Readiness & Status Truth Hierarchy
- **Type**: hardening
- **Source**: product/operator-trust analysis 2026-03-24 — tenant-facing status presentation and source-of-truth hierarchy review
- **Vehicle**: new standalone candidate
- **Problem**: Tenant-facing surfaces expose multiple parallel status domains — lifecycle, legacy app status, provider connection state, provider health, verification report availability, RBAC readiness, and recent run evidence — without a clear hierarchy. Some domains are valid but poorly explained; at least one (`Tenant.app_status`) is legacy/orphaned truth still presented as if authoritative. The combined presentation does not answer the operator's actual question: "Can I trust this tenant right now, and is any action required?" Instead, operators must mentally reconcile six semi-related status fragments with no clear precedence, creating three distinct risks: legacy truth leakage (dead fields displayed as current truth), state collision without hierarchy (valid domains answering different questions but appearing to compete), and support/trust burden (operators asking why a tenant is "active" yet also "unknown," or provider is "connected" but health is "unknown," even when operational evidence proves usability).
- **Why it matters**: TenantPilot is moving further into governance, evidence, reviews, drift, and portfolio visibility. As the product becomes more compliance- and operations-centric, source-of-truth quality on core tenant surfaces becomes more important, not less. If left unresolved: support load scales with tenant count, MSP operators learn to distrust or ignore status surfaces, future governance views inherit ambiguous foundations, and headline truth across baselines, evidence, findings, and reviews remains semantically inconsistent. For an enterprise governance platform, this is a product-truth and operator-confidence issue, not just a wording problem.
- **Core insight**: Not every status belongs at the same level. The product currently exposes multiple truths belonging to different semantic layers:
- **Layer 1 — Headline operator truth**: "Can I work with this tenant, and is action required?"
- **Layer 2 — Domain truth**: lifecycle, provider consent/access, verification, RBAC, recent operational evidence
- **Layer 3 — Diagnostic truth**: low-level or specialized states useful for investigation, not competing with headline summary
- **Layer 4 — Legacy/orphaned truth**: stale, weakly maintained, deprecated, or no longer authoritative fields
- **Proposed direction**:
- **Headline readiness model**: define a single tenant-facing readiness summary answering whether the tenant is usable and whether action is needed. Concise operator-facing states such as: Ready, Ready with follow-up, Limited, Action required, Not ready.
- **Source-of-truth hierarchy**: every tenant-facing status shown on primary surfaces classified as authoritative, derived, diagnostic, or legacy. Authoritative sources: lifecycle, canonical provider consent/access state, canonical verification state, RBAC readiness, recent operational evidence as supporting evidence.
- **Domain breakdown beneath headline**: each supporting domain exists in a clearly subordinate role — lifecycle, provider access/consent, verification state, RBAC readiness, recent operational evidence.
- **Action semantics clarity**: primary surfaces must distinguish between no action needed, recommended follow-up, required action, and informational only.
- **Verification semantics**: UI must distinguish between never verified, verification unavailable, verification stale, verification failed, and verified but follow-up recommended. These must not collapse into ambiguous "unknown" messaging.
- **Provider truth clarity**: provider access state must clearly differentiate access configured/consented, access verified, access usable but not freshly verified, access blocked or failed.
- **RBAC semantics clarity**: RBAC readiness must clearly state whether write actions are blocked, without implying that all tenant operations are unavailable when read-only operations still function.
- **Operational evidence handling**: recent successful operations may contribute supporting confidence, but must not silently overwrite or replace distinct provider verification truth.
- **Legacy truth removal/demotion**: fields that are legacy, orphaned, or too weak to serve as source of truth must not remain prominent on tenant overview surfaces. Explicit disposition for orphaned fields like `Tenant.app_status`.
- **Reusable semantics model**: the resulting truth hierarchy and readiness model must be reusable across tenant list/detail and future higher-level governance surfaces.
- **Functional requirements**:
- FR1 — Single tenant-facing readiness summary answering operability and action-needed
- FR2 — Every primary-surface status classified as authoritative, derived, diagnostic, or legacy
- FR3 — Legacy/orphaned fields not displayed as current operational truth on primary surfaces
- FR4 — No peer-level contradiction on primary surfaces
- FR5 — Verification semantics explicitly distinguishing not yet verified / unavailable / stale / failed / verified with follow-up
- FR6 — Provider access state clearly differentiating configured, verified, usable-but-not-fresh, blocked
- FR7 — RBAC readiness clarifying write-block vs full-block
- FR8 — Operational evidence supportive but not substitutive for verification truth
- FR9 — Actionability clarity on primary surfaces
- FR10 — Reusable semantics for future governance surfaces
- **UX/product rules**:
- Same question, one answer: if several states contribute to the same operator decision, present one synthesized answer first
- Summary before diagnostics: operator summary belongs first, domain detail underneath or behind expansion
- "Unknown" is not enough: must not substitute for not checked, no report stored, stale result, legacy field, or unavailable artifact
- Evidence is supportive, not substitutive: successful operations reinforce confidence but do not replace explicit verification
- Lifecycle is not health: active does not mean provider access is verified or write operations are ready
- Health is not onboarding history: historical onboarding verification is not automatically current operational truth
- **Likely surfaces affected**:
- Primary: tenant detail/overview page, tenant list presentation, tenant widgets/cards related to verification and recent operations, provider-related status presentation within tenant views, helper text/badge semantics on primary tenant surfaces
- Secondary follow-up: provider connection detail pages, onboarding completion/follow-up states, future portfolio rollup views
- **Scope boundaries**:
- **In scope**: truth hierarchy definition, headline readiness model, tenant detail/overview presentation rules, provider state presentation rules on tenant surfaces, verification semantics on tenant surfaces, RBAC relationship to readiness, role of recent operational evidence, legacy truth cleanup on primary tenant surfaces
- **Out of scope**: redesigning OperationRun result semantics in general, revisiting every badge/helper in the product, evidence/reporting semantics outside tenant readiness, changing onboarding lifecycle requirements unless directly necessary for truth consistency, provider architecture overhaul, full data-model cleanup beyond what is needed to remove legacy truth from primary surfaces, full badge taxonomy standardization everywhere, color palette / visual design overhaul, findings severity or workflow semantics, broad IA/navigation redesign, portfolio-level rollup semantics beyond stating compatibility goals
- **Acceptance criteria**:
- AC1: Primary tenant surfaces present a single operator-facing readiness truth rather than several equal-weight raw statuses
- AC2: Lifecycle, provider access, verification, RBAC, and operational evidence shown with explicit semantic roles and no ambiguous precedence
- AC3: Legacy/orphaned status fields no longer presented as live operational truth on primary surfaces
- AC4: System clearly differentiates not yet verified / verification unavailable / stale / failed
- AC5: Operator can tell within seconds whether tenant is usable / usable with follow-up / limited / blocked / in need of action
- AC6: Recent successful operations reinforce confidence where appropriate but do not silently overwrite explicit verification truth
- AC7: Primary tenant status communication suitable for MSP/enterprise use without requiring tribal knowledge to interpret contradictions
- **Boundary with Tenant App Status False-Truth Removal**: That candidate is a quick, bounded removal of the single most obvious legacy truth field (`Tenant.app_status`). This candidate defines the broader truth hierarchy and presentation model that decides how all tenant status domains interrelate. The false-truth removal is a subset action that can proceed independently as a quick win; this candidate provides the architectural direction that prevents future truth leakage.
- **Boundary with Provider Connection Status Vocabulary Cutover**: Provider vocabulary cutover owns the `ProviderConnection` model-level status field cleanup (canonical enum sources replacing legacy varchar fields). This candidate owns how provider truth appears on tenant-level surfaces in relation to other readiness domains. The vocabulary cutover provides cleaner provider truth; this candidate defines where and how that truth is presented alongside lifecycle, verification, RBAC, and evidence.
- **Boundary with Inventory, Provider & Operability Semantics**: That candidate normalizes how inventory capture mode, provider health/prerequisites, and verification results are presented on their own surfaces. This candidate defines the tenant-level integration layer that synthesizes those domain truths into a single headline readiness answer. Complementary: domain-level semantic cleanup feeds into the readiness model.
- **Boundary with Operator Explanation Layer**: The explanation layer defines cross-cutting interpretation patterns for degraded/partial/suppressed results. This candidate defines the tenant-specific readiness truth hierarchy. The explanation layer may inform how degraded readiness states are explained, but the readiness model itself is tenant-domain-specific.
- **Boundary with Governance Operator Outcome Compression**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains.
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Tenant App Status False-Truth Removal (quick win that removes the most obvious legacy truth — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Tenant App Status False-Truth Removal, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Tenant App Status False-Truth Removal removes the most obvious legacy truth as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input.
- **Priority**: high
### Exception / Risk-Acceptance Workflow for Findings
- **Type**: feature
- **Source**: HANDOVER gap analysis, Spec 111 follow-up

View File

@ -12,8 +12,8 @@
// Duplicate-name warning banner
'duplicate_warning_title' => 'Warning',
'duplicate_warning_body_plural' => ':count policies in this tenant share the same display name. :app cannot match them to the baseline. Please rename the duplicates in the Microsoft Intune portal.',
'duplicate_warning_body_singular' => ':count policy in this tenant shares the same display name. :app cannot match it to the baseline. Please rename the duplicate in the Microsoft Intune portal.',
'duplicate_warning_body_plural' => ':count policies in this tenant share generic display names, resulting in :ambiguous_count ambiguous subjects. :app cannot match them safely to the baseline.',
'duplicate_warning_body_singular' => ':count policy in this tenant shares a generic display name, resulting in :ambiguous_count ambiguous subject. :app cannot match it safely to the baseline.',
// Stats card labels
'stat_assigned_baseline' => 'Assigned Baseline',
@ -29,6 +29,36 @@
'badge_fidelity' => 'Fidelity: :level',
'badge_evidence_gaps' => 'Evidence gaps: :count',
'evidence_gaps_tooltip' => 'Top gaps: :summary',
'evidence_gap_details_heading' => 'Evidence gap details',
'evidence_gap_details_description' => 'Search recorded gap subjects by reason, policy type, subject class, outcome, next action, or subject key before falling back to raw diagnostics.',
'evidence_gap_search_label' => 'Search gap details',
'evidence_gap_search_placeholder' => 'Search by reason, type, class, outcome, action, or subject key',
'evidence_gap_search_help' => 'Filter matches across reason, policy type, subject class, outcome, next action, and subject key.',
'evidence_gap_bucket_help' => 'Reason summaries stay separate from the detailed row table below.',
'evidence_gap_reason' => 'Reason',
'evidence_gap_reason_affected' => ':count affected',
'evidence_gap_reason_recorded' => ':count recorded',
'evidence_gap_reason_missing_detail' => ':count missing detail',
'evidence_gap_structural' => 'Structural: :count',
'evidence_gap_operational' => 'Operational: :count',
'evidence_gap_transient' => 'Transient: :count',
'evidence_gap_bucket_structural' => ':count structural',
'evidence_gap_bucket_operational' => ':count operational',
'evidence_gap_bucket_transient' => ':count transient',
'evidence_gap_missing_details_title' => 'Detailed rows were not recorded for this run',
'evidence_gap_missing_details_body' => 'Evidence gaps were counted for this compare run, but subject-level detail was not stored. Review the raw diagnostics below or rerun the comparison for fresh detail.',
'evidence_gap_missing_reason_body' => ':count affected subjects were counted for this reason, but detailed rows were not recorded for this run.',
'evidence_gap_legacy_title' => 'Legacy development gap payload detected',
'evidence_gap_legacy_body' => 'This run still uses the retired broad reason shape. Regenerate the run or purge stale local development payloads before treating the gap details as operator-safe.',
'evidence_gap_diagnostics_heading' => 'Baseline compare evidence',
'evidence_gap_diagnostics_description' => 'Raw diagnostics remain available for support and deep troubleshooting after the operator summary and searchable detail.',
'evidence_gap_policy_type' => 'Policy type',
'evidence_gap_subject_class' => 'Subject class',
'evidence_gap_outcome' => 'Outcome',
'evidence_gap_next_action' => 'Next action',
'evidence_gap_subject_key' => 'Subject key',
'evidence_gap_table_empty_heading' => 'No recorded gap rows match this view',
'evidence_gap_table_empty_description' => 'Adjust the current search or filters to review other affected subjects.',
// Comparing state
'comparing_indicator' => 'Comparing…',

View File

@ -0,0 +1,118 @@
(() => {
if (typeof window === 'undefined') {
return;
}
if (window.__tenantpilotUnhandledRejectionLoggerApplied) {
return;
}
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
const recentKeys = new Map();
const cleanupRecentKeys = (nowMs) => {
for (const [key, timestampMs] of recentKeys.entries()) {
if (nowMs - timestampMs > 5_000) {
recentKeys.delete(key);
}
}
};
const normalizeReason = (value, depth = 0) => {
if (depth > 3) {
return '[max-depth-reached]';
}
if (value instanceof Error) {
return {
type: 'Error',
name: value.name,
message: value.message,
stack: value.stack,
};
}
if (value === null || value === undefined) {
return value;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (Array.isArray(value)) {
return value.slice(0, 10).map((item) => normalizeReason(item, depth + 1));
}
if (typeof value === 'object') {
const result = {};
const allowedKeys = [
'message',
'stack',
'name',
'type',
'status',
'body',
'json',
'errors',
'reason',
'code',
];
for (const key of allowedKeys) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
result[key] = normalizeReason(value[key], depth + 1);
}
}
if (Object.keys(result).length > 0) {
return result;
}
const stringTag = Object.prototype.toString.call(value);
return {
type: stringTag,
value: String(value),
};
}
return String(value);
};
const toStableJson = (payload) => {
try {
return JSON.stringify(payload);
} catch {
return JSON.stringify({
source: payload.source,
href: payload.href,
timestamp: payload.timestamp,
reason: '[unserializable]',
});
}
};
window.addEventListener('unhandledrejection', (event) => {
const payload = {
source: 'window.unhandledrejection',
href: window.location.href,
timestamp: new Date().toISOString(),
reason: normalizeReason(event.reason),
};
const payloadJson = toStableJson(payload);
const nowMs = Date.now();
cleanupRecentKeys(nowMs);
if (recentKeys.has(payloadJson)) {
return;
}
recentKeys.set(payloadJson, nowMs);
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
});
})();

View File

@ -0,0 +1,141 @@
@php
$summary = is_array($summary ?? null) ? $summary : [];
$buckets = is_array($buckets ?? null) ? $buckets : [];
$detailState = is_string($summary['detail_state'] ?? null) ? $summary['detail_state'] : 'no_gaps';
$structuralCount = is_numeric($summary['structural_count'] ?? null) ? (int) $summary['structural_count'] : 0;
$operationalCount = is_numeric($summary['operational_count'] ?? null) ? (int) $summary['operational_count'] : 0;
$transientCount = is_numeric($summary['transient_count'] ?? null) ? (int) $summary['transient_count'] : 0;
$tableContext = is_string($searchId ?? null) && $searchId !== '' ? $searchId : 'evidence-gap-search';
@endphp
@if ($detailState === 'legacy_broad_reason')
<div class="rounded-xl border border-danger-300 bg-danger-50/80 p-4 dark:border-danger-800 dark:bg-danger-950/30">
<div class="space-y-1">
<div class="text-sm font-semibold text-danger-950 dark:text-danger-100">
{{ __('baseline-compare.evidence_gap_legacy_title') }}
</div>
<p class="text-sm text-danger-900 dark:text-danger-200">
{{ __('baseline-compare.evidence_gap_legacy_body') }}
</p>
</div>
</div>
@elseif ($detailState === 'details_not_recorded' && $buckets === [])
<div class="rounded-xl border border-warning-300 bg-warning-50/80 p-4 dark:border-warning-800 dark:bg-warning-950/30">
<div class="space-y-1">
<div class="text-sm font-semibold text-warning-950 dark:text-warning-100">
{{ __('baseline-compare.evidence_gap_missing_details_title') }}
</div>
<p class="text-sm text-warning-900 dark:text-warning-200">
{{ __('baseline-compare.evidence_gap_missing_details_body') }}
</p>
</div>
</div>
@elseif ($buckets !== [])
<div class="space-y-4">
@if ($detailState === 'structured_details_recorded' && ($structuralCount > 0 || $operationalCount > 0 || $transientCount > 0))
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center rounded-full bg-danger-100 px-2.5 py-1 text-xs font-medium text-danger-900 dark:bg-danger-900/30 dark:text-danger-100">
{{ __('baseline-compare.evidence_gap_structural', ['count' => $structuralCount]) }}
</span>
<span class="inline-flex items-center rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-900 dark:bg-primary-900/30 dark:text-primary-100">
{{ __('baseline-compare.evidence_gap_operational', ['count' => $operationalCount]) }}
</span>
<span class="inline-flex items-center rounded-full bg-warning-100 px-2.5 py-1 text-xs font-medium text-warning-900 dark:bg-warning-900/30 dark:text-warning-100">
{{ __('baseline-compare.evidence_gap_transient', ['count' => $transientCount]) }}
</span>
</div>
@endif
@if ($detailState === 'details_not_recorded')
<div class="rounded-xl border border-warning-300 bg-warning-50/80 p-4 dark:border-warning-800 dark:bg-warning-950/30">
<div class="space-y-1">
<div class="text-sm font-semibold text-warning-950 dark:text-warning-100">
{{ __('baseline-compare.evidence_gap_missing_details_title') }}
</div>
<p class="text-sm text-warning-900 dark:text-warning-200">
{{ __('baseline-compare.evidence_gap_missing_details_body') }}
</p>
</div>
</div>
@endif
<div class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
@foreach ($buckets as $bucket)
@php
$reasonLabel = is_string($bucket['reason_label'] ?? null) ? $bucket['reason_label'] : 'Evidence gap';
$count = is_numeric($bucket['count'] ?? null) ? (int) $bucket['count'] : 0;
$recordedCount = is_numeric($bucket['recorded_count'] ?? null) ? (int) $bucket['recorded_count'] : 0;
$missingDetailCount = is_numeric($bucket['missing_detail_count'] ?? null) ? (int) $bucket['missing_detail_count'] : 0;
@endphp
<section class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="space-y-3">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $reasonLabel }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('baseline-compare.evidence_gap_bucket_help') }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center rounded-full bg-warning-100 px-2.5 py-1 text-xs font-medium text-warning-900 dark:bg-warning-900/40 dark:text-warning-100">
{{ __('baseline-compare.evidence_gap_reason_affected', ['count' => $count]) }}
</span>
<span class="inline-flex items-center rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-900 dark:bg-primary-900/40 dark:text-primary-100">
{{ __('baseline-compare.evidence_gap_reason_recorded', ['count' => $recordedCount]) }}
</span>
@if ($missingDetailCount > 0)
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ __('baseline-compare.evidence_gap_reason_missing_detail', ['count' => $missingDetailCount]) }}
</span>
@endif
@if ((int) ($bucket['structural_count'] ?? 0) > 0)
<span class="inline-flex items-center rounded-full bg-danger-100 px-2.5 py-1 text-xs font-medium text-danger-900 dark:bg-danger-900/30 dark:text-danger-100">
{{ __('baseline-compare.evidence_gap_bucket_structural', ['count' => (int) $bucket['structural_count']]) }}
</span>
@endif
@if ((int) ($bucket['operational_count'] ?? 0) > 0)
<span class="inline-flex items-center rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-900 dark:bg-primary-900/30 dark:text-primary-100">
{{ __('baseline-compare.evidence_gap_bucket_operational', ['count' => (int) $bucket['operational_count']]) }}
</span>
@endif
@if ((int) ($bucket['transient_count'] ?? 0) > 0)
<span class="inline-flex items-center rounded-full bg-warning-100 px-2.5 py-1 text-xs font-medium text-warning-900 dark:bg-warning-900/30 dark:text-warning-100">
{{ __('baseline-compare.evidence_gap_bucket_transient', ['count' => (int) $bucket['transient_count']]) }}
</span>
@endif
</div>
@if ($missingDetailCount > 0)
<div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-3 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-200">
{{ __('baseline-compare.evidence_gap_missing_reason_body', ['count' => $missingDetailCount]) }}
</div>
@endif
</div>
</section>
@endforeach
</div>
<div class="space-y-2">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-gray-950 dark:text-white">
{{ __('baseline-compare.evidence_gap_search_label') }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('baseline-compare.evidence_gap_search_help') }}
</p>
</div>
@livewire(
\App\Livewire\BaselineCompareEvidenceGapTable::class,
[
'buckets' => $buckets,
'context' => $tableContext,
],
key('baseline-compare-evidence-gap-table-'.$tableContext)
)
</div>
</div>
@endif

View File

@ -6,6 +6,7 @@
@php
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
@ -27,6 +28,7 @@
<div class="text-sm text-warning-800 dark:text-warning-300">
{{ __($duplicateNamePoliciesCountValue === 1 ? 'baseline-compare.duplicate_warning_body_singular' : 'baseline-compare.duplicate_warning_body_plural', [
'count' => $duplicateNamePoliciesCountValue,
'ambiguous_count' => $duplicateNameSubjectsCountValue,
'app' => config('app.name', 'TenantPilot'),
]) }}
</div>
@ -353,6 +355,20 @@ class="w-fit"
</x-filament::section>
@endif
@if ($hasEvidenceGapDetailSection)
<x-filament::section :heading="__('baseline-compare.evidence_gap_details_heading')">
<x-slot name="description">
{{ __('baseline-compare.evidence_gap_details_description') }}
</x-slot>
@include('filament.infolists.entries.evidence-gap-subjects', [
'summary' => $evidenceGapSummary,
'buckets' => $evidenceGapBuckets ?? [],
'searchId' => 'tenant-baseline-compare-gap-search',
])
</x-filament::section>
@endif
{{-- Severity breakdown + actions --}}
@if ($state === 'ready' && ($findingsCount ?? 0) > 0)
<x-filament::section>
@ -476,4 +492,14 @@ class="w-fit"
</div>
</x-filament::section>
@endif
@if ($hasEvidenceGapDiagnostics)
<x-filament::section :heading="__('baseline-compare.evidence_gap_diagnostics_heading')">
<x-slot name="description">
{{ __('baseline-compare.evidence_gap_diagnostics_description') }}
</x-slot>
@include('filament.partials.json-viewer', ['value' => $baselineCompareDiagnostics])
</x-filament::section>
@endif
</x-filament::page>

View File

@ -1,3 +1,4 @@
<script src="{{ asset('js/tenantpilot/livewire-intercept-shim.js') }}"></script>
<script src="{{ asset('js/tenantpilot/filament-sidebar-store-fallback.js') }}"></script>
<script src="{{ asset('js/tenantpilot/ops-ux-progress-widget-poller.js') }}"></script>
<script src="{{ asset('js/tenantpilot/unhandled-rejection-logger.js') }}"></script>

View File

@ -0,0 +1,3 @@
<div class="space-y-2">
{{ $this->table }}
</div>

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Enterprise Evidence Gap Details for Baseline Compare
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-24
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass completed on 2026-03-24.
- Spec intentionally covers both canonical Monitoring → Operation Run Detail and tenant-scoped Baseline Compare review because operators move between both surfaces during the same investigation flow.
- No clarification blockers remain; spec is ready for /speckit.plan.

View File

@ -0,0 +1,108 @@
openapi: 3.1.0
info:
title: Baseline Compare Evidence Gap Details Contract
version: 0.1.0
summary: Internal reference schema for the shared operator-safe evidence-gap read model.
description: |
This artifact defines the stable read shape for baseline compare evidence-gap detail.
It is designed for canonical Monitoring run detail and tenant baseline compare review surfaces.
In this feature slice it is a reference schema for the shared page read model, not a commitment to add new HTTP endpoints.
servers:
- url: https://tenantpilot.local
paths: {}
components:
schemas:
OperationRunReference:
type: object
required:
- id
- type
- status
- outcome
properties:
id:
type: integer
minimum: 1
type:
type: string
enum:
- baseline_compare
status:
type: string
outcome:
type: string
tenant_id:
type: integer
nullable: true
workspace_id:
type: integer
nullable: true
EvidenceGapBucket:
type: object
required:
- reason_code
- reason_label
- count
- rows
properties:
reason_code:
type: string
reason_label:
type: string
count:
type: integer
minimum: 0
rows:
type: array
items:
$ref: '#/components/schemas/EvidenceGapRow'
EvidenceGapRow:
type: object
required:
- reason_code
- policy_type
- subject_key
properties:
reason_code:
type: string
policy_type:
type: string
subject_key:
type: string
EvidenceGapSummary:
type: object
required:
- count
- by_reason
- detail_state
properties:
count:
type: integer
minimum: 0
by_reason:
type: object
additionalProperties:
type: integer
minimum: 0
detail_state:
type: string
enum:
- no_gaps
- details_recorded
- details_not_recorded
EvidenceGapDetailResponse:
type: object
required:
- summary
- buckets
properties:
summary:
$ref: '#/components/schemas/EvidenceGapSummary'
buckets:
type: array
items:
$ref: '#/components/schemas/EvidenceGapBucket'
filters:
type: object
additionalProperties:
type: string

View File

@ -0,0 +1,139 @@
# Data Model: Enterprise Evidence Gap Details for Baseline Compare
## Overview
This feature does not introduce new database tables. It extends the tenant-owned `OperationRun` JSONB context for `baseline_compare` runs and defines a derived operator-facing row model for evidence-gap detail rendering.
## Entity: OperationRun
- Ownership: Tenant-owned operational artifact with `workspace_id` and `tenant_id`
- Storage: Existing `operation_runs` table
- Relevant relationships:
- Belongs to `Tenant`
- Belongs to `Workspace`
- Belongs to initiating `User`
- Relevant invariant:
- `status` and `outcome` remain service-owned through `OperationRunService`
- `context` may be enriched by the compare job without changing lifecycle semantics
## Subdocument: `baseline_compare`
Stored under `OperationRun.context['baseline_compare']`.
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `inventory_sync_run_id` | integer | no | Source inventory sync run used for coverage and freshness context |
| `subjects_total` | integer | no | Total compare subjects considered for the run |
| `evidence_capture` | object | no | Capture stats such as requested, succeeded, skipped, failed, throttled |
| `coverage` | object | no | Coverage proof and covered/uncovered policy types |
| `fidelity` | string | no | Compare fidelity, typically `meta` or `content` |
| `reason_code` | string | no | Top-level compare reason code used by the explanation layer |
| `resume_token` | string or null | no | Resume state for incomplete content capture |
| `evidence_gaps` | object | no | Aggregate and subject-level evidence-gap contract |
## Subdocument: `baseline_compare.evidence_gaps`
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `count` | integer | no | Total evidence-gap count across all reasons |
| `by_reason` | object<string,int> | no | Aggregate counts keyed by evidence-gap reason code |
| `subjects` | object<string,list<string>> | no | Bounded reason-grouped list of concrete subject keys |
### Validation Rules
- `count` must be a non-negative integer.
- Each `by_reason` value must be a non-negative integer.
- Each `subjects` key must be a non-empty reason code string.
- Each `subjects[reason]` list item must be a non-empty string in `policy_type|subject_key` format.
- Subject lists are deduplicated per reason.
- Subject lists are capped at the compare job limit, currently 50 items per reason.
## Derived Entity: EvidenceGapDetailRow
This is not stored separately. It is derived from `baseline_compare.evidence_gaps.subjects` for rendering and filtering.
### Fields
| Field | Type | Source | Description |
|---|---|---|---|
| `reason_code` | string | map key | Stable reason identifier such as `ambiguous_match` or `policy_not_found` |
| `reason_label` | string | derived | Operator-facing label from `reason_code` |
| `policy_type` | string | parsed from subject string | Policy family segment before the first pipe |
| `subject_key` | string | parsed from subject string | Subject identity segment after the first pipe |
| `search_text` | string | derived | Lowercased concatenation of reason, policy type, and subject key for local filtering |
### Validation Rules
- `policy_type` must be non-empty.
- `subject_key` may be human-readable text, GUID-like values, or workspace-safe identifiers, but must be non-empty once persisted.
- `search_text` must be deterministic and derived only from persisted row values.
## Derived Entity: EvidenceGapReasonBucket
Groups detail rows for rendering.
### Fields
| Field | Type | Description |
|---|---|---|
| `reason_code` | string | Stable bucket key |
| `reason_label` | string | Operator-facing label |
| `count` | integer | Number of visible persisted subjects in the bucket |
| `rows` | list<EvidenceGapDetailRow> | Rows shown for the reason |
## State Model
### Run detail evidence-gap states
1. `NoGaps`
- `evidence_gaps.count` is absent or `0`
- No evidence-gap detail section is required
2. `GapsWithRecordedSubjects`
- `evidence_gaps.count > 0`
- `evidence_gaps.subjects` exists with at least one non-empty reason bucket
- Render searchable grouped rows
3. `GapsWithoutRecordedSubjects`
- `evidence_gaps.count > 0`
- `evidence_gaps.subjects` is absent or empty
- Render explicit fallback copy indicating detail was not recorded for that run
## Example Payload
```json
{
"baseline_compare": {
"subjects_total": 50,
"fidelity": "meta",
"reason_code": "evidence_capture_incomplete",
"evidence_capture": {
"requested": 50,
"succeeded": 47,
"skipped": 0,
"failed": 3,
"throttled": 0
},
"evidence_gaps": {
"count": 5,
"by_reason": {
"ambiguous_match": 3,
"policy_not_found": 2
},
"subjects": {
"ambiguous_match": [
"deviceConfiguration|WiFi-Corp-Profile",
"deviceConfiguration|VPN-Always-On"
],
"policy_not_found": [
"deviceConfiguration|Deleted-Policy-ABC"
]
}
}
}
}
```

View File

@ -0,0 +1,139 @@
# Implementation Plan: Enterprise Evidence Gap Details for Baseline Compare
**Branch**: `162-baseline-gap-details` | **Date**: 2026-03-24 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/spec.md`
## Summary
Expose baseline compare evidence gaps as an operator-first, searchable, tenant-safe detail experience backed by immutable `OperationRun.context` data. The implementation keeps compare execution and `OperationRun` lifecycle unchanged, persists bounded per-reason subject details during compare/capture work, renders them in the canonical run-detail and tenant compare surfaces ahead of raw JSON diagnostics, and validates the behavior with Pest feature coverage for persistence, rendering, and legacy-run fallbacks.
## Technical Context
**Language/Version**: PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services
**Storage**: PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required
**Testing**: Pest feature tests run through Sail, plus existing Filament page rendering tests
**Target Platform**: Laravel web application in Sail locally and Linux container deployment in staging/production
**Project Type**: Web application
**Performance Goals**: DB-only render path; no render-time Graph calls; searchable gap detail remains usable for bounded per-reason subject lists; operator can isolate a relevant subject in under 30 seconds
**Constraints**: Preserve `OperationRunService` ownership of status/outcome, keep evidence-gap JSON bounded by existing caps, retain tenant-safe canonical monitoring behavior, add no destructive actions, and avoid new global/published Filament assets
**Scale/Scope**: Tenant-scoped baseline compare runs for enterprise tenants, subject-detail persistence within the existing compare job/capture pipeline, read-only UX changes across the canonical run detail and tenant baseline compare landing surfaces
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
- Inventory-first: Pass. This feature reads existing inventory and baseline snapshot outputs and only improves how compare gaps are persisted and presented.
- Read/write separation: Pass. Compare initiation remains unchanged; the new surface is read-only and introduces no new mutation path.
- Graph contract path: Pass. No new Graph calls or contract bypasses are introduced; all render behavior remains DB-only.
- Deterministic capabilities: Pass. No new capability derivation is introduced.
- RBAC-UX plane separation and tenant safety: Pass. Canonical `/admin/operations/{run}` remains tenant-safe through existing workspace + tenant entitlement checks, and tenant landing remains tenant-scoped.
- Workspace isolation: Pass. No workspace-context semantics are broadened.
- Destructive confirmation: Pass. No destructive action is added or changed.
- Global search safety: Pass. No global-search behavior is introduced or modified.
- Tenant isolation: Pass. Evidence-gap detail is persisted on the tenant-owned `OperationRun` and revealed only through already authorized run/tenant surfaces.
- Run observability: Pass. Existing baseline compare `OperationRun` behavior stays intact; this feature enriches context only.
- Ops-UX 3-surface feedback: Pass. No additional toasts, progress surfaces, or terminal notifications are introduced.
- Ops-UX lifecycle ownership: Pass. `status`/`outcome` transitions remain service-owned; only `context` payload content is extended.
- Ops-UX summary counts: Pass. No new `summary_counts` keys are added.
- Ops-UX guards and system-run rules: Pass. Existing monitoring and notification invariants remain untouched.
- Automation/backoff: Pass. Existing capture-phase retry/backoff behavior remains the only throttling mechanism in scope.
- Data minimization: Pass. Persisted detail is bounded, operator-safe subject identity rather than raw payload dumps or secrets.
- Badge semantics: Pass. Existing run outcome/trust badges remain centralized; evidence-gap detail is plain text/searchable rows.
- UI naming: Pass. The operator copy uses domain terms such as `Evidence gap details`, `Policy type`, and `Subject key`.
- Operator surfaces: Pass. Outcome and trust stay ahead of diagnostics, and the detail section is secondary to result meaning.
- Filament Action Surface Contract: Pass. The change is read-only and does not alter header/row/bulk/destructive semantics.
- UX-001 Layout and IA: Pass. The detail stays sectioned and readable within the existing enterprise detail layout.
- Filament v5 / Livewire v4 compliance: Pass. The feature remains within the existing Filament v5 + Livewire v4 stack.
- Provider registration location: Pass. No panel provider changes are required; Laravel 11+ registration remains in `bootstrap/providers.php`.
- Global search hard rule: Pass. No resource searchability changes are required.
- Asset strategy: Pass. The design relies on existing Filament/Blade/Alpine capabilities and does not require new published or on-demand assets.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/research.md`.
Key decisions:
- Persist subject-level evidence gaps as an immutable read model under `baseline_compare.evidence_gaps.subjects` inside `OperationRun.context`.
- Merge subject detail from all compare evidence-gap sources: ambiguous inventory matches, capture-phase failures, and drift-time missing evidence.
- Keep filtering local to the rendered detail section because the stored dataset is intentionally bounded and render-time network/database chatter would add unnecessary complexity.
- Preserve operator-first reading order: result meaning and trust first, evidence-gap detail second, raw JSON last.
- Treat legacy runs with counts but no recorded subjects as a first-class fallback state rather than as empty/healthy runs.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/162-baseline-gap-details/`:
- `data-model.md`: JSONB-backed read-model contract and derived UI row shape.
- `contracts/baseline-gap-details.openapi.yaml`: internal reference schema for the shared evidence-gap read model used by both operator surfaces; it is not a commitment to add new HTTP endpoints in this slice.
- `quickstart.md`: verification path covering focused tests, tenant-safety checks, render-safety checks, local run review, and legacy-run fallback checks.
Design decisions:
- No schema migration is required; the durable contract is an extension of the existing JSONB payload.
- The canonical record remains `OperationRun`; no separate evidence-gap table or Eloquent model is introduced.
- Filtering is modeled as reason/policy-type/subject-key matching over persisted row data in the page surface, not via new server-side filtering endpoints in this slice.
- Tenant landing and canonical run detail must consume semantically identical evidence-gap groupings and preserve operator-first summary ordering ahead of diagnostics.
- Regression coverage must explicitly prove tenant-safe access semantics and the no-external-calls-on-render rule on both affected surfaces.
## Project Structure
### Documentation (this feature)
```text
specs/162-baseline-gap-details/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── baseline-gap-details.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ └── Resources/
│ └── OperationRunResource.php
├── Jobs/
│ └── CompareBaselineToTenantJob.php
├── Models/
│ └── OperationRun.php
└── Services/
└── Baselines/
└── BaselineContentCapturePhase.php
resources/
└── views/
└── filament/
└── infolists/
└── entries/
└── evidence-gap-subjects.blade.php
tests/
└── Feature/
├── Baselines/
│ ├── BaselineCompareAmbiguousMatchGapTest.php
│ └── BaselineCompareResumeTokenTest.php
└── Filament/
└── OperationRunEnterpriseDetailPageTest.php
```
**Structure Decision**: Single Laravel web application. The feature stays inside the existing baseline compare pipeline, `OperationRun` read model, Filament resource rendering, Blade view composition, and Pest feature-test layout.
## Implementation Strategy
1. Normalize and persist subject-level gap detail in the compare/capture pipeline while preserving bounded payload size and legacy compare semantics.
2. Render the evidence-gap detail section from `OperationRun.context` on canonical run detail and keep the tenant landing semantics aligned.
3. Support operator filtering across reason, policy type, and subject key without introducing a new server-side search endpoint in the first implementation.
4. Keep operator-first summary content ahead of diagnostics on both the canonical run detail and tenant landing surfaces.
5. Add regression coverage for new-run persistence, tenant-safe access semantics, render-safety, UI visibility, and legacy/no-detail behavior.
## Complexity Tracking
No constitution violations or justified complexity exceptions were identified.

View File

@ -0,0 +1,61 @@
# Quickstart: Enterprise Evidence Gap Details for Baseline Compare
## Prerequisites
1. Start the local stack.
```bash
vendor/bin/sail up -d
```
2. Ensure the app is clean enough to run focused tests.
## Focused Verification
Run the minimum regression pack for the feature:
```bash
vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
```
Format touched files before shipping implementation updates:
```bash
vendor/bin/sail bin pint --dirty --format agent
```
## Manual Verification Flow
1. Trigger or locate a completed `baseline_compare` run with evidence gaps.
2. Open the canonical run detail page at `/admin/operations/{run}`.
3. Confirm the page shows outcome/trust guidance before diagnostics.
4. Confirm the `Evidence gap details` section is visible when subject-level details exist.
5. Use `Search gap details` to filter by:
- reason text such as `ambiguous`
- policy type such as `deviceConfiguration`
- subject key fragment such as part of a display name or GUID
6. Confirm raw JSON evidence remains available in the separate `Baseline compare evidence` section.
## Legacy-Run Verification
1. Open an older baseline compare run that contains `evidence_gaps.count` but no `evidence_gaps.subjects`.
2. Confirm the UI distinguishes missing recorded detail from the absence of gaps.
3. Confirm the page still renders successfully and does not imply a healthy compare result.
## Tenant-Safety Verification
1. Verify an entitled user can inspect the same run through canonical monitoring.
2. Verify a non-member cannot discover tenant-owned detail through canonical or tenant-scoped surfaces.
3. Verify member-but-underprivileged behavior remains enforced by existing authorization rules.
## Render-Safety Verification
1. Bind the fail-hard graph client in the affected UI tests.
2. Verify the canonical run detail renders evidence-gap detail without invoking `GraphClientInterface`.
3. Verify the tenant landing evidence-gap state renders without invoking `GraphClientInterface`.
## Deployment Notes
- No new database migration is required.
- No new Filament assets are registered, so this feature does not add a new `filament:assets` deployment requirement.
- Filament remains on Livewire v4-compatible patterns and requires no panel provider changes.

View File

@ -0,0 +1,57 @@
# Research: Enterprise Evidence Gap Details for Baseline Compare
## Decision 1: Persist evidence-gap subjects inside `OperationRun.context`
- Decision: Store concrete evidence-gap subject detail in `baseline_compare.evidence_gaps.subjects` within the existing JSONB `OperationRun.context` payload.
- Rationale: The canonical operator review surface is already backed by `OperationRun`, Monitoring pages must remain DB-only at render time, and compare runs are immutable operational artifacts. Extending the existing context preserves observability without introducing a new persistence model.
- Alternatives considered:
- Create a dedicated relational evidence-gap table: rejected because the feature needs a bounded, immutable run snapshot rather than an independently mutable dataset.
- Recompute detail on demand from inventory and baseline state: rejected because it would violate DB-only render expectations and risk drift between recorded compare outcome and later inventory state.
## Decision 2: Merge all evidence-gap subject sources before persistence
- Decision: Consolidate gap subjects from ambiguous current-inventory matches, capture-phase failures, and drift-time missing evidence into one reason-grouped structure.
- Rationale: Operators need one coherent explanation of why confidence is reduced. Splitting detail across multiple internal sources would force the UI to know too much about compare internals and would create inconsistent trust messaging.
- Alternatives considered:
- Persist only ambiguous matches: rejected because it would leave `policy_not_found`, `missing_current`, and similar reasons as counts-only.
- Persist per-phase fragments separately: rejected because the UI contract is reason-oriented, not phase-oriented.
## Decision 3: Keep filtering local to the rendered detail surface
- Decision: Use local filtering across reason, policy type, and subject key in the evidence-gap detail surface.
- Rationale: The payload is intentionally bounded by the compare job, the operator workflow is investigative rather than analytical, and local filtering avoids new server requests or additional read endpoints in the initial user experience.
- Alternatives considered:
- Server-side filtering via new query endpoints: rejected for the initial slice because it adds API surface without solving a current scale bottleneck.
- No filtering at all: rejected because enterprise runs can accumulate enough subjects to make manual scanning too slow.
## Decision 4: Preserve operator-first information hierarchy
- Decision: Keep result meaning, trust, and next-step guidance ahead of evidence-gap detail, and keep raw JSON diagnostics last.
- Rationale: The constitution requires operator-first `/admin` surfaces. Evidence-gap detail is important, but it supports the decision already summarized by the run outcome and explanation layers.
- Alternatives considered:
- Show raw JSON only: rejected because it fails the operator-first requirement.
- Put evidence-gap rows ahead of result meaning: rejected because it would over-prioritize diagnostics and weaken the page contract.
## Decision 5: Explicitly model legacy and partial-detail runs
- Decision: Differentiate among runs with no evidence gaps, runs with gaps and recorded subjects, and runs with gaps but no recorded subject detail.
- Rationale: Historical compare runs already exist, and silence must not be interpreted as health. The UI needs an explicit fallback state to preserve trust in old data.
- Alternatives considered:
- Treat missing subjects as empty subjects: rejected because it misrepresents historical/partial runs.
- Hide the section when subjects are missing: rejected because operators would lose the signal that detail quality differs across runs.
## Decision 6: Use existing Filament/Blade patterns rather than new assets
- Decision: Implement the detail surface with existing Filament resource sections, Blade partials, and Alpine-powered filtering only.
- Rationale: The feature does not require a new panel plugin, custom published asset, or heavy client library. Existing Filament v5 and Livewire v4 patterns already support the interaction.
- Alternatives considered:
- Introduce a custom JS table package: rejected because it adds operational overhead and does not materially improve the bounded use case.
- Publish or override Filament internal views: rejected because render hooks and custom entries are sufficient.
## Decision 7: Validate with persistence and render-path regression tests
- Decision: Anchor verification in Pest feature tests for compare persistence, capture-phase subject storage, and run-detail rendering.
- Rationale: The root failure was data not being persisted, not just a missing view. The test plan must cover both the job path and the operator surface.
- Alternatives considered:
- UI-only assertions: rejected because they would not prove the persistence contract.
- Queue smoke tests only: rejected because they are too broad to protect the specific JSON contract.

View File

@ -0,0 +1,144 @@
# Feature Specification: Enterprise Evidence Gap Details for Baseline Compare
**Feature Branch**: `162-baseline-gap-details`
**Created**: 2026-03-24
**Status**: Draft
**Input**: User description: "Create an enterprise-grade baseline compare evidence gap details experience for operation runs, including searchable operator-first presentation of concrete gap subjects, diagnostic clarity, filtering expectations, audit-safe visibility, and best-practice information architecture for tenant-scoped operations."
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant, canonical-view
- **Primary Routes**: `/admin/operations/{run}`, `/admin/t/{tenant}/baseline-compare-landing`, and existing related navigation back to tenant operations and baseline compare entry points
- **Data Ownership**: Tenant-owned `OperationRun` records remain the source of evidence-gap execution context. Workspace-owned baseline profiles and snapshots remain unchanged in ownership. This feature changes capture and presentation of tenant-owned evidence-gap detail, not record ownership.
- **RBAC**: Existing workspace membership, tenant entitlement, and baseline compare or monitoring view capabilities remain authoritative. No new role or capability is introduced.
- **Default filter behavior when tenant-context is active**: Canonical Monitoring entry points continue to respect active tenant context in navigation and related links, while direct run-detail access remains explicit to the run's tenant and must not silently widen visibility.
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical run detail and tenant compare review surfaces must continue to enforce workspace entitlement first and tenant entitlement second, with deny-as-not-found behavior for non-members and no cross-tenant hinting through gap details.
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Monitoring → Operation Run Detail for baseline compare runs | Workspace manager or entitled tenant operator | Canonical detail | Which specific subjects caused evidence gaps, and can I trust this compare result? | Outcome, trust statement, next step, grouped gap-detail summary, searchable gap subjects, related context | Raw JSON, internal payload fragments, low-level capture fragments | execution outcome, result trust, data completeness, follow-up readiness | Simulation only for compare results, TenantPilot only for page rendering | View run, search gap details, open related tenant operations | None |
| Tenant Baseline Compare landing | Tenant operator | Tenant-scoped review surface | Which evidence gaps are blocking a trustworthy compare, and what should I inspect next? | Result meaning, gap counts, grouped reasons, searchable concrete subjects, next-step guidance | Raw evidence payload, secondary technical context | evaluation result, reliability, completeness, actionability | Simulation only | Compare now, inspect latest run, filter concrete gaps | None |
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Inspect concrete evidence gaps quickly (Priority: P1)
An operator reviewing a degraded baseline compare run needs to see which exact policy subjects caused evidence gaps so they can determine whether the visible result is trustworthy and what needs follow-up.
**Why this priority**: Aggregate counts alone do not support operational action. The core value is turning an abstract warning into concrete, reviewable subjects.
**Independent Test**: Create a baseline compare run with evidence gaps and verify that the operator can identify the affected policy subjects from the default-visible detail surface without opening JSON or database tooling.
**Acceptance Scenarios**:
1. **Given** a completed baseline compare run with evidence gaps, **When** an entitled operator opens the run detail page, **Then** the page shows grouped concrete gap subjects tied to the relevant evidence-gap reasons.
2. **Given** a completed baseline compare run with both aggregate counts and concrete gap subjects, **When** the operator reads the page, **Then** the concrete details align with the same reason buckets as the counts and do not contradict the top-level trust statement.
---
### User Story 2 - Filter large gap sets without scanning raw diagnostics (Priority: P2)
An operator dealing with many evidence gaps needs to filter the list by reason, policy type, or subject key so they can isolate the specific policy family or identifier they are investigating.
**Why this priority**: Evidence-gap sets can be too large to inspect manually. Filtering is required for operational usefulness at enterprise scale.
**Independent Test**: Open a run with multiple reasons and many gap subjects, enter a partial policy type or subject key, and confirm that the visible rows narrow to the relevant subset without leaving the page.
**Acceptance Scenarios**:
1. **Given** a run with many evidence-gap rows, **When** the operator filters by a policy type token, **Then** only matching reasons and rows remain visible.
2. **Given** a run with GUID-like subject keys or mixed human-readable names, **When** the operator filters by a partial subject key value, **Then** matching rows remain visible regardless of whether the value is human-readable text or an identifier.
---
### User Story 3 - Distinguish missing detail from no gaps (Priority: P3)
An operator reviewing a legacy or partially recorded run needs the surface to distinguish between runs that had no evidence-gap details and runs where details were never recorded, so they do not misread silence as health.
**Why this priority**: Historical runs and partial payloads will continue to exist. The system must preserve trust even when detail quality varies over time.
**Independent Test**: Open one run with gaps but no recorded subject-level details and one run with no gaps, then verify the page communicates the difference clearly.
**Acceptance Scenarios**:
1. **Given** a run with evidence-gap counts but no stored concrete subjects, **When** the operator opens the detail surface, **Then** the page explains that no detailed gap rows were recorded for that run.
2. **Given** a run with no evidence gaps, **When** the operator opens the detail surface, **Then** no misleading gap-detail section appears and the run reads as having no gap details because none exist.
### Edge Cases
- A run contains aggregate evidence-gap counts, but only some reasons have concrete subject details. The surface must show what is known without implying the missing reasons have zero affected subjects.
- A run predates subject-level evidence-gap storage. The surface must explicitly say that detailed rows were not recorded for that run.
- The same subject appears under different reasons across the same run. The surface must preserve each reason association rather than collapsing away meaning.
- Subject keys may contain spaces, GUIDs, underscores, or mixed human-readable and machine-generated values. Filtering must still work predictably.
- Very large gap sets must remain searchable and readable without requiring raw JSON inspection.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce new Microsoft Graph calls or new long-running job types. It reuses existing baseline compare execution and extends what tenant-owned `OperationRun` records capture and reveal about evidence gaps. Existing tenant isolation, audit, and safe execution rules remain unchanged.
**Constitution alignment (OPS-UX):** Existing baseline compare `OperationRun` behavior remains within the current three-surface feedback contract. `OperationRun.status` and `OperationRun.outcome` remain service-owned. Summary counts remain numeric and lifecycle-oriented. This feature adds richer evidence-gap interpretation and detail within existing run context rather than redefining lifecycle semantics. Scheduled or system-run behavior remains unchanged.
**Constitution alignment (RBAC-UX):** This feature changes what is visible on tenant and canonical run-detail surfaces, but not who is authorized. Non-members remain 404. Members without the relevant view capability remain 403 only after membership is established. No raw capability strings or role-specific shortcuts are introduced.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that no auth-handshake behavior is added.
**Constitution alignment (BADGE-001):** Existing run outcome, trust, and completeness semantics remain centralized. This feature must not invent new ad-hoc badge mappings for evidence-gap states.
**Constitution alignment (UI-NAMING-001):** Operator-facing labels must use domain language such as “Evidence gap details”, “Policy type”, and “Subject key” rather than implementation-first phrasing. Internal reason codes remain diagnostic, not primary.
**Constitution alignment (OPSURF-001):** Default-visible content must remain operator-first. Outcome, trust, and next-step guidance remain ahead of raw JSON. Evidence-gap details are diagnostic, but promoted enough to support first-pass action without forcing operators into raw payloads.
**Constitution alignment (Filament Action Surfaces):** The affected Filament pages remain compliant with the Action Surface Contract. No new destructive actions are introduced. Existing compare actions remain unchanged. This feature only improves read and investigation behavior on existing surfaces.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The evidence-gap experience must remain sectioned, searchable, and readable inside existing detail layouts. The searchable detail table is secondary to the result summary but primary within the diagnostics path.
### Functional Requirements
- **FR-001**: The system MUST persist subject-level evidence-gap details for new baseline compare runs whenever the compare process can identify affected subjects.
- **FR-002**: The system MUST retain aggregate evidence-gap counts and reason groupings alongside subject-level evidence-gap details so both summary and detail remain available on the same run.
- **FR-003**: The system MUST present evidence-gap details on the baseline compare run-detail experience in a searchable table-oriented format rather than as raw JSON alone.
- **FR-004**: The system MUST let operators filter evidence-gap details by at least reason, policy type, and subject key from within the operator-facing surface.
- **FR-005**: The system MUST group evidence-gap details by reason so operators can understand whether subjects are blocked by ambiguity, missing current evidence, missing policy references, or similar distinct causes.
- **FR-006**: The system MUST preserve operator-safe default reading order on the run-detail surface so execution outcome, result trust, and next-step guidance appear before searchable evidence-gap detail and before raw JSON.
- **FR-007**: The system MUST distinguish between “no evidence gaps exist” and “evidence-gap details were not recorded” so historical runs and partial payloads are not misread.
- **FR-008**: The system MUST keep evidence-gap details tenant-safe on both tenant-scoped and canonical monitoring surfaces, revealing them only to entitled workspace and tenant members.
- **FR-009**: The system MUST keep the baseline compare landing experience and the canonical run-detail experience semantically aligned when they reference the same evidence-gap state.
- **FR-010**: The system MUST preserve existing compare initiation behavior, mutation scope messaging, and audit semantics for baseline compare actions. This feature MUST NOT add new dangerous actions or broaden mutation scope.
- **FR-011**: The system MUST continue to support raw JSON diagnostics for support and deep troubleshooting, but those diagnostics MUST remain secondary to the searchable evidence-gap detail experience.
- **FR-012**: The system MUST remain usable for large enterprise tenants by allowing an operator to isolate a relevant gap subject without manually scanning the full visible set.
- **FR-013**: The system MUST continue rendering older runs that only contain aggregate evidence-gap counts without failing the page or hiding the existence of evidence gaps.
- **FR-014**: The system MUST provide regression coverage for subject-level evidence-gap persistence, operator-surface rendering, and filtering-visible affordances on the affected pages.
- **FR-015**: The system MUST preserve the current no-external-calls-on-render rule for Monitoring and run-detail surfaces.
### Assumptions
- This slice focuses on baseline compare evidence-gap detail, not every diagnostic surface in the product.
- Existing baseline compare reason-code and trust semantics remain the semantic source of truth for the top-level operator reading path.
- The primary enterprise need is fast investigation of concrete gap subjects, not full ad hoc reporting from the run detail page.
- Historical runs may continue to exist without subject-level evidence-gap detail and must remain readable.
## 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 compare run detail | Existing canonical run detail surface | Existing navigation and refresh actions remain | Existing detail sections remain the inspect affordance | None added | None | No new CTA. If no gap rows are recorded, the section explains why. | Existing run-detail header actions remain | Not applicable | Existing run and compare audit semantics remain | Read-only information architecture change only |
| Tenant baseline compare landing | Existing tenant compare review surface | Existing compare action remains | Existing navigation to latest run remains | None added | None | Existing compare CTA remains | Existing page-level actions remain | Not applicable | Existing run-backed audit semantics remain | Read-only information architecture change only |
### Key Entities *(include if feature involves data)*
- **Evidence Gap Detail**: A concrete affected subject associated with a specific evidence-gap reason for a baseline compare run, including enough operator-readable identity to investigate the issue.
- **Evidence Gap Reason Group**: A reason bucket that explains why one or more subjects limited compare confidence, used to structure both counts and detailed rows.
- **Baseline Compare Run Context**: The tenant-owned run context that stores both summary evidence-gap information and subject-level detail for later operator review.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: On a baseline compare run with recorded subject-level evidence gaps, an entitled operator can identify at least one affected subject from the default-visible detail surface in under 30 seconds without opening raw JSON.
- **SC-002**: On a run with multiple gap reasons, an entitled operator can isolate the relevant reason, policy type, or subject key using the on-page filter in one filtering action.
- **SC-003**: Legacy runs without subject-level detail continue to render successfully and clearly distinguish missing recorded detail from absence of evidence gaps.
- **SC-004**: The canonical run-detail surface and tenant baseline compare review surface remain semantically consistent in how they describe evidence-gap-driven limited-confidence results.
- **SC-005**: Regression coverage exists for subject-level detail persistence, operator-facing rendering, and search-visible affordances on the affected surfaces.

View File

@ -0,0 +1,208 @@
# Tasks: Enterprise Evidence Gap Details for Baseline Compare
**Input**: Design documents from `/specs/162-baseline-gap-details/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/baseline-gap-details.openapi.yaml`, `quickstart.md`
**Tests**: Runtime behavior changes in this repo require Pest coverage. Each user story below includes the minimum focused tests needed to prove the slice works independently.
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Confirm the feature slice, target files, and verification entrypoints before implementation changes start.
- [X] T001 Confirm the feature scope, affected files, and verification commands in `specs/162-baseline-gap-details/plan.md`, `specs/162-baseline-gap-details/quickstart.md`, and `specs/162-baseline-gap-details/contracts/baseline-gap-details.openapi.yaml`
- [X] T002 Map the current baseline compare detail flow in `app/Jobs/CompareBaselineToTenantJob.php`, `app/Services/Baselines/BaselineContentCapturePhase.php`, `app/Filament/Resources/OperationRunResource.php`, and `app/Filament/Pages/BaselineCompareLanding.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the shared evidence-gap read model and rendering helpers required by all user stories.
**⚠️ CRITICAL**: No user story work should start until this phase is complete.
- [X] T003 Define the shared evidence-gap read-model contract and state mapping in `app/Filament/Resources/OperationRunResource.php` to match `specs/162-baseline-gap-details/data-model.md`
- [X] T004 [P] Normalize canonical run-detail reason-bucket extraction helpers for `baseline_compare.evidence_gaps.subjects` in `app/Filament/Resources/OperationRunResource.php`
- [X] T005 [P] Normalize tenant-landing evidence-gap summary/detail extraction in `app/Support/Baselines/BaselineCompareStats.php`
- [X] T006 Establish the tenant-landing integration points for evidence-gap summary, detail, and diagnostics sections in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
**Checkpoint**: Shared read model and page integration points are clear; user-story implementation can proceed.
---
## Phase 3: User Story 1 - Inspect concrete evidence gaps quickly (Priority: P1) 🎯 MVP
**Goal**: Persist and render grouped concrete evidence-gap subjects so an operator can inspect affected policies without opening raw JSON.
**Independent Test**: Create a baseline compare run with evidence gaps and verify the canonical run detail shows grouped concrete subjects aligned with the aggregate reason buckets.
### Tests for User Story 1
- [X] T007 [P] [US1] Extend compare persistence coverage for reason-grouped subject details in `tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php`
- [X] T008 [P] [US1] Extend capture-phase subject persistence coverage in `tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`
- [X] T009 [P] [US1] Add or update canonical run-detail rendering coverage for grouped evidence-gap rows in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T010 [P] [US1] Add canonical run-detail render-safety coverage with `bindFailHardGraphClient()` in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
### Implementation for User Story 1
- [X] T011 [US1] Persist bounded subject-level evidence-gap buckets for new compare runs in `app/Jobs/CompareBaselineToTenantJob.php`
- [X] T012 [US1] Persist capture-phase `gap_subjects` alongside aggregate gaps in `app/Services/Baselines/BaselineContentCapturePhase.php`
- [X] T013 [US1] Render grouped evidence-gap detail sections from the shared read model in `app/Filament/Resources/OperationRunResource.php`
- [X] T014 [US1] Create or refine the grouped read-only evidence-gap detail view in `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
- [X] T015 [US1] Keep raw JSON diagnostics secondary and aligned with the new evidence-gap detail contract in `app/Filament/Resources/OperationRunResource.php`
**Checkpoint**: Operators can inspect concrete evidence-gap subjects on the canonical run detail page without using raw JSON.
---
## Phase 4: User Story 2 - Filter large gap sets without scanning raw diagnostics (Priority: P2)
**Goal**: Let operators narrow evidence-gap detail by reason, policy type, or subject key directly on the page.
**Independent Test**: Open a run with multiple gap reasons and many subject rows, enter a partial search token, and confirm only the relevant grouped rows remain visible.
### Tests for User Story 2
- [X] T016 [P] [US2] Add filtering-affordance assertions for the canonical run detail in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T017 [P] [US2] Add tenant-landing parity and filtering visibility coverage in `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
- [X] T018 [P] [US2] Add tenant-landing render-safety coverage with `bindFailHardGraphClient()` in `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
- [X] T019 [P] [US2] Add unchanged compare-start surface and mutation-scope messaging coverage in `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`
- [X] T020 [P] [US2] Add unchanged baseline-compare audit semantics coverage in `tests/Feature/Baselines/BaselineCompareAuditEventsTest.php`
### Implementation for User Story 2
- [X] T021 [US2] Implement local filtering across reason, policy type, and subject key in `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
- [X] T022 [US2] Render the tenant-landing evidence-gap detail block and search affordances in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
- [X] T023 [US2] Align `BaselineCompareStats` reason-group payloads with the canonical run-detail bucket contract in `app/Support/Baselines/BaselineCompareStats.php`, `app/Filament/Pages/BaselineCompareLanding.php`, and `app/Filament/Resources/OperationRunResource.php`
- [X] T024 [US2] Keep tenant-landing raw diagnostics explicitly secondary to summary and evidence-gap detail in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
**Checkpoint**: Operators can isolate relevant evidence-gap rows on both affected surfaces without leaving the page.
---
## Phase 5: User Story 3 - Distinguish missing detail from no gaps (Priority: P3)
**Goal**: Preserve operator trust by clearly distinguishing legacy or partially recorded runs from runs that truly have no evidence gaps.
**Independent Test**: Open one run with aggregate evidence-gap counts but no recorded subject details and another run with no evidence gaps, then verify the UI communicates the difference clearly.
### Tests for User Story 3
- [X] T025 [P] [US3] Add legacy-run fallback coverage to `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T026 [P] [US3] Add tenant-landing fallback coverage for missing-detail versus no-gap states in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
- [X] T027 [P] [US3] Add explicit tenant-safety regression coverage for canonical and tenant surfaces in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
### Implementation for User Story 3
- [X] T028 [US3] Implement canonical run-detail fallback messaging for `details_not_recorded` versus `no_gaps` in `app/Filament/Resources/OperationRunResource.php` and `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
- [X] T029 [US3] Implement tenant-landing fallback messaging for legacy and partial-detail compare runs in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
- [X] T030 [US3] Ensure partial reason coverage does not imply zero affected subjects for missing buckets in `app/Filament/Resources/OperationRunResource.php` and `app/Support/Baselines/BaselineCompareStats.php`
**Checkpoint**: Legacy and partial-detail runs remain readable and trustworthy without being mistaken for healthy no-gap runs.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final consistency, regression validation, and release readiness across all user stories.
- [X] T031 [P] Update feature artifacts for any final contract or wording changes in `specs/162-baseline-gap-details/spec.md`, `specs/162-baseline-gap-details/plan.md`, `specs/162-baseline-gap-details/quickstart.md`, and `specs/162-baseline-gap-details/contracts/baseline-gap-details.openapi.yaml`
- [X] T032 Run focused formatting on touched PHP files with `vendor/bin/sail bin pint --dirty --format agent`
- [X] T033 Run the focused canonical verification pack from `specs/162-baseline-gap-details/quickstart.md` with `vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T034 [P] Run the tenant-landing evidence-gap verification tests with `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Baselines/BaselineCompareAuditEventsTest.php`
- [X] T035 Execute the manual run-review checks in `specs/162-baseline-gap-details/quickstart.md` against `/admin/operations/{run}` and `/admin/t/{tenant}/baseline-compare-landing`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1: Setup**: No dependencies.
- **Phase 2: Foundational**: Depends on Phase 1 and blocks all user stories.
- **Phase 3: US1**: Starts after Phase 2 and delivers the MVP.
- **Phase 4: US2**: Starts after Phase 2; depends functionally on the US1 detail structure being present.
- **Phase 5: US3**: Starts after Phase 2; depends functionally on the shared read model and both surface integrations.
- **Phase 6: Polish**: Starts after the desired user stories are complete.
### User Story Dependencies
- **US1 (P1)**: No dependency on other user stories once the foundational phase is complete.
- **US2 (P2)**: Depends on US1 detail rendering because filtering operates on the rendered evidence-gap rows.
- **US3 (P3)**: Depends on US1 read-model/rendering and should also align with US2 surface semantics where filtering is visible.
### Within Each User Story
- Tests should be written or updated before implementation and fail for the missing behavior.
- Persistence work precedes rendering work.
- Rendering work precedes parity and fallback polish.
- Each story should be independently runnable through the focused tests listed above.
## Parallel Opportunities
- `T004` and `T005` can run in parallel after `T003`.
- `T007`, `T008`, `T009`, and `T010` can run in parallel within US1.
- `T016`, `T017`, `T018`, `T019`, and `T020` can run in parallel within US2.
- `T025`, `T026`, and `T027` can run in parallel within US3.
- `T031` and `T034` can run in parallel during Polish.
## Parallel Example: User Story 1
```bash
# Parallelize the US1 regression updates first
Task: T007 tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php
Task: T008 tests/Feature/Baselines/BaselineCompareResumeTokenTest.php
Task: T009 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Task: T010 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
# Then implement the persistence and render paths
Task: T011 app/Jobs/CompareBaselineToTenantJob.php
Task: T012 app/Services/Baselines/BaselineContentCapturePhase.php
Task: T013 app/Filament/Resources/OperationRunResource.php
Task: T014 resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php
```
## Parallel Example: User Story 2
```bash
# Parallelize test scaffolding for both surfaces
Task: T016 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Task: T017 tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php
Task: T018 tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php
Task: T019 tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php
Task: T020 tests/Feature/Baselines/BaselineCompareAuditEventsTest.php
# Then implement filtering and surface parity
Task: T021 resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php
Task: T022 app/Filament/Pages/BaselineCompareLanding.php
Task: T023 app/Support/Baselines/BaselineCompareStats.php
Task: T024 resources/views/filament/pages/baseline-compare-landing.blade.php
```
## Parallel Example: User Story 3
```bash
# Parallelize legacy-state tests
Task: T025 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Task: T026 tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php
Task: T027 tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php
# Then implement fallback states on both surfaces
Task: T028 app/Filament/Resources/OperationRunResource.php
Task: T029 resources/views/filament/pages/baseline-compare-landing.blade.php
Task: T030 app/Support/Baselines/BaselineCompareStats.php
```
## Implementation Strategy
### MVP First
Deliver Phase 3 first. That yields the core value: subject-level persistence plus canonical run-detail visibility for evidence-gap details.
### Incremental Delivery
1. Complete Setup and Foundational phases.
2. Deliver US1 as the MVP for operator visibility.
3. Add US2 for enterprise-scale filtering and landing-page parity.
4. Add US3 for legacy-run trust and fallback clarity.
5. Finish with formatting, focused regression runs, and manual validation.

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Baseline Subject Resolution and Evidence Gap Semantics Foundation
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-24
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/163-baseline-subject-resolution/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
- Validated on first pass.
- The spec intentionally establishes root-cause and runtime-support semantics only; fidelity richness, renderer density, and wording refinement remain deferred follow-on work.

View File

@ -0,0 +1,334 @@
openapi: 3.1.0
info:
title: Baseline Subject Resolution Semantics Contract
version: 0.1.0
description: >-
Read-model and validation contracts for baseline compare and capture subject-resolution
semantics. This contract documents the payloads existing operator surfaces consume after
the foundation upgrade and the capability matrix used to keep baseline support truthful
at runtime.
servers:
- url: https://tenantpilot.local
paths:
/admin/api/operations/{operationRun}/baseline-gap-semantics:
get:
summary: Get structured baseline gap semantics for an operation run
operationId: getBaselineGapSemanticsForRun
parameters:
- name: operationRun
in: path
required: true
schema:
type: integer
responses:
'200':
description: Structured baseline gap semantics for the run
content:
application/json:
schema:
$ref: '#/components/schemas/BaselineGapSemanticsResponse'
'404':
description: Run not found or not visible in current workspace or tenant scope
'403':
description: Caller is a member but lacks permission to inspect the run
/admin/api/tenants/{tenant}/baseline-compare/latest-gap-semantics:
get:
summary: Get latest baseline compare gap semantics for a tenant and profile
operationId: getLatestTenantBaselineCompareGapSemantics
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: baseline_profile_id
in: query
required: true
schema:
type: integer
responses:
'200':
description: Latest tenant baseline compare semantics projection
content:
application/json:
schema:
$ref: '#/components/schemas/BaselineGapSemanticsResponse'
'404':
description: Tenant or run not found for current entitled scope
'403':
description: Caller lacks compare or review visibility within the established tenant scope
/admin/api/baseline-support/resolution-capabilities:
get:
summary: Get runtime baseline support and resolution capability matrix
operationId: getBaselineResolutionCapabilities
responses:
'200':
description: Capability matrix used to validate truthful compare and capture support
content:
application/json:
schema:
type: object
required:
- data
properties:
data:
type: array
items:
$ref: '#/components/schemas/SupportCapabilityRecord'
components:
schemas:
BaselineGapSemanticsResponse:
type: object
required:
- summary
- buckets
properties:
summary:
$ref: '#/components/schemas/BaselineGapSummary'
buckets:
type: array
items:
$ref: '#/components/schemas/BaselineGapBucket'
BaselineGapSummary:
type: object
required:
- count
- by_reason
- detail_state
- recorded_subjects_total
- missing_detail_count
- structural_count
- operational_count
- transient_count
- legacy_mode
- requires_regeneration
properties:
count:
type: integer
minimum: 0
by_reason:
type: object
additionalProperties:
type: integer
minimum: 0
detail_state:
type: string
enum:
- no_gaps
- structured_details_recorded
- details_not_recorded
- legacy_broad_reason
recorded_subjects_total:
type: integer
minimum: 0
missing_detail_count:
type: integer
minimum: 0
structural_count:
type: integer
minimum: 0
operational_count:
type: integer
minimum: 0
transient_count:
type: integer
minimum: 0
legacy_mode:
type: boolean
requires_regeneration:
type: boolean
BaselineGapBucket:
type: object
required:
- reason_code
- reason_label
- count
- recorded_count
- missing_detail_count
- structural_count
- operational_count
- transient_count
- detail_state
- search_text
- rows
properties:
reason_code:
type: string
reason_label:
type: string
count:
type: integer
minimum: 0
recorded_count:
type: integer
minimum: 0
missing_detail_count:
type: integer
minimum: 0
structural_count:
type: integer
minimum: 0
operational_count:
type: integer
minimum: 0
transient_count:
type: integer
minimum: 0
detail_state:
type: string
enum:
- structured_details_recorded
- details_not_recorded
- legacy_broad_reason
search_text:
type: string
rows:
type: array
items:
$ref: '#/components/schemas/EvidenceGapDetailRecord'
EvidenceGapDetailRecord:
type: object
required:
- policy_type
- subject_key
- subject_class
- resolution_path
- resolution_outcome
- reason_code
- operator_action_category
- structural
- retryable
properties:
policy_type:
type: string
subject_external_id:
type:
- string
- 'null'
subject_key:
type: string
subject_class:
$ref: '#/components/schemas/SubjectClass'
resolution_path:
$ref: '#/components/schemas/ResolutionPath'
resolution_outcome:
$ref: '#/components/schemas/ResolutionOutcome'
reason_code:
type: string
operator_action_category:
$ref: '#/components/schemas/OperatorActionCategory'
structural:
type: boolean
retryable:
type: boolean
source_model_expected:
type:
- string
- 'null'
enum:
- policy
- inventory
- derived
- 'null'
source_model_found:
type:
- string
- 'null'
enum:
- policy
- inventory
- derived
- 'null'
legacy_reason_code:
type:
- string
- 'null'
SupportCapabilityRecord:
type: object
required:
- policy_type
- subject_class
- compare_capability
- capture_capability
- resolution_path
- config_supported
- runtime_valid
properties:
policy_type:
type: string
subject_class:
$ref: '#/components/schemas/SubjectClass'
compare_capability:
type: string
enum:
- supported
- limited
- unsupported
capture_capability:
type: string
enum:
- supported
- limited
- unsupported
resolution_path:
$ref: '#/components/schemas/ResolutionPath'
config_supported:
type: boolean
runtime_valid:
type: boolean
SubjectClass:
type: string
enum:
- policy_backed
- inventory_backed
- foundation_backed
- derived
ResolutionPath:
type: string
enum:
- policy
- inventory
- foundation_policy
- foundation_inventory
- derived
- unsupported
ResolutionOutcome:
type: string
enum:
- resolved_policy
- resolved_inventory
- policy_record_missing
- inventory_record_missing
- foundation_inventory_only
- resolution_type_mismatch
- unresolvable_subject
- permission_or_scope_blocked
- retryable_capture_failure
- capture_failed
- throttled
- budget_exhausted
- ambiguous_match
- invalid_subject
- duplicate_subject
- invalid_support_config
OperatorActionCategory:
type: string
enum:
- none
- retry
- run_inventory_sync
- run_policy_sync_or_backup
- review_permissions
- inspect_subject_mapping
- product_follow_up

View File

@ -0,0 +1,167 @@
# Data Model: Baseline Subject Resolution and Evidence Gap Semantics Foundation
## Overview
This feature does not require a new primary database table. It introduces a richer logical model for subject classification and resolution, then persists that model inside existing compare and capture run context for new runs. Development-only run payloads using the old broad reason shape may be removed or regenerated instead of preserved through a compatibility contract.
## Entity: SubjectDescriptor
Business-level descriptor for a compare or capture target before local resolution.
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `policy_type` | string | yes | Canonical policy or foundation type from support metadata |
| `subject_external_id` | string | no | Stable local or tenant-local external identifier when available |
| `subject_key` | string | yes | Deterministic comparison key used across capture and compare |
| `subject_class` | enum | yes | One of `policy_backed`, `inventory_backed`, `foundation_backed`, `derived` |
| `resolution_path` | enum | yes | Intended local path, such as `policy`, `inventory`, `foundation_policy`, `foundation_inventory`, or `derived` |
| `support_mode` | enum | yes | `supported`, `limited`, `excluded`, or `invalid_support_config` |
| `source_model_expected` | enum | no | Which local model is expected to satisfy the lookup |
### Validation Rules
- `policy_type` must exist in canonical support metadata.
- `subject_class` must be one of the supported subject-class values.
- `resolution_path` must be compatible with `subject_class`.
- `support_mode=invalid_support_config` is only valid when metadata claims support but no truthful runtime path exists.
## Entity: ResolutionOutcomeRecord
Deterministic result of attempting to resolve a `SubjectDescriptor` against tenant-local state.
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `resolution_outcome` | enum | yes | Precise outcome such as `resolved_policy`, `resolved_inventory`, `policy_record_missing`, `inventory_record_missing`, `foundation_inventory_only`, `resolution_type_mismatch`, `unresolvable_subject`, `permission_or_scope_blocked`, `retryable_capture_failure`, `capture_failed`, `throttled`, or `budget_exhausted` |
| `reason_code` | string | yes | Stable operator-facing reason family persisted with the run |
| `operator_action_category` | enum | yes | `none`, `retry`, `run_inventory_sync`, `run_policy_sync_or_backup`, `review_permissions`, `inspect_subject_mapping`, or `product_follow_up` |
| `structural` | boolean | yes | Whether the outcome is structural rather than operational or transient |
| `retryable` | boolean | yes | Whether retry may change the outcome without prerequisite changes |
| `source_model_found` | enum or null | no | Which local model actually satisfied the lookup when resolution succeeded |
### State Families
| Family | Outcomes |
|---|---|
| `resolved` | `resolved_policy`, `resolved_inventory` |
| `structural` | `foundation_inventory_only`, `resolution_type_mismatch`, `unresolvable_subject`, `invalid_support_config` |
| `operational` | `policy_record_missing`, `inventory_record_missing`, `permission_or_scope_blocked`, `ambiguous_match`, `invalid_subject`, `duplicate_subject` |
| `transient` | `retryable_capture_failure`, `throttled`, `budget_exhausted`, `capture_failed` |
### Validation Rules
- `resolution_outcome` must map to exactly one state family.
- `structural=true` is only valid for structural state-family outcomes.
- `retryable=true` is only valid for transient outcomes or explicitly retryable operational outcomes.
## Entity: SupportCapabilityRecord
Runtime truth contract for whether a subject type may enter baseline compare or capture.
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `policy_type` | string | yes | Canonical type key |
| `subject_class` | enum | yes | Dominant subject class for the type |
| `compare_capability` | enum | yes | `supported`, `limited`, or `unsupported` |
| `capture_capability` | enum | yes | `supported`, `limited`, or `unsupported` |
| `resolution_path` | enum | yes | Truthful runtime resolution path |
| `config_supported` | boolean | yes | Whether metadata claims support |
| `runtime_valid` | boolean | yes | Whether the runtime can honor that support claim |
### Validation Rules
- `config_supported=true` and `runtime_valid=false` must be surfaced as `invalid_support_config` rather than silently ignored.
- Types with `compare_capability=unsupported` must not enter compare scope.
- Types with `capture_capability=unsupported` must not enter capture execution.
## Entity: EvidenceGapDetailRecord
Structured subject-level record persisted under compare or capture run context for new runs.
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `policy_type` | string | yes | Canonical type |
| `subject_external_id` | string or null | no | Stable identifier when available |
| `subject_key` | string | yes | Deterministic subject identity |
| `subject_class` | enum | yes | Classified subject class |
| `resolution_path` | enum | yes | Path attempted or declared |
| `resolution_outcome` | enum | yes | Deterministic resolution result |
| `reason_code` | string | yes | Stable reason family |
| `operator_action_category` | enum | yes | Recommended next action family |
| `structural` | boolean | yes | Structural versus non-structural marker |
| `retryable` | boolean | yes | Retryability marker |
| `source_model_expected` | enum or null | no | Expected local evidence model |
| `source_model_found` | enum or null | no | Actual local evidence model when present |
### Storage Locations
- `operation_runs.context.baseline_compare.evidence_gaps.subjects[]`
- `operation_runs.context.baseline_capture.gaps.subjects[]` or equivalent capture-context namespace
### Validation Rules
- New-run records must store structured objects, not only string subject tokens.
- `subject_key` must be deterministic for identical inputs.
- `reason_code` and `resolution_outcome` must not contradict each other.
- Old development rows that omit the new fields are cleanup candidates and should be regenerated or deleted rather than treated as a first-class runtime shape.
## Derived Entity: EvidenceGapProjection
Read-model projection used by canonical run-detail and tenant review surfaces.
### Fields
| Field | Type | Description |
|---|---|---|
| `detail_state` | enum | `no_gaps`, `structured_details_recorded`, `details_not_recorded`, `legacy_broad_reason` |
| `count` | integer | Total gap count |
| `by_reason` | map<string,int> | Aggregate counts by reason |
| `recorded_subjects_total` | integer | Number of structured subject rows available for projection |
| `missing_detail_count` | integer | Gap count that has no structured row attached |
| `structural_count` | integer | Number of recorded structural gap rows |
| `operational_count` | integer | Number of recorded non-structural, non-retryable rows |
| `transient_count` | integer | Number of recorded retryable rows |
| `legacy_mode` | boolean | Indicates the run still stores a broad legacy gap payload |
| `buckets` | list | Grouped records by reason with searchable row payload |
| `requires_regeneration` | boolean | Whether stale local development data should be regenerated rather than interpreted semantically |
## State Transitions
### Resolution lifecycle for a subject
1. `described`
- `SubjectDescriptor` is created from scope, metadata, and capability information.
2. `validated`
- Runtime support guard confirms whether the subject may enter compare or capture.
3. `resolved`
- The system attempts the appropriate local path and emits a `ResolutionOutcomeRecord`.
4. `persisted`
- New runs store the structured `EvidenceGapDetailRecord` or resolved outcome details in `OperationRun.context`.
5. `projected`
- Existing operator surfaces consume the new structured projection. Stale development data is regenerated or removed instead of driving a permanent compatibility path.
## Example New-Run Compare Gap Record
```json
{
"policy_type": "roleScopeTag",
"subject_external_id": "42f4fcb8-5b35-44ec-9240-0a1ad7c31fb1",
"subject_key": "rolescopetag|42f4fcb8-5b35-44ec-9240-0a1ad7c31fb1",
"subject_class": "foundation_backed",
"resolution_path": "foundation_inventory",
"resolution_outcome": "foundation_inventory_only",
"reason_code": "foundation_not_policy_backed",
"operator_action_category": "product_follow_up",
"structural": true,
"retryable": false,
"source_model_expected": "inventory_item",
"source_model_found": "inventory_item"
}
```

View File

@ -0,0 +1,246 @@
# Implementation Plan: 163 — Baseline Subject Resolution and Evidence Gap Semantics Foundation
**Branch**: `163-baseline-subject-resolution` | **Date**: 2026-03-24 | **Spec**: `specs/163-baseline-subject-resolution/spec.md`
**Input**: Feature specification from `specs/163-baseline-subject-resolution/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce an explicit backend subject-resolution contract for baseline compare and baseline capture so the system can classify each subject before resolution, select the correct local model path, and persist precise operator-safe gap semantics instead of collapsing structural, operational, and transient causes into broad `policy_not_found` style states. The implementation will extend existing baseline scope, inventory policy-type metadata, compare and capture jobs, baseline evidence-gap detail parsing, and OperationRun context persistence rather than introducing a parallel execution stack, with a bounded runtime support guard that prevents baseline-supported types from entering compare or capture on a resolver path that cannot truthfully classify them.
## Technical Context
**Language/Version**: PHP 8.4
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
**Storage**: PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON
**Testing**: Pest v4 on PHPUnit 12
**Target Platform**: Dockerized Laravel web application running through Sail locally and Dokploy in deployment
**Project Type**: Web application
**Performance Goals**: Preserve DB-only render behavior for Monitoring and tenant review surfaces, add no render-time Graph calls, and keep evidence-gap interpretation deterministic and lightweight enough for existing run-detail and landing surfaces
**Constraints**:
- No new render-time remote work and no bypass of `GraphClientInterface`
- No change to `OperationRun` lifecycle ownership, notification channels, or summary-count rules
- No new operator screen; existing surfaces must present richer semantics
- Existing development-only run payloads may be deleted or regenerated if that simplifies migration to the new structured contract
- Baseline-supported configuration must not overpromise runtime capability
**Scale/Scope**: Cross-cutting backend semantic work across baseline compare and capture pipelines, support-layer parsers and translators, OperationRun context contracts, tenant and canonical read surfaces, and focused Pest coverage for deterministic classification and development-safe contract cleanup
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS — the design keeps inventory as last-observed truth and distinguishes inventory-backed evidence from policy-backed evidence rather than conflating them.
- Read/write separation: PASS — this feature changes classification and persisted run semantics inside existing compare and capture flows; it does not add new write or restore actions.
- Graph contract path: PASS — no new Graph contract or direct endpoint use is introduced; existing capture and sync services remain the only remote paths.
- Deterministic capabilities: PASS — subject-class derivation, resolution outcome mapping, and support-capability guards are explicitly designed to be deterministic and testable.
- RBAC-UX: PASS — existing `/admin` and tenant-context authorization boundaries remain unchanged; only read semantics improve.
- Workspace isolation: PASS — no new workspace leakage is introduced and canonical run-detail remains tenant-safe.
- RBAC confirmations: PASS — no new destructive actions are added.
- Global search: PASS — unaffected.
- Tenant isolation: PASS — all compare, capture, inventory, and run data remain tenant-bound and entitlement-checked.
- Run observability: PASS — compare and capture continue to use existing `OperationRun` types; this slice enriches context semantics only.
- Ops-UX 3-surface feedback: PASS — no new toast, progress, or terminal-notification channels are added.
- Ops-UX lifecycle: PASS — `OperationRun.status` and `OperationRun.outcome` remain service-owned; only context enrichment changes.
- Ops-UX summary counts: PASS — no non-numeric values are moved into `summary_counts`; richer semantics live in context and read models.
- Ops-UX guards: PASS — focused regression tests can protect classification determinism and development cleanup behavior without relaxing existing CI rules.
- Ops-UX system runs: PASS — unchanged.
- Automation: PASS — existing queue, retry, and backoff behavior stays intact; transient outcomes are classified more precisely, not re-executed differently.
- Data minimization: PASS — the new gap detail contract stores classification and stable identifiers, not raw policy payloads or secrets.
- Badge semantics (BADGE-001): PASS — if structural, operational, or transient labels surface as badges, they must route through centralized badge or presentation helpers rather than ad hoc maps.
- UI naming (UI-NAMING-001): PASS — the feature exists to replace implementation-first broad error prose with domain-first operator meaning.
- Operator surfaces (OPSURF-001): PASS — existing run detail and tenant review surfaces remain operator-first and diagnostics-secondary.
- Filament UI Action Surface Contract: PASS — action topology stays unchanged; this is a read-surface semantics upgrade.
- Filament UI UX-001 (Layout & IA): PASS — existing layouts remain, but sections become more semantically truthful. No exemption required.
## Project Structure
### Documentation (this feature)
```text
specs/163-baseline-subject-resolution/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── BaselineCompareLanding.php
│ ├── Resources/
│ │ ├── OperationRunResource.php
│ │ └── BaselineSnapshotResource.php
│ └── Widgets/
├── Jobs/
│ ├── CompareBaselineToTenantJob.php
│ └── CaptureBaselineSnapshotJob.php
├── Services/
│ ├── Baselines/
│ │ ├── BaselineCompareService.php
│ │ ├── BaselineCaptureService.php
│ │ ├── BaselineContentCapturePhase.php
│ │ └── Evidence/
│ ├── Intune/
│ │ └── PolicySyncService.php
│ └── Inventory/
│ └── InventorySyncService.php
├── Support/
│ ├── Baselines/
│ ├── Inventory/
│ ├── OpsUx/
│ └── Ui/
├── Livewire/
└── Models/
config/
├── tenantpilot.php
└── graph_contracts.php
tests/
├── Feature/
│ ├── Baselines/
│ ├── Filament/
│ └── Monitoring/
└── Unit/
└── Support/
```
**Structure Decision**: Web application. The work stays inside existing baseline jobs and services, support-layer value objects and presenters, current Filament surfaces, and focused Pest coverage. No new top-level architecture area is required.
## Complexity Tracking
No constitution violations are required for this feature.
## Phase 0 — Outline & Research (DONE)
Outputs:
- `specs/163-baseline-subject-resolution/research.md`
Key decisions captured:
- Introduce a first-class subject-resolution contract in the backend instead of solving the problem with UI-only relabeling.
- Persist both `subject_class` and `resolution_outcome` because they answer different operator questions.
- Keep foundation-backed subjects eligible only when the runtime can truthfully classify them through an inventory-backed or limited-capability path.
- Add a runtime consistency guard during scope or resolver preparation so `baseline_compare.supported` cannot silently overpromise structural capability.
- Preserve transient reasons such as throttling and capture failure as precise operational outcomes rather than absorbing them into structural taxonomy.
- Treat broad legacy gap shapes as development-only cleanup candidates rather than a compatibility requirement for the new runtime contract.
## Phase 1 — Design & Contracts (DONE)
Outputs:
- `specs/163-baseline-subject-resolution/data-model.md`
- `specs/163-baseline-subject-resolution/contracts/openapi.yaml`
- `specs/163-baseline-subject-resolution/quickstart.md`
Design highlights:
- The core semantic unit is a `SubjectDescriptor` that is classified before resolution and yields a deterministic `ResolutionOutcomeRecord`.
- `OperationRun.context` remains the canonical persisted contract for compare and capture evidence-gap semantics, but new runs store richer subject-level objects instead of reason plus raw string only.
- The runtime support guard sits before compare and capture execution so unsupported structural mismatches are blocked or reclassified before misleading `policy_not_found`-style outcomes are emitted.
- Existing detail and landing surfaces are updated for the new structured gap contract, and development fixtures or stale local run data are regenerated instead of driving a permanent compatibility layer.
- Compare and capture share the same root-cause vocabulary, but retain operation-specific outcome families where needed.
## Phase 1 — Agent Context Update (REQUIRED)
Run:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Constitution Check — Post-Design Re-evaluation
- PASS — the design remains inside existing compare and capture operations and does not add new remote-call paths or lifecycle mutations.
- PASS — inventory-first semantics are strengthened because inventory-backed subjects are no longer mislabeled as missing policy records.
- PASS — operator surfaces stay on existing pages and remain DB-only at render time.
- PASS — development cleanup is explicit and bounded; the new contract remains the only forward-looking runtime shape.
- PASS — no Action Surface or UX-001 exemptions are needed because action topology and layouts remain intact.
## Phase 2 — Implementation Plan
### Step 1 — Subject classification and runtime capability foundation
Goal: implement FR-001 through FR-003, FR-008, FR-015, and FR-016 by creating a deterministic subject-resolution foundation shared by compare and capture.
Changes:
- Introduce a dedicated subject-resolution support layer under `app/Support/Baselines/` that defines:
- subject classes
- resolution paths
- resolution outcomes
- operator action categories
- structural versus operational versus transient classification
- Extend `InventoryPolicyTypeMeta` and related metadata accessors so baseline support can express whether a type is policy-backed, inventory-backed, foundation-backed, or limited.
- Add a runtime capability guard used by `BaselineScope`, `BaselineCompareService`, and `BaselineCaptureService` so types only enter compare or capture on a truthful path.
- Keep the guard deterministic and explicit in logs or run context when support is limited or excluded.
Tests:
- Add unit tests for subject-class derivation, resolution-path derivation, and runtime-capability guard behavior.
- Add golden-style tests covering supported, limited, and structurally invalid foundation types.
### Step 2 — Capture-path resolution and gap taxonomy upgrade
Goal: implement FR-004 through FR-010 on the capture side so structural resolver mismatches are no longer emitted as generic missing-policy cases.
Changes:
- Refactor `BaselineContentCapturePhase` so it resolves subjects through the new subject contract rather than assuming a policy lookup for all subjects.
- Replace broad `policy_not_found` capture gaps with precise structured outcomes such as:
- policy record missing
- inventory record missing
- foundation-backed via inventory path
- resolution type mismatch
- unresolvable subject
- Preserve existing transient outcomes like `throttled`, `capture_failed`, and `budget_exhausted` unchanged except for richer structured metadata.
- Persist new structured gap-subject objects for new runs and remove any requirement to keep broad legacy reason shapes alive for future writes.
Tests:
- Add feature and unit coverage for capture-path classification across policy-backed, inventory-backed, foundation-backed, duplicate, invalid, and transient cases.
- Add deterministic replay coverage proving unchanged capture inputs produce unchanged outcomes.
- Add regressions proving structural foundation subjects no longer produce new generic `policy_not_found` gaps.
### Step 3 — Compare-path resolution and evidence-gap detail contract
Goal: implement FR-004 through FR-014 on the compare side by aligning current-evidence resolution, evidence-gap reasoning, and persisted run context with the new contract.
Changes:
- Refactor `CompareBaselineToTenantJob` so baseline item interpretation and current-state resolution produce explicit `resolution_outcome` records rather than only count buckets and raw subject keys.
- Add structured evidence-gap subject records under `baseline_compare.evidence_gaps.subjects` for new runs, including subject class, resolution path, resolution outcome, reason code, operator action category, and retryability or structural flags.
- Preserve already precise compare reasons such as `missing_current`, `ambiguous_match`, and role-definition-specific gap families while separating them from structural non-policy-backed outcomes.
- Ensure baseline compare reason translation remains aligned with the new detailed reason taxonomy instead of flattening distinct root causes.
Tests:
- Add feature tests for mixed compare runs containing structural, operational, transient, and successful subjects.
- Add deterministic compare tests proving identical inputs yield identical resolution outcomes.
- Add regressions for evidence-gap persistence shape and compare-surface rendering against the new structured contract.
### Step 4 — Development cleanup and operator-surface adoption
Goal: implement FR-011 through FR-014 and the User Story 3 acceptance scenarios by moving existing read surfaces to the new gap contract and treating stale development data as disposable.
Changes:
- Extend `BaselineCompareEvidenceGapDetails`, `BaselineCompareStats`, `OperationRunResource`, `BaselineCompareLanding`, and any related Livewire gap tables so they read the new structured gap subject records consistently.
- Add an explicit development cleanup mechanism for stale local run payloads, preferably a dedicated development-only Artisan command plus fixture regeneration steps, so old broad string-only gap subjects can be purged instead of preserved.
- Introduce operator-facing labels that answer root cause before action advice while keeping diagnostics secondary.
- Keep existing pages and sections, but expose structural versus operational versus transient semantics consistently across dense and detailed surfaces.
- Update snapshot and compare summary surfaces where old broad reason aggregations would otherwise misread the new taxonomy.
Tests:
- Add or update Filament feature tests for canonical run detail and tenant baseline compare landing against the new structured run shape.
- Add cleanup-oriented tests proving the development cleanup mechanism removes or invalidates stale broad-reason run payloads without extending production semantics.
### Step 5 — Focused validation pack and rollout safety
Goal: protect the foundation from semantic regressions and make follow-on fidelity work safe.
Changes:
- Add a focused regression pack spanning compare, capture, capability guard, and development-safe contract cleanup.
- Review every touched reason-label and badge usage to ensure structural, operational, and transient meanings remain centralized.
- Document the new backend contract shape in code-level PHPDoc and tests so follow-on specs can build on stable semantics.
- Keep rollout bounded to baseline compare and capture semantics without adding renderer-richness work from Spec 164.
Tests:
- Run the focused Pest pack in `quickstart.md`.
- Add one regression proving no render-time Graph calls occur on affected run-detail or landing surfaces.

View File

@ -0,0 +1,87 @@
# Quickstart: Baseline Subject Resolution and Evidence Gap Semantics Foundation
## Prerequisites
1. Start the local stack.
```bash
vendor/bin/sail up -d
```
2. Clear stale cached state if you have been switching branches or configs.
```bash
vendor/bin/sail artisan optimize:clear
```
## Focused Verification Pack
Run the minimum targeted regression pack for this foundation:
```bash
vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
```
If the implementation introduces dedicated new files, narrow the pack further to the new subject-resolution, compare, capture, and development-cleanup tests.
Format touched files before final review:
```bash
vendor/bin/sail bin pint --dirty --format agent
```
## Manual Verification Flow
1. Ensure a tenant has fresh inventory data for at least one policy-backed type and one baseline-supported foundation type.
2. Trigger or locate a baseline capture run and a baseline compare run for that tenant and profile.
3. Open the canonical run detail at `/admin/operations/{run}`.
4. Confirm the page distinguishes:
- structural cases
- operational or missing-local-data cases
- transient retryable cases
5. Confirm inventory-only foundation subjects no longer surface as a new generic `policy_not_found` gap.
6. Confirm policy-backed missing-local-record cases still surface as an operational missing-record outcome.
## Development Cleanup Verification
1. Remove or invalidate old local compare or capture runs that still contain broad legacy gap reasons.
Dry-run:
```bash
vendor/bin/sail artisan tenantpilot:baselines:purge-legacy-gap-runs
```
Apply deletion:
```bash
vendor/bin/sail artisan tenantpilot:baselines:purge-legacy-gap-runs --force
```
2. Regenerate fresh runs under the new structured contract.
3. Confirm the product and targeted tests no longer depend on the broad legacy shape being preserved in runtime code.
## Runtime Capability Guard Verification
1. Configure or seed one baseline-supported type whose runtime resolver path is valid.
2. Configure or seed one type whose support claim would be structurally invalid without the new guard.
3. Start compare or capture preparation.
4. Confirm the valid type enters execution with a truthful path.
5. Confirm the invalid type is limited, excluded, or explicitly classified as invalid support configuration before misleading gap output is produced.
## Determinism Verification
1. Run the same compare scenario twice against unchanged tenant-local data.
2. Confirm both runs persist the same `subject_class`, `resolution_outcome`, and `operator_action_category` values for the same subject.
## Render-Safety Verification
1. Bind the fail-hard Graph client in affected UI tests.
2. Verify canonical run detail and tenant baseline compare landing render without triggering Graph calls.
3. Verify the richer semantics are derived solely from persisted run context and local metadata.
## Deployment Notes
- No new panel provider is required; Laravel 12 continues to register providers in `bootstrap/providers.php`.
- Filament remains on Livewire v4-compatible patterns and does not require view publishing.
- No new shared or panel assets are required, so this slice adds no new `filament:assets` deployment step beyond the existing deployment baseline.
- Existing compare and capture operations remain on current `OperationRun` types and notification behavior.

View File

@ -0,0 +1,65 @@
# Research: Baseline Subject Resolution and Evidence Gap Semantics Foundation
## Decision 1: Introduce a backend subject-resolution contract instead of UI-only relabeling
- Decision: Add an explicit backend resolution layer that classifies each compare or capture subject before lookup and returns a structured resolution outcome.
- Rationale: The current failure is rooted in the resolver path itself. `BaselineContentCapturePhase` still assumes every subject can be resolved via `Policy`, while compare scope already admits foundation types through inventory metadata. Renaming `policy_not_found` in the UI would preserve the wrong source-of-truth contract.
- Alternatives considered:
- UI-only copy fixes: rejected because they would leave incorrect persisted semantics and non-deterministic operator meaning.
- Per-surface one-off translation rules: rejected because compare, capture, and run detail would drift semantically.
## Decision 2: Persist both `subject_class` and `resolution_outcome`
- Decision: Store both the business class of the subject and the result of resolving it.
- Rationale: `subject_class` answers what kind of object the system is dealing with, while `resolution_outcome` answers what actually happened. A single field would either blur object identity or overload root-cause meaning.
- Alternatives considered:
- Only store `resolution_outcome`: rejected because operators and future renderer work still need to know whether the target was policy-backed, inventory-backed, foundation-backed, or derived.
- Only store `subject_class`: rejected because class alone cannot distinguish resolved, missing-local-record, throttled, or structurally unsupported states.
## Decision 3: Keep inventory-only foundation subjects in scope only when the runtime can truthfully classify them
- Decision: Inventory-only foundation subjects may remain compare or capture eligible only when the runtime explicitly supports an inventory-backed or limited-capability path for them.
- Rationale: The product already includes supported foundations in baseline scope via `InventoryPolicyTypeMeta::baselineSupportedFoundations()` and `BaselineScope::allTypes()`. Removing them wholesale would hide legitimate support cases. Allowing them in scope without a truthful path produces predictable false alarms.
- Alternatives considered:
- Remove all inventory-only foundations from compare and capture: rejected because it would throw away potentially valid baseline support.
- Keep all supported foundations in scope and tolerate broad `policy_not_found`: rejected because it preserves the current trust problem.
## Decision 4: Add a runtime consistency guard before compare and capture execution
- Decision: Add a deterministic support-capability guard in scope or service preparation that validates each supported type against an actual resolution path before compare or capture runs.
- Rationale: The specs core “config must not overpromise” requirement is best enforced before job execution. This prevents structurally invalid types from silently entering a run and only failing later as misleading gaps.
- Alternatives considered:
- Validate only after gap generation: rejected because it still emits misleading runtime states.
- Validate only in configuration review or documentation: rejected because runtime truth must not depend on manual discipline.
## Decision 5: Preserve transient and already-precise operational reasons as distinct outcomes
- Decision: Keep `throttled`, `capture_failed`, `budget_exhausted`, `ambiguous_match`, and related precise reasons intact, while adding new structural and missing-local-record outcomes beside them.
- Rationale: These reasons already carry actionable meaning and should not be re-modeled into a coarse structural taxonomy. The new foundation is about separating root-cause families, not flattening them.
- Alternatives considered:
- Replace the entire existing reason vocabulary: rejected because it would cause unnecessary churn and regress already-useful operator semantics.
- Collapse transient reasons into one retryable bucket: rejected because rate limiting, capture errors, and budget exhaustion still imply different remediation paths.
## Decision 6: Prefer development cleanup over legacy compatibility
- Decision: Newly created runs write the richer structured shape immediately, and obsolete development-only run payloads may be deleted or regenerated instead of preserved through a compatibility parser.
- Rationale: The repository is still in development, so preserving broad historical reason codes would keep ambiguous semantics alive in the runtime model for no real product benefit. Rebuilding local data and fixtures is cheaper and cleaner than carrying a long-term compatibility path.
- Alternatives considered:
- Keep a compatibility parser for old run shapes: rejected because it would preserve the old semantic contract in code paths that should move to the new model immediately.
- Backfill old runs with inferred outcomes: rejected because the original resolver context is incomplete and inference would still be unreliable.
## Decision 7: Reuse `OperationRun.context` as the canonical persistence boundary
- Decision: Store the richer gap semantics inside existing compare and capture run context rather than creating a new relational evidence-gap table.
- Rationale: Compare and capture results are already run-scoped, immutable operational artifacts. Monitoring and tenant review surfaces must stay DB-only at render time. Extending the existing run context keeps the persistence boundary aligned with execution truth.
- Alternatives considered:
- New evidence-gap relational tables: rejected because they add mutable join complexity for a run-bounded artifact.
- On-demand recomputation from current inventory and policy state: rejected because current state can drift away from the runs original truth.
## Decision 8: Upgrade existing surfaces instead of adding a new operator page
- Decision: Surface the richer semantics on existing canonical run-detail, tenant baseline compare landing, and related evidence-gap detail surfaces.
- Rationale: The feature is about truthfulness of semantics, not information architecture expansion. Existing surfaces already have the right operator entry points.
- Alternatives considered:
- Add a dedicated resolver diagnostics page: rejected because it would make core trust semantics secondary and harder to discover.
- Keep structured semantics backend-only: rejected because the operator value comes from clearer action guidance on current pages.

View File

@ -0,0 +1,174 @@
# Feature Specification: Baseline Subject Resolution and Evidence Gap Semantics Foundation
**Feature Branch**: `163-baseline-subject-resolution`
**Created**: 2026-03-24
**Status**: Draft
**Input**: User description: "Spec 163 — Baseline Subject Resolution & Evidence Gap Semantics Foundation"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant, canonical-view
- **Primary Routes**: `/admin/operations/{run}`, `/admin/t/{tenant}/baseline-compare-landing`, and existing baseline compare and baseline capture entry points that surface evidence-gap meaning
- **Data Ownership**: Tenant-owned local evidence records, captured baseline comparison results, and operation-run context remain the operational source of truth for resolution outcomes. Workspace-owned baseline support metadata remains the source of support promises and subject-class expectations.
- **RBAC**: Existing workspace membership, tenant entitlement, and baseline compare or monitoring view permissions remain authoritative. This feature does not introduce new roles or broaden visibility.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Canonical monitoring surfaces must continue to respect active tenant context in navigation and related links, while direct run-detail access remains explicit to the run's tenant and must not silently widen visibility.
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical run-detail and tenant-scoped compare review surfaces must continue to enforce workspace entitlement first and tenant entitlement second, with deny-as-not-found behavior for non-members and no cross-tenant hinting through resolution outcomes or evidence-gap details.
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Monitoring → baseline compare or capture run detail | Workspace manager or entitled tenant operator | Canonical detail | Is this gap structural, operational, or transient, and what action should I take next? | Resolution meaning, subject class, operator-safe next step, whether retry or sync is relevant, whether the issue is a product limitation or tenant-local data issue | Raw context payloads and low-level diagnostic fragments | execution outcome, evidence completeness, root-cause class, actionability | Simulation only for compare interpretation, TenantPilot only for rendering and classification persistence | View run, inspect gap meaning, navigate to related tenant review surfaces | None |
| Tenant baseline compare landing and related review surfaces | Tenant operator | Tenant-scoped review surface | Can I trust this compare result, and what exactly is missing or mismatched locally? | Structural versus operational meaning, subject class, local evidence expectation, next-step guidance, compare support limitations | Raw stored context and secondary technical diagnostics | compare trust, data completeness, root-cause class, actionability | Simulation only | Compare now, inspect latest run, review evidence-gap meaning | None |
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Distinguish structural from missing-local-data gaps (Priority: P1)
An operator reviewing a baseline compare or capture result needs the product to tell them whether the gap is structurally expected for that subject class or whether a policy or inventory record is actually missing locally.
**Why this priority**: This is the core trust problem. If structural limits and missing-local-data cases are collapsed into one generic reason, operators take the wrong follow-up action and lose confidence in the product.
**Independent Test**: Run compare and capture flows that include both policy-backed and foundation-backed subjects, then verify that the resulting gaps clearly separate structural resolver limits from missing local records without relying on raw diagnostics.
**Acceptance Scenarios**:
1. **Given** a baseline-supported subject that is inventory-backed but not policy-backed, **When** a new compare or capture run evaluates it, **Then** the run records a structural foundation or inventory outcome instead of a generic policy-not-found meaning.
2. **Given** a policy-backed subject with no local policy record, **When** the same flow evaluates it, **Then** the run records an operational missing-local-data outcome that is distinct from structural subject-class limits.
---
### User Story 2 - Keep support promises truthful at runtime (Priority: P2)
A product owner or operator needs baseline-supported subject types to enter compare or capture only when the runtime can classify and resolve them truthfully, so support configuration does not overpromise capabilities the resolver cannot deliver.
**Why this priority**: False support promises create predictable false alarms and make baseline support metadata untrustworthy.
**Independent Test**: Evaluate supported subject types against current resolver capability and verify that each type either enters the run with a valid resolution path and meaningful outcome set or is explicitly limited or excluded before misleading gaps are produced.
**Acceptance Scenarios**:
1. **Given** a subject type marked as baseline-supported, **When** the runtime has no truthful resolution path for that subject class, **Then** the type is either explicitly limited, explicitly excluded, or classified through a non-policy path instead of silently producing a generic missing-policy signal.
2. **Given** a subject type with a valid resolution path, **When** the run evaluates it, **Then** the stored outcome reflects the correct subject class and local evidence model.
---
### User Story 3 - Replace dev-era broad reasons with the new contract cleanly (Priority: P3)
A developer or operator needs the repository to move to the new structured gap contract without carrying obsolete development-only run payloads forward just for compatibility.
**Why this priority**: Staying in a mixed old-and-new state during development would preserve ambiguity in exactly the area this feature is trying to fix.
**Independent Test**: Remove or regenerate old development runs, create a new run under the updated contract, and verify that the existing surfaces expose subject class, resolution meaning, and action category without fallback to the old broad reason contract.
**Acceptance Scenarios**:
1. **Given** development-only runs that use the old broad reason shape, **When** the team chooses to clean or regenerate them, **Then** the product does not require runtime preservation of the obsolete shape to proceed.
2. **Given** a new run created after this foundation is implemented, **When** an operator opens the existing detail surfaces, **Then** the run exposes subject class, resolution meaning, and action category without requiring a new screen.
### Edge Cases
- A run contains a mix of policy-backed, foundation-backed, inventory-backed, and derived subjects. Each subject must keep its own resolution meaning instead of being normalized into one broad reason bucket.
- A subject is supported in configuration but currently lacks a truthful runtime resolution path. The system must not silently enter the subject into compare or capture as if the path were valid.
- A transient upstream or budget-related failure occurs for one subject while another subject in the same run is structurally not policy-backed. The surface must keep transient and structural meaning distinct.
- Development data may still contain obsolete broad-reason payloads during rollout. The team may remove or regenerate those runs instead of extending the runtime contract to support them indefinitely.
- Two identical subjects evaluated against the same tenant-local state at different points in the same release must produce the same resolution outcome and operator meaning.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not add new external provider calls or a new long-running operation type. It establishes a stricter semantic contract for how existing baseline compare and capture workflows classify subjects, persist evidence-gap meaning, and describe operator action truth. Existing tenant isolation, preview, and audit expectations remain in force.
**Constitution alignment (OPS-UX):** Existing compare and capture runs continue to use the established three-surface feedback contract. Run status and outcome remain service-owned. Summary counts remain numeric and lifecycle-safe. This feature extends the semantic detail stored in run context so evidence-gap meaning is deterministic, reproducible, and available on progress and terminal surfaces without redefining run lifecycle ownership.
**Constitution alignment (RBAC-UX):** This feature changes what existing entitled users can understand on run-detail and tenant review surfaces, not who may access those surfaces. Non-members remain deny-as-not-found. Members who lack the relevant capability remain forbidden only after entitlement is established. No cross-tenant visibility or capability broadening is introduced.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that monitoring and operations surfaces continue to avoid synchronous auth-handshake behavior.
**Constitution alignment (BADGE-001):** If any status-like labels are refined for evidence-gap meaning, the semantic mapping must remain centralized and shared across dense and detailed surfaces. This feature must not create ad hoc surface-specific meanings for structural, operational, or transient states.
**Constitution alignment (UI-NAMING-001):** Operator-facing wording must describe the object class and root cause before advice. Labels should use domain language such as “Policy record missing locally”, “Inventory-backed foundation subject”, or “Retry may help”, and avoid implementation-first phrasing.
**Constitution alignment (OPSURF-001):** Existing run detail and tenant review surfaces remain operator-first. Default-visible content must answer whether the issue is structural, operational, or transient before exposing raw diagnostics. Mutation scope messaging for existing compare actions remains unchanged.
**Constitution alignment (Filament Action Surfaces):** The affected Filament pages remain compliant with the Action Surface Contract. No new destructive actions are introduced. Existing compare or review actions remain read or inspect oriented, and this feature changes interpretation rather than mutation behavior.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature reuses existing layouts and surfaces. The required change is semantic clarity, not a new layout pattern. Existing sections and detail affordances must present the new meaning without introducing naked diagnostics or a parallel screen.
### Functional Requirements
- **FR-001**: The system MUST determine the subject class for every compare or capture subject before attempting local resolution.
- **FR-002**: The system MUST support, at minimum, policy-backed, inventory-backed, foundation-backed, and derived subject classes for new runs.
- **FR-003**: The system MUST choose the local resolution strategy from the subject class and supported capability contract, rather than implicitly treating every in-scope subject as policy-backed.
- **FR-004**: The system MUST distinguish policy-backed missing-local-record cases from structural foundation or inventory-only cases in new run outputs.
- **FR-005**: The system MUST support a precise evidence-gap reason taxonomy for new runs that can separately represent missing policy records, missing inventory records, structural non-policy-backed subjects, resolution mismatches, invalid or duplicate subjects, transient capture failures, ambiguity, and budget or throttling limits.
- **FR-006**: The system MUST persist structured gap metadata for new runs that includes subject class, resolution meaning, and operator action category, rather than relying only on a broad reason code and a raw subject key.
- **FR-007**: The system MUST provide an explicit resolution outcome for each evaluated subject, including successful resolution path, structural limitation, missing local artifact, or transient failure as applicable.
- **FR-008**: The system MUST prevent baseline support metadata from overpromising compare or capture capability when no truthful runtime resolution path exists for that subject class.
- **FR-009**: The system MUST classify new gaps so operators can tell whether retry, backup or sync, or product follow-up is the correct next action.
- **FR-010**: The system MUST NOT persist the historical broad policy-not-found reason as the sole reason for newly created structural cases that have a more precise semantic classification.
- **FR-011**: During development, the system MAY invalidate or discard previously stored run payloads that only contain the broad legacy reason if that simplifies migration to the new structured contract.
- **FR-012**: The system MUST preserve already precise reason families, including transient and ambiguity-related cases, without collapsing them into the new structural taxonomy.
- **FR-013**: The system MUST keep the semantic meaning aligned across dense landing surfaces and richer detail surfaces so the same run does not communicate different root causes on different pages.
- **FR-014**: The system MUST derive resolution meaning on the backend so run context, auditability, and diagnostic replay do not depend on UI-only interpretation.
- **FR-015**: The system MUST produce the same resolution outcome and operator-facing meaning for the same subject and tenant-local state whenever the input conditions are unchanged.
- **FR-016**: The system MUST allow inventory-backed or foundation-backed supported subjects to remain in scope only when their compare or capture behavior can be described truthfully through the resolution contract.
### Assumptions
- Foundation-backed subjects remain eligible for compare or capture only when the product can truthfully classify them through an inventory-backed or limited non-policy resolution path. Otherwise they are treated as explicitly unsupported for that operation rather than as generic missing-policy cases.
- Subject class and resolution outcome are both required because they answer different operator questions: what kind of object is this, and what happened when the system tried to resolve it.
- The repository is still in active development, so breaking cleanup of previously stored development run payloads is acceptable when it removes obsolete broad-reason semantics instead of preserving them.
- Newly created runs are expected to use the new structured contract immediately; there is no requirement to keep the old broad reason shape alive for future writes.
- This foundation spec establishes root-cause truth and runtime support truth. Fidelity richness, renderer density, and deeper wording refinements are handled in follow-on work.
### Deferred Scope
- New renderer families, fidelity badges, or snapshot richness redesign are not included in this feature.
- This feature does not redefine content diff algorithms, reporting exports, or large historical data backfills.
- This feature does not require a new operator screen. It upgrades semantic truth on existing surfaces.
- This feature does not preserve historical development run payloads only for compatibility's sake.
- This feature does not create dual-read or dual-write architecture for old and new gap semantics unless a concrete development need emerges later.
- New downstream domain behavior, including new findings, alerts, or follow-on automation, must be designed around the new structured contract rather than the old broad reason.
## Development Migration Policy
- **Breaking Cleanup Is Acceptable**: Existing development-only compare and capture runs MAY be deleted, regenerated, or rendered invalid if that removes obsolete broad-reason semantics and keeps the runtime model cleaner.
- **Single Contract Going Forward**: Newly created runs MUST write the new structured resolution and gap contract only.
- **No Parallel Semantic Core**: The old broad reason MAY be recognized temporarily in one-off development utilities or cleanup scripts, but it MUST NOT remain a first-class domain contract for ongoing feature work.
- **Regenerate Over Preserve**: When tests, fixtures, or local demo data depend on the old shape, the preferred path is to rebuild them against the new contract instead of extending production code to preserve the obsolete structure.
## 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 compare or capture run detail | Existing canonical run-detail surface | Existing navigation and refresh actions remain | Existing detail and diagnostic sections remain the inspect affordance | None added | None | No new CTA. Empty states explain whether the run has no gaps or whether development data must be regenerated under the new contract. | Existing run-detail header actions remain | Not applicable | Existing run-backed audit semantics remain | Read-only semantic upgrade; no new mutation surface |
| Tenant baseline compare landing and related review surfaces | Existing tenant-scoped review surfaces | Existing compare and navigation actions remain | Existing summary and detail sections remain the inspect affordance | None added | None | Existing compare CTA remains; no new dangerous action is introduced | Existing page-level actions remain | Not applicable | Existing run-backed audit semantics remain | Read-only semantic upgrade; same actions, clearer meaning |
### Key Entities *(include if feature involves data)*
- **Subject**: A compare or capture target that the product must classify and resolve against tenant-local evidence before it can judge trust or completeness.
- **Subject Class**: The business-level class that describes whether a subject is policy-backed, inventory-backed, foundation-backed, or derived.
- **Resolution Outcome**: The deterministic result of attempting to resolve a subject locally, including both successful resolution paths and precise failure or limitation meanings.
- **Evidence Gap Detail**: The structured record attached to a run that captures which subject was affected, how it was classified, what local evidence expectation applied, and which operator action category follows.
- **Support Capability Contract**: The support promise that states whether a subject type may enter compare or capture and through which truthful resolution path.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In validation runs containing both structural and missing-local-data cases, operators can distinguish the two classes from the default-visible surface without opening raw diagnostics in 100% of sampled review sessions.
- **SC-002**: For every supported subject type included in a release validation pack, the runtime either produces a truthful resolution path or classifies the type as explicitly limited or unsupported before misleading broad-cause gaps are emitted.
- **SC-003**: Local development data, tests, and fixtures can be regenerated against the new structured contract without requiring production code to preserve the obsolete broad-reason payload shape.
- **SC-004**: New runs expose enough structured gap metadata that operators can determine whether retry, backup or sync, or product follow-up is the next action in a single page visit.
- **SC-005**: Replaying the same subject against the same tenant-local state yields the same stored resolution outcome and operator action category across repeated validation runs.
## Definition of Done
- Newly created compare and capture runs persist the new structured resolution contract and do not rely on the broad legacy reason as their primary semantic output.
- Development fixtures, local data, and tests that depended on the old broad reason shape are either regenerated or intentionally removed instead of forcing the runtime to preserve obsolete semantics.
- New domain logic introduced for this feature uses subject class, resolution outcome, and structured gap metadata as the source of truth instead of branching on the legacy broad reason.
- Structural, operational, and transient cases are distinguishable in backend persistence and in operator-facing interpretation.
- Baseline-supported subject types do not enter the runtime path with a silent structural resolver mismatch.

View File

@ -0,0 +1,194 @@
# Tasks: Baseline Subject Resolution and Evidence Gap Semantics Foundation
**Input**: Design documents from `/specs/163-baseline-subject-resolution/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/openapi.yaml`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature because it changes runtime behavior in compare, capture, and operator surfaces.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently where practical.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Create shared test support and optional cleanup-command scaffolding needed by later story work.
- [X] T001 Scaffold the development-only cleanup command entry point in `app/Console/Commands/PurgeLegacyBaselineGapRuns.php`
- [X] T002 [P] Create shared compare and capture fixture builders for subject-resolution scenarios in `tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php`
- [X] T003 [P] Create shared assertion helpers for structured gap payloads in `tests/Feature/Baselines/Support/AssertsStructuredBaselineGaps.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Build the shared subject-resolution contract and runtime capability foundation before user story work begins.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 [P] Create subject-resolution enums in `app/Support/Baselines/SubjectClass.php`, `app/Support/Baselines/ResolutionPath.php`, `app/Support/Baselines/ResolutionOutcome.php`, and `app/Support/Baselines/OperatorActionCategory.php`
- [X] T005 [P] Create subject-resolution value objects in `app/Support/Baselines/SubjectDescriptor.php`, `app/Support/Baselines/ResolutionOutcomeRecord.php`, and `app/Support/Baselines/SupportCapabilityRecord.php`
- [X] T006 Implement the shared resolver and capability services in `app/Support/Baselines/SubjectResolver.php` and `app/Support/Baselines/BaselineSupportCapabilityGuard.php`
- [X] T007 Update metadata derivation for subject classes and support capability in `app/Support/Inventory/InventoryPolicyTypeMeta.php` and `config/tenantpilot.php`
- [X] T008 Update shared scope and service wiring for the new resolver contract in `app/Support/Baselines/BaselineScope.php`, `app/Services/Baselines/BaselineCompareService.php`, and `app/Services/Baselines/BaselineCaptureService.php`
- [X] T009 [P] Add foundational unit coverage for enums, metadata rules, and resolver behavior in `tests/Unit/Support/Baselines/SubjectResolverTest.php` and `tests/Unit/Support/Inventory/InventoryPolicyTypeMetaResolutionContractTest.php`
**Checkpoint**: Subject-resolution foundation and runtime capability guard are ready.
---
## Phase 3: User Story 1 - Distinguish structural from missing-local-data gaps (Priority: P1) 🎯 MVP
**Goal**: Ensure compare and capture can tell structural inventory or foundation limitations apart from missing local policy or inventory records.
**Independent Test**: Run compare and capture flows that include both policy-backed and foundation-backed subjects and verify the resulting gaps separate structural resolver limits from missing local records without relying on raw diagnostics.
### Tests for User Story 1
- [X] T010 [P] [US1] Add compare gap classification and ambiguity-preservation coverage for structural versus missing-local-data cases in `tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`
- [X] T011 [P] [US1] Add capture gap classification coverage for policy-backed and foundation-backed subjects in `tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php`
- [X] T012 [P] [US1] Add deterministic replay coverage for unchanged compare and capture inputs in `tests/Feature/Baselines/BaselineResolutionDeterminismTest.php`
### Implementation for User Story 1
- [X] T013 [US1] Refactor subject lookup and outcome emission in `app/Services/Baselines/BaselineContentCapturePhase.php` to use `SubjectResolver` instead of raw policy-only lookup
- [X] T014 [US1] Update compare-side subject persistence and deterministic subject keys in `app/Jobs/CompareBaselineToTenantJob.php` and `app/Support/Baselines/BaselineSubjectKey.php`
- [X] T015 [US1] Replace broad gap taxonomy handling with structured structural versus operational semantics while preserving ambiguity-related reason families in `app/Support/Baselines/BaselineCompareReasonCode.php` and `app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`
- [X] T016 [US1] Update compare summary aggregation for structural, operational, and transient counts in `app/Support/Baselines/BaselineCompareStats.php`
**Checkpoint**: User Story 1 is functional and testable on its own.
---
## Phase 4: User Story 2 - Keep support promises truthful at runtime (Priority: P2)
**Goal**: Prevent baseline-supported types from entering compare or capture on a resolver path that cannot classify them truthfully.
**Independent Test**: Evaluate supported subject types against runtime resolver capability and verify each type either enters execution with a valid path and meaningful outcome set or is limited or excluded before misleading gaps are produced.
### Tests for User Story 2
- [X] T017 [P] [US2] Add feature coverage for runtime support-capability guard decisions in `tests/Feature/Baselines/BaselineSupportCapabilityGuardTest.php`
- [X] T018 [P] [US2] Add unit coverage for subject-class and support-mode derivation in `tests/Unit/Support/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php`
### Implementation for User Story 2
- [X] T019 [US2] Extend baseline support metadata with subject-class and capability truth in `config/tenantpilot.php` and `app/Support/Inventory/InventoryPolicyTypeMeta.php`
- [X] T020 [US2] Enforce capability guard decisions before compare execution in `app/Support/Baselines/BaselineScope.php` and `app/Services/Baselines/BaselineCompareService.php`
- [X] T021 [US2] Enforce the same capability guard before capture execution in `app/Services/Baselines/BaselineCaptureService.php` and `app/Jobs/CaptureBaselineSnapshotJob.php`
- [X] T022 [US2] Persist operator-safe capability and support outcomes in `app/Jobs/CompareBaselineToTenantJob.php` and `app/Services/Baselines/BaselineContentCapturePhase.php`
**Checkpoint**: User Story 2 is functional and testable on its own.
---
## Phase 5: User Story 3 - Replace dev-era broad reasons with the new contract cleanly (Priority: P3)
**Goal**: Move existing operator surfaces, tests, and development fixtures to the new structured gap contract without preserving the old broad reason shape in runtime code.
**Independent Test**: Remove or regenerate old development runs, create a new run under the updated contract, and verify the existing surfaces expose subject class, resolution meaning, and action category without fallback to the old broad reason contract.
### Tests for User Story 3
- [X] T023 [P] [US3] Add canonical run-detail regression coverage for structured gap semantics in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T024 [P] [US3] Add tenant landing regression coverage for structured gap semantics in `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
- [X] T025 [P] [US3] Add DB-only render regression coverage for gap surfaces in `tests/Feature/Filament/BaselineGapSurfacesDbOnlyRenderTest.php`
- [X] T026 [P] [US3] Add development cleanup and regeneration coverage for stale run payloads in `tests/Feature/Baselines/BaselineGapContractCleanupTest.php`
### Implementation for User Story 3
- [X] T027 [US3] Update run-detail semantics for structured gap records in `app/Filament/Resources/OperationRunResource.php` and `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
- [X] T028 [US3] Update tenant landing semantics for structured gap records in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
- [X] T029 [US3] Implement the cleanup command logic and run-selection criteria in `app/Console/Commands/PurgeLegacyBaselineGapRuns.php` and `app/Models/OperationRun.php`
- [X] T030 [US3] Remove broad-reason dev fixture usage and regenerate structured payload fixtures in `tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php`, `tests/Feature/Baselines`, and `tests/Feature/Filament`
- [X] T031 [US3] Finalize projection states and empty-state semantics for development cleanup in `app/Support/Baselines/BaselineCompareEvidenceGapDetails.php` and `app/Support/Baselines/BaselineCompareStats.php`
**Checkpoint**: User Story 3 is functional and testable on its own.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final consistency, documentation alignment, and focused validation across all stories.
- [X] T032 [P] Document the implemented cleanup command and final contract examples in `specs/163-baseline-subject-resolution/contracts/openapi.yaml`, `specs/163-baseline-subject-resolution/data-model.md`, and `specs/163-baseline-subject-resolution/quickstart.md`
- [X] T033 Run the focused validation pack and the cleanup command flow documented in `specs/163-baseline-subject-resolution/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: T002 and T003 start immediately; T001 is optional early scaffolding for US3 and does not block semantic work.
- **Foundational (Phase 2)**: Depends on shared support where needed for tests, but is not blocked by T001 cleanup-command scaffolding; blocks all user stories once started.
- **User Story 1 (Phase 3)**: Depends on Foundational completion; delivers the MVP semantic contract.
- **User Story 2 (Phase 4)**: Depends on Foundational completion; can proceed after Phase 2 and integrate with US1 outputs.
- **User Story 3 (Phase 5)**: Depends on US1 structured gap contract and benefits from US2 capability guard outputs.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: Starts after Phase 2; no dependency on later stories.
- **US2 (P2)**: Starts after Phase 2; shares foundational components but remains independently testable.
- **US3 (P3)**: Starts after US1 because surface adoption depends on the new structured gap payload; it consumes T001 if the cleanup command scaffold was created early.
### Within Each User Story
- Tests must be written and fail before the implementation tasks they cover.
- Resolver or metadata changes must land before surface or projection updates that consume them.
- Story-level verification must pass before moving to the next dependent story.
### Parallel Opportunities
- T002 and T003 can run in parallel.
- T004 and T005 can run in parallel.
- T009 can run in parallel with the end of T006 through T008 once the foundational interfaces stabilize.
- T010, T011, and T012 can run in parallel.
- T017 and T018 can run in parallel.
- T023, T024, T025, and T026 can run in parallel.
- T032 can run in parallel with final validation prep once implementation stabilizes.
---
## Parallel Example: User Story 1
```bash
# Launch the independent US1 tests together:
Task: "Add compare gap classification coverage in tests/Feature/Baselines/BaselineCompareGapClassificationTest.php"
Task: "Add capture gap classification coverage in tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php"
```
## Parallel Example: User Story 2
```bash
# Launch the independent US2 tests together:
Task: "Add feature coverage for runtime support-capability guard decisions in tests/Feature/Baselines/BaselineSupportCapabilityGuardTest.php"
Task: "Add unit coverage for subject-class and support-mode derivation in tests/Unit/Support/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php"
```
## Parallel Example: User Story 3
```bash
# Launch the independent US3 regression tests together:
Task: "Add canonical run-detail regression coverage in tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php"
Task: "Add tenant landing regression coverage in tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php"
Task: "Add DB-only render regression coverage in tests/Feature/Filament/BaselineGapSurfacesDbOnlyRenderTest.php"
Task: "Add development cleanup and regeneration coverage in tests/Feature/Baselines/BaselineGapContractCleanupTest.php"
```
---
## Implementation Strategy
### MVP First
Deliver Phase 3 (US1) first after the foundational phase. That provides the core semantic win: structural versus missing-local-data gaps become distinguishable in persisted run context.
### Incremental Delivery
1. Finish Setup and Foundational phases.
2. Deliver US1 to establish the new structured resolution and gap contract.
3. Deliver US2 to stop support metadata from overpromising runtime capability.
4. Deliver US3 to move existing surfaces and development fixtures fully onto the new contract.
5. Finish with Polish to align the design docs and validation steps with the implemented behavior.
### Suggested MVP Scope
US1 only is the smallest valuable slice. It fixes the primary trust problem and creates the contract that US2 and US3 build on.

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Models\BaselineProfile;
use App\Models\InventoryItem;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\OperationRunType;
use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps;
it('classifies structural foundation capture gaps separately from missing local policy records', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 50);
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: [
'deviceConfiguration' => 'succeeded',
'roleScopeTag' => 'succeeded',
],
foundationTypes: ['roleScopeTag'],
);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-missing-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Missing Policy Subject',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'etag-policy-missing'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'scope-tag-1',
'policy_type' => 'roleScopeTag',
'display_name' => 'Structural Foundation Subject',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.roleScopeTag', 'etag' => 'etag-scope-tag'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$run = app(OperationRunService::class)->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' => ['roleScopeTag'],
'truthful_types' => ['deviceConfiguration', 'roleScopeTag'],
],
'capture_mode' => BaselineCaptureMode::FullContent->value,
],
initiator: $user,
);
(new CaptureBaselineSnapshotJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
$run->refresh();
expect(data_get($run->context, 'baseline_capture.gaps.by_reason.policy_not_found'))->toBeNull()
->and(data_get($run->context, 'baseline_capture.gaps.by_reason.policy_record_missing'))->toBe(1)
->and(data_get($run->context, 'baseline_capture.gaps.by_reason.foundation_not_policy_backed'))->toBe(1);
$subjects = data_get($run->context, 'baseline_capture.gaps.subjects');
expect($subjects)->toBeArray();
AssertsStructuredBaselineGaps::assertStructuredSubjects($subjects);
$subjectsByType = collect($subjects)->keyBy('policy_type');
expect(data_get($subjectsByType['deviceConfiguration'], 'subject_class'))->toBe('policy_backed')
->and(data_get($subjectsByType['deviceConfiguration'], 'resolution_outcome'))->toBe('policy_record_missing')
->and(data_get($subjectsByType['deviceConfiguration'], 'reason_code'))->toBe('policy_record_missing')
->and(data_get($subjectsByType['deviceConfiguration'], 'structural'))->toBeFalse();
expect(data_get($subjectsByType['roleScopeTag'], 'subject_class'))->toBe('foundation_backed')
->and(data_get($subjectsByType['roleScopeTag'], 'resolution_outcome'))->toBe('foundation_inventory_only')
->and(data_get($subjectsByType['roleScopeTag'], 'reason_code'))->toBe('foundation_not_policy_backed')
->and(data_get($subjectsByType['roleScopeTag'], 'structural'))->toBeTrue();
});

View File

@ -12,6 +12,7 @@
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps;
it('treats duplicate subject_key matches as an evidence gap and suppresses findings', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -114,4 +115,17 @@
$context = is_array($run->context) ? $run->context : [];
expect(data_get($context, 'baseline_compare.evidence_gaps.by_reason.ambiguous_match'))->toBe(1);
$gapSubjects = data_get($context, 'baseline_compare.evidence_gaps.subjects');
expect($gapSubjects)->toBeArray();
AssertsStructuredBaselineGaps::assertStructuredSubjects($gapSubjects);
$ambiguousSubject = collect($gapSubjects)->firstWhere('reason_code', 'ambiguous_match');
expect($ambiguousSubject)->toBeArray()
->and(data_get($ambiguousSubject, 'policy_type'))->toBe('deviceConfiguration')
->and(data_get($ambiguousSubject, 'subject_class'))->toBe('policy_backed')
->and(data_get($ambiguousSubject, 'resolution_outcome'))->toBe('ambiguous_match')
->and(data_get($ambiguousSubject, 'operator_action_category'))->toBe('inspect_subject_mapping')
->and(data_get($ambiguousSubject, 'subject_key'))->toContain('duplicate policy');
});

View File

@ -172,4 +172,6 @@ public function capture(
expect($completedMeta)->toHaveKey('subjects_total');
expect($completedMeta)->toHaveKey('evidence_capture');
expect($completedMeta)->toHaveKey('gaps');
expect($completedMeta)->not->toHaveKey('gap_subjects');
expect(json_encode($completedMeta))->not->toContain('Audit Compare Policy');
});

View File

@ -0,0 +1,143 @@
<?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\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps;
it('classifies compare capture-path gaps as structural or missing-local-data without using policy_not_found', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 50);
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$policySubjectKey = BaselineSubjectKey::fromDisplayName('Missing Compare Policy');
$foundationSubjectKey = BaselineSubjectKey::fromDisplayName('Structural Compare Foundation');
expect($policySubjectKey)->not->toBeNull()
->and($foundationSubjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $policySubjectKey),
'subject_key' => (string) $policySubjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-policy'),
'meta_jsonb' => ['display_name' => 'Missing Compare Policy'],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('roleScopeTag', (string) $foundationSubjectKey),
'subject_key' => (string) $foundationSubjectKey,
'policy_type' => 'roleScopeTag',
'baseline_hash' => hash('sha256', 'baseline-foundation'),
'meta_jsonb' => ['display_name' => 'Structural Compare Foundation'],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: [
'deviceConfiguration' => 'succeeded',
'roleScopeTag' => 'succeeded',
],
foundationTypes: ['roleScopeTag'],
);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'compare-missing-policy',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Missing Compare Policy',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'etag-compare-policy'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'compare-scope-tag',
'policy_type' => 'roleScopeTag',
'display_name' => 'Structural Compare Foundation',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.roleScopeTag', 'etag' => 'etag-compare-foundation'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$run = app(OperationRunService::class)->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' => ['roleScopeTag'],
'truthful_types' => ['deviceConfiguration', 'roleScopeTag'],
],
'capture_mode' => BaselineCaptureMode::FullContent->value,
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
$run->refresh();
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBeNull()
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1)
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.foundation_not_policy_backed'))->toBe(1);
$subjects = data_get($run->context, 'baseline_compare.evidence_gaps.subjects');
expect($subjects)->toBeArray();
AssertsStructuredBaselineGaps::assertStructuredSubjects($subjects);
$subjectsByType = collect($subjects)->keyBy('policy_type');
expect(data_get($subjectsByType['deviceConfiguration'], 'resolution_outcome'))->toBe('policy_record_missing')
->and(data_get($subjectsByType['deviceConfiguration'], 'reason_code'))->toBe('policy_record_missing')
->and(data_get($subjectsByType['deviceConfiguration'], 'operator_action_category'))->toBe('run_policy_sync_or_backup');
expect(data_get($subjectsByType['roleScopeTag'], 'resolution_outcome'))->toBe('foundation_inventory_only')
->and(data_get($subjectsByType['roleScopeTag'], 'reason_code'))->toBe('foundation_not_policy_backed')
->and(data_get($subjectsByType['roleScopeTag'], 'operator_action_category'))->toBe('product_follow_up');
});

View File

@ -17,6 +17,7 @@
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps;
it('records a resume token when full-content compare cannot capture all subjects within budget', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
@ -170,3 +171,121 @@ public function capture(
expect($state)->toHaveKey('offset');
expect($state['offset'])->toBe(1);
});
it('stores capture-phase gap subjects for policy-record-missing evidence gaps', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 10);
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$displayName = 'Missing Capture Policy';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
expect($subjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey),
'subject_key' => (string) $subjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-missing-capture-policy'),
'meta_jsonb' => [
'display_name' => $displayName,
'evidence' => [
'fidelity' => 'content',
'source' => 'policy_version',
'observed_at' => now()->toIso8601String(),
],
],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'missing-capture-policy',
'policy_type' => 'deviceConfiguration',
'display_name' => $displayName,
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => fake()->uuid()],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$contentCapturePhase = new BaselineContentCapturePhase(new class extends PolicyCaptureOrchestrator
{
public function __construct() {}
public function capture(
Policy $policy,
\App\Models\Tenant $tenant,
bool $includeAssignments = false,
bool $includeScopeTags = false,
?string $createdBy = null,
array $metadata = [],
PolicyVersionCapturePurpose $capturePurpose = PolicyVersionCapturePurpose::Backup,
?int $operationRunId = null,
?int $baselineProfileId = null,
): array {
throw new \RuntimeException('Should not be called for a missing policy capture case.');
}
});
$opService = app(OperationRunService::class);
$run = $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) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
'capture_mode' => BaselineCaptureMode::FullContent->value,
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
contentCapturePhase: $contentCapturePhase,
);
$run->refresh();
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1);
$subjects = data_get($run->context, 'baseline_compare.evidence_gaps.subjects');
expect($subjects)->toBeArray();
AssertsStructuredBaselineGaps::assertStructuredSubjects($subjects);
$missingSubject = collect($subjects)->firstWhere('reason_code', 'policy_record_missing');
expect($missingSubject)->toBeArray()
->and(data_get($missingSubject, 'policy_type'))->toBe('deviceConfiguration')
->and(data_get($missingSubject, 'subject_key'))->toBe('missing capture policy')
->and(data_get($missingSubject, 'subject_external_id'))->toBe('missing-capture-policy')
->and(data_get($missingSubject, 'resolution_outcome'))->toBe('policy_record_missing')
->and(data_get($missingSubject, 'operator_action_category'))->toBe('run_policy_sync_or_backup');
});

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
uses(RefreshDatabase::class);
it('reports legacy compare and capture runs in dry-run mode without deleting them', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$legacyCompare = 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,
'context' => [
'baseline_compare' => [
'evidence_gaps' => [
'count' => 1,
'by_reason' => ['policy_not_found' => 1],
'subjects' => [
'policy_not_found' => [
'deviceConfiguration|legacy-policy-gap',
],
],
],
],
],
]);
$legacyCapture = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => [
'baseline_capture' => [
'gaps' => [
'count' => 1,
'by_reason' => ['policy_not_found' => 1],
'subjects' => [
'policy_not_found' => [
'deviceConfiguration|legacy-capture-gap',
],
],
],
],
],
]);
$structuredCompare = 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,
'context' => BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap(),
]),
]);
expect($legacyCompare->hasLegacyBaselineGapPayload())->toBeTrue()
->and($legacyCapture->hasLegacyBaselineGapPayload())->toBeTrue()
->and($structuredCompare->hasStructuredBaselineGapPayload())->toBeTrue()
->and($structuredCompare->hasLegacyBaselineGapPayload())->toBeFalse();
$this->artisan('tenantpilot:baselines:purge-legacy-gap-runs')
->expectsOutputToContain('Dry run: matched 2 legacy baseline run(s). Re-run with --force to delete them.')
->assertSuccessful();
expect(OperationRun::query()->whereKey($legacyCompare->getKey())->exists())->toBeTrue()
->and(OperationRun::query()->whereKey($legacyCapture->getKey())->exists())->toBeTrue()
->and(OperationRun::query()->whereKey($structuredCompare->getKey())->exists())->toBeTrue();
});
it('deletes only legacy baseline gap runs when forced', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$legacyCompare = 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,
'context' => [
'baseline_compare' => [
'evidence_gaps' => [
'count' => 1,
'by_reason' => ['policy_not_found' => 1],
'subjects' => [
'policy_not_found' => [
'deviceConfiguration|legacy-policy-gap',
],
],
],
],
],
]);
$structuredCompare = 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,
'context' => BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap(),
]),
]);
$structuredCapture = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => BaselineSubjectResolutionFixtures::captureContext([
BaselineSubjectResolutionFixtures::structuredGap(),
]),
]);
$this->artisan('tenantpilot:baselines:purge-legacy-gap-runs', ['--force' => true])
->expectsOutputToContain('Deleted 1 legacy baseline run(s).')
->assertSuccessful();
expect(OperationRun::query()->whereKey($legacyCompare->getKey())->exists())->toBeFalse()
->and(OperationRun::query()->whereKey($structuredCompare->getKey())->exists())->toBeTrue()
->and(OperationRun::query()->whereKey($structuredCapture->getKey())->exists())->toBeTrue();
});

View File

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
it('persists the same structured gap subjects for unchanged capture and compare inputs', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 50);
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$policySubjectKey = BaselineSubjectKey::fromDisplayName('Deterministic Missing Policy');
$foundationSubjectKey = BaselineSubjectKey::fromDisplayName('Deterministic Foundation');
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $policySubjectKey),
'subject_key' => (string) $policySubjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'deterministic-policy'),
'meta_jsonb' => ['display_name' => 'Deterministic Missing Policy'],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('roleScopeTag', (string) $foundationSubjectKey),
'subject_key' => (string) $foundationSubjectKey,
'policy_type' => 'roleScopeTag',
'baseline_hash' => hash('sha256', 'deterministic-foundation'),
'meta_jsonb' => ['display_name' => 'Deterministic Foundation'],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: [
'deviceConfiguration' => 'succeeded',
'roleScopeTag' => 'succeeded',
],
foundationTypes: ['roleScopeTag'],
);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'deterministic-policy',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Deterministic Missing Policy',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'etag-deterministic-policy'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'deterministic-foundation',
'policy_type' => 'roleScopeTag',
'display_name' => 'Deterministic Foundation',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.roleScopeTag', 'etag' => 'etag-deterministic-foundation'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$captureRunA = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
'truthful_types' => ['deviceConfiguration', 'roleScopeTag'],
],
'capture_mode' => BaselineCaptureMode::FullContent->value,
],
'user_id' => (int) $user->getKey(),
]);
$captureRunB = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => $captureRunA->context,
'user_id' => (int) $user->getKey(),
]);
(new CaptureBaselineSnapshotJob($captureRunA))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
(new CaptureBaselineSnapshotJob($captureRunB))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
$compareRunA = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
'truthful_types' => ['deviceConfiguration', 'roleScopeTag'],
],
'capture_mode' => BaselineCaptureMode::FullContent->value,
],
'user_id' => (int) $user->getKey(),
]);
$compareRunB = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => $compareRunA->context,
'user_id' => (int) $user->getKey(),
]);
(new CompareBaselineToTenantJob($compareRunA))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
(new CompareBaselineToTenantJob($compareRunB))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
$captureSubjectsA = collect(data_get($captureRunA->fresh()->context, 'baseline_capture.gaps.subjects', []))->sortBy('policy_type')->values()->all();
$captureSubjectsB = collect(data_get($captureRunB->fresh()->context, 'baseline_capture.gaps.subjects', []))->sortBy('policy_type')->values()->all();
$compareSubjectsA = collect(data_get($compareRunA->fresh()->context, 'baseline_compare.evidence_gaps.subjects', []))->sortBy('policy_type')->values()->all();
$compareSubjectsB = collect(data_get($compareRunB->fresh()->context, 'baseline_compare.evidence_gaps.subjects', []))->sortBy('policy_type')->values()->all();
expect($captureSubjectsA)->toBe($captureSubjectsB)
->and($compareSubjectsA)->toBe($compareSubjectsB);
});

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineCaptureMode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
uses(RefreshDatabase::class);
function appendBrokenFoundationSupportConfig(): void
{
$foundationTypes = is_array(config('tenantpilot.foundation_types')) ? config('tenantpilot.foundation_types') : [];
$foundationTypes[] = [
'type' => 'brokenFoundation',
'label' => 'Broken Foundation',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'external_id',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'inventory',
],
],
];
config()->set('tenantpilot.foundation_types', $foundationTypes);
}
it('persists truthful compare scope capability decisions before dispatching compare work', function (): void {
Bus::fake();
appendBrokenFoundationSupportConfig();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag', 'brokenFoundation', 'unknownFoundation'],
],
]);
$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(),
]);
$result = app(BaselineCompareService::class)->startCompare($tenant, $user);
expect($result['ok'])->toBeTrue();
$run = $result['run'];
$scope = data_get($run->context, 'effective_scope');
expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag'])
->and(data_get($scope, 'limited_types'))->toBe(['roleScopeTag'])
->and(data_get($scope, 'all_types'))->toBe(['brokenFoundation', 'deviceConfiguration', 'roleScopeTag'])
->and(data_get($scope, 'unsupported_types'))->toBe(['brokenFoundation'])
->and(data_get($scope, 'invalid_support_types'))->toBe(['brokenFoundation'])
->and(data_get($scope, 'capabilities.deviceConfiguration.support_mode'))->toBe('supported')
->and(data_get($scope, 'capabilities.roleScopeTag.support_mode'))->toBe('limited')
->and(data_get($scope, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config')
->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull();
Bus::assertDispatched(CompareBaselineToTenantJob::class);
});
it('persists the same truthful scope capability decisions before dispatching capture work', function (): void {
Bus::fake();
appendBrokenFoundationSupportConfig();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag', 'brokenFoundation', 'unknownFoundation'],
],
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeTrue();
$run = $result['run'];
$scope = data_get($run->context, 'effective_scope');
expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag'])
->and(data_get($scope, 'limited_types'))->toBe(['roleScopeTag'])
->and(data_get($scope, 'all_types'))->toBe(['brokenFoundation', 'deviceConfiguration', 'roleScopeTag'])
->and(data_get($scope, 'unsupported_types'))->toBe(['brokenFoundation'])
->and(data_get($scope, 'invalid_support_types'))->toBe(['brokenFoundation'])
->and(data_get($scope, 'capabilities.deviceConfiguration.support_mode'))->toBe('supported')
->and(data_get($scope, 'capabilities.roleScopeTag.support_mode'))->toBe('limited')
->and(data_get($scope, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config')
->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull();
Bus::assertDispatched(CaptureBaselineSnapshotJob::class);
});

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Baselines\Support;
use PHPUnit\Framework\Assert;
final class AssertsStructuredBaselineGaps
{
/**
* @param array<string, mixed> $subject
*/
public static function assertStructuredSubject(array $subject): void
{
foreach ([
'policy_type',
'subject_key',
'subject_class',
'resolution_path',
'resolution_outcome',
'reason_code',
'operator_action_category',
'structural',
'retryable',
] as $key) {
Assert::assertArrayHasKey($key, $subject);
}
}
/**
* @param list<array<string, mixed>> $subjects
*/
public static function assertStructuredSubjects(array $subjects): void
{
Assert::assertNotEmpty($subjects);
foreach ($subjects as $subject) {
Assert::assertIsArray($subject);
self::assertStructuredSubject($subject);
}
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Baselines\Support;
use App\Support\Baselines\OperatorActionCategory;
use App\Support\Baselines\ResolutionOutcome;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
final class BaselineSubjectResolutionFixtures
{
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
public static function structuredGap(array $overrides = []): array
{
return array_replace([
'policy_type' => 'deviceConfiguration',
'subject_external_id' => 'subject-1',
'subject_key' => 'deviceconfiguration|subject-1',
'subject_class' => SubjectClass::PolicyBacked->value,
'resolution_path' => ResolutionPath::Policy->value,
'resolution_outcome' => ResolutionOutcome::PolicyRecordMissing->value,
'reason_code' => 'policy_record_missing',
'operator_action_category' => OperatorActionCategory::RunPolicySyncOrBackup->value,
'structural' => false,
'retryable' => false,
'source_model_expected' => 'policy',
'source_model_found' => null,
], $overrides);
}
/**
* @param list<array<string, mixed>> $subjects
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
public static function compareContext(array $subjects, array $overrides = []): array
{
$byReason = [];
foreach ($subjects as $subject) {
$reasonCode = is_string($subject['reason_code'] ?? null) ? $subject['reason_code'] : 'unknown';
$byReason[$reasonCode] = ($byReason[$reasonCode] ?? 0) + 1;
}
return array_replace_recursive([
'baseline_compare' => [
'evidence_gaps' => [
'count' => count($subjects),
'by_reason' => $byReason,
'subjects' => $subjects,
],
],
], $overrides);
}
/**
* @param list<array<string, mixed>> $subjects
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
public static function captureContext(array $subjects, array $overrides = []): array
{
$byReason = [];
foreach ($subjects as $subject) {
$reasonCode = is_string($subject['reason_code'] ?? null) ? $subject['reason_code'] : 'unknown';
$byReason[$reasonCode] = ($byReason[$reasonCode] ?? 0) + 1;
}
return array_replace_recursive([
'baseline_capture' => [
'gaps' => [
'count' => count($subjects),
'by_reason' => $byReason,
'subjects' => $subjects,
],
],
], $overrides);
}
}

View File

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
use App\Livewire\BaselineCompareEvidenceGapTable;
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
use Filament\Tables\Table;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
uses(RefreshDatabase::class);
function baselineCompareEvidenceGapTable(Testable $component): Table
{
return $component->instance()->getTable();
}
/**
* @return list<array<string, mixed>>
*/
function baselineCompareEvidenceGapBuckets(): array
{
return BaselineCompareEvidenceGapDetails::fromContext(BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'WiFi-Corp-Profile',
'subject_class' => 'policy_backed',
'resolution_path' => 'policy',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'VPN-Always-On',
'subject_class' => 'policy_backed',
'resolution_path' => 'policy',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'Windows-Encryption-Required',
'subject_class' => 'policy_backed',
'resolution_path' => 'policy',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'Deleted-Policy-ABC',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'Retired-Compliance-Policy',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
]))['buckets'];
}
it('uses a Filament table for evidence-gap rows with searchable visible columns', function (): void {
$component = Livewire::test(BaselineCompareEvidenceGapTable::class, [
'buckets' => baselineCompareEvidenceGapBuckets(),
'context' => 'canonical-run',
]);
$table = baselineCompareEvidenceGapTable($component);
expect($table->isSearchable())->toBeTrue();
expect($table->getDefaultSortColumn())->toBe('reason_label');
expect($table->getColumn('reason_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('policy_type')?->isSearchable())->toBeTrue();
expect($table->getColumn('subject_class_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('resolution_outcome_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('operator_action_category_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('subject_key')?->isSearchable())->toBeTrue();
$component
->assertSee('WiFi-Corp-Profile')
->assertSee('Deleted-Policy-ABC')
->assertSee('Reason')
->assertSee('Policy type')
->assertSee('Subject class')
->assertSee('Outcome')
->assertSee('Next action')
->assertSee('Subject key')
->assertSee('Policy-backed')
->assertSee('Policy record missing')
->assertSee('Run policy sync or backup')
->assertSee(TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceConfiguration')->label)
->assertSee(TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceCompliancePolicy')->label);
});
it('filters evidence-gap rows by table search and select filters', function (): void {
Livewire::test(BaselineCompareEvidenceGapTable::class, [
'buckets' => baselineCompareEvidenceGapBuckets(),
'context' => 'tenant-landing',
])
->searchTable('Deleted-Policy-ABC')
->assertSee('Deleted-Policy-ABC')
->assertDontSee('WiFi-Corp-Profile');
Livewire::test(BaselineCompareEvidenceGapTable::class, [
'buckets' => baselineCompareEvidenceGapBuckets(),
'context' => 'tenant-landing-filters',
])
->filterTable('reason_code', 'policy_record_missing')
->assertSee('Retired-Compliance-Policy')
->assertDontSee('VPN-Always-On')
->filterTable('policy_type', 'deviceCompliancePolicy')
->assertSee('Retired-Compliance-Policy')
->assertDontSee('Deleted-Policy-ABC')
->filterTable('operator_action_category', 'run_policy_sync_or_backup')
->assertSee('Run policy sync or backup')
->filterTable('subject_class', 'policy_backed')
->assertSee('Policy-backed');
});
it('shows an explicit empty state when only missing-detail buckets exist', function (): void {
$buckets = BaselineCompareEvidenceGapDetails::fromContext([
'baseline_compare' => [
'evidence_gaps' => [
'count' => 2,
'by_reason' => [
'policy_record_missing' => 2,
],
'subjects' => [],
],
],
])['buckets'];
Livewire::test(BaselineCompareEvidenceGapTable::class, [
'buckets' => $buckets,
'context' => 'legacy-run',
])
->assertSee('No recorded gap rows match this view')
->assertSee('Adjust the current search or filters to review other affected subjects.');
});

View File

@ -3,12 +3,100 @@
declare(strict_types=1);
use App\Filament\Pages\BaselineCompareLanding;
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 App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
uses(RefreshDatabase::class);
/**
* @return array<string, mixed>
*/
function baselineCompareLandingGapContext(): array
{
return array_replace_recursive(BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'WiFi-Corp-Profile',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'VPN-Always-On',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'Deleted-Policy-ABC',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
]), [
'baseline_compare' => [
'subjects_total' => 50,
'reason_code' => 'evidence_capture_incomplete',
'fidelity' => 'meta',
'coverage' => [
'proof' => true,
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'effective_types' => ['deviceConfiguration'],
],
'evidence_capture' => [
'requested' => 50,
'succeeded' => 47,
'skipped' => 0,
'failed' => 3,
'throttled' => 0,
],
],
]);
}
function seedBaselineCompareLandingGapRun(\App\Models\Tenant $tenant): OperationRun
{
$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(),
]);
return 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' => baselineCompareLandingGapContext(),
]);
}
it('can access baseline compare when only the remembered admin tenant is available', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -24,3 +112,54 @@
expect(BaselineCompareLanding::canAccess())->toBeTrue();
});
it('renders tenant landing evidence-gap details with the same search affordances as the canonical run detail', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
seedBaselineCompareLandingGapRun($tenant);
Livewire::test(BaselineCompareLanding::class)
->assertSee('Evidence gap details')
->assertSee('Search gap details')
->assertSee('Search by reason, type, class, outcome, action, or subject key')
->assertSee('Reason')
->assertSee('Ambiguous inventory match')
->assertSee('Policy record missing')
->assertSee('Subject class')
->assertSee('Outcome')
->assertSee('Next action')
->assertSee('WiFi-Corp-Profile')
->assertSee('Inspect subject mapping')
->assertSee('Baseline compare evidence');
});
it('renders tenant landing evidence-gap details without invoking graph during render', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
bindFailHardGraphClient();
seedBaselineCompareLandingGapRun($tenant);
Livewire::test(BaselineCompareLanding::class)
->assertSee('Evidence gap details')
->assertSee('WiFi-Corp-Profile')
->assertSee('Baseline compare evidence');
});
it('returns 404 for non-members on the tenant landing even when evidence-gap details exist', function (): void {
[$member, $tenant] = createUserWithTenant(role: 'owner');
$nonMember = \App\Models\User::factory()->create();
seedBaselineCompareLandingGapRun($tenant);
$this->actingAs($nonMember)
->get(BaselineCompareLanding::getUrl(tenant: $tenant, panel: 'tenant'))
->assertNotFound();
});

View File

@ -61,8 +61,9 @@
Livewire::test(BaselineCompareLanding::class)
->assertSee(__('baseline-compare.duplicate_warning_title'))
->assertSee('share the same display name')
->assertSee('cannot match them to the baseline');
->assertSee('share generic display names')
->assertSee('resulting in 1 ambiguous subject')
->assertSee('cannot match them safely to the baseline');
});
it('does not show the duplicate-name warning for stale rows outside the latest inventory sync', function (): void {
@ -140,6 +141,6 @@
Livewire::test(BaselineCompareLanding::class)
->assertDontSee(__('baseline-compare.duplicate_warning_title'))
->assertDontSee('share the same display name')
->assertDontSee('cannot match them to the baseline');
->assertDontSee('share generic display names')
->assertDontSee('cannot match them safely to the baseline');
});

View File

@ -11,6 +11,7 @@
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -111,6 +112,39 @@
expect($run?->status)->toBe('queued');
});
it('keeps compare-now confirmation and mutation-scope messaging unchanged on the landing surface', 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,
'capture_mode' => \App\Support\Baselines\BaselineCaptureMode::FullContent->value,
]);
$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)
->assertActionExists('compareNow', function (Action $action): bool {
return $action->getLabel() === 'Compare now (full content)'
&& $action->isConfirmationRequired()
&& $action->getModalDescription() === 'This will refresh content evidence on demand (redacted) before comparing the current tenant inventory against the assigned baseline snapshot.';
});
});
it('does not start full-content baseline compare when rollout is disabled', function (): void {
Queue::fake();
config()->set('tenantpilot.baselines.full_content_capture.enabled', false);

View File

@ -62,3 +62,119 @@
Livewire::test(BaselineCompareLanding::class)
->assertSee(BaselineCompareReasonCode::NoDriftDetected->message());
});
it('shows explicit missing-detail fallback when evidence gaps were counted without recorded subject rows', 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(),
]);
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' => [
'reason_code' => BaselineCompareReasonCode::EvidenceCaptureIncomplete->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
'fidelity' => 'meta',
'evidence_gaps' => [
'count' => 2,
'by_reason' => [
'policy_record_missing' => 2,
],
],
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->assertSee('Evidence gap details')
->assertSee('Detailed rows were not recorded for this run')
->assertSee('Baseline compare evidence');
});
it('does not imply missing detail when the latest run has no evidence gaps', 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(),
]);
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' => [
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
'coverage' => [
'effective_types' => ['deviceConfiguration'],
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'proof' => true,
],
'fidelity' => 'meta',
'evidence_gaps' => [
'count' => 0,
'by_reason' => [],
'subjects' => [],
],
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->assertDontSee('Evidence gap details')
->assertSee('Baseline compare evidence');
});

View File

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\BaselineCompareLanding;
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 App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
uses(RefreshDatabase::class);
/**
* @return array<string, mixed>
*/
function structuredGapSurfaceContext(): array
{
return array_replace_recursive(BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'WiFi-Corp-Profile',
'subject_class' => 'policy_backed',
'resolution_path' => 'policy',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'roleScopeTag',
'subject_key' => 'scope-tag-finance',
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_inventory',
'resolution_outcome' => 'foundation_inventory_only',
'reason_code' => 'foundation_not_policy_backed',
'operator_action_category' => 'product_follow_up',
'structural' => true,
]),
]), [
'baseline_compare' => [
'reason_code' => 'evidence_capture_incomplete',
'coverage' => [
'proof' => true,
'covered_types' => ['deviceConfiguration', 'roleScopeTag'],
'uncovered_types' => [],
'effective_types' => ['deviceConfiguration', 'roleScopeTag'],
],
'fidelity' => 'meta',
],
]);
}
it('renders canonical run detail gap semantics from persisted db context only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
bindFailHardGraphClient();
Filament::setTenant(null, true);
$run = 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,
'context' => structuredGapSurfaceContext(),
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Evidence gap details')
->assertSee('Policy-backed')
->assertSee('Foundation-backed')
->assertSee('Policy record missing')
->assertSee('Foundation not policy-backed')
->assertSee('Run policy sync or backup')
->assertSee('Product follow-up')
->assertSee('WiFi-Corp-Profile')
->assertSee('scope-tag-finance');
});
it('renders tenant landing gap semantics from persisted db context only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
bindFailHardGraphClient();
$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(),
]);
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,
'context' => structuredGapSurfaceContext(),
'completed_at' => now(),
]);
Livewire::test(BaselineCompareLanding::class)
->assertSee('Evidence gap details')
->assertSee('Subject class')
->assertSee('Outcome')
->assertSee('Next action')
->assertSee('Foundation-backed')
->assertSee('Foundation not policy-backed')
->assertSee('Product follow-up')
->assertSee('scope-tag-finance');
});

View File

@ -13,6 +13,7 @@
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Testing\TestResponse;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
function visiblePageText(TestResponse $response): string
{
@ -25,6 +26,70 @@ function visiblePageText(TestResponse $response): string
return trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function baselineCompareGapContext(array $overrides = []): array
{
return array_replace_recursive(BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'WiFi-Corp-Profile',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'VPN-Always-On',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'Email-Exchange-Config',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'Deleted-Policy-ABC',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'Removed-Config-XYZ',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
]), [
'baseline_compare' => [
'subjects_total' => 50,
'reason_code' => 'evidence_capture_incomplete',
'fidelity' => 'meta',
'coverage' => [
'proof' => true,
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
'effective_types' => ['deviceConfiguration'],
],
'evidence_capture' => [
'requested' => 50,
'succeeded' => 47,
'skipped' => 0,
'failed' => 3,
'throttled' => 0,
],
],
], $overrides);
}
it('renders operation runs with summary content before counts and technical context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -225,3 +290,151 @@ function visiblePageText(TestResponse $response): string
->assertSee('Reconciled by')
->assertSee('Adapter reconciler');
});
it('renders evidence gap details section for baseline compare runs with gap subjects', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => baselineCompareGapContext(),
'completed_at' => now(),
]);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Evidence gap details')
->assertSee('Search gap details')
->assertSee('Search by reason, type, class, outcome, action, or subject key')
->assertSee('Reason')
->assertSee('Ambiguous inventory match')
->assertSee('Policy record missing')
->assertSee('3 affected')
->assertSee('2 affected')
->assertSee('WiFi-Corp-Profile')
->assertSee('Deleted-Policy-ABC')
->assertSee('Policy type')
->assertSee('Subject class')
->assertSee('Outcome')
->assertSee('Next action')
->assertSee('Subject key');
});
it('renders baseline compare evidence-gap details without invoking graph during canonical run detail render', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
bindFailHardGraphClient();
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => baselineCompareGapContext(),
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Evidence gap details')
->assertSee('WiFi-Corp-Profile');
});
it('distinguishes missing recorded gap detail from no-gap runs on the canonical run detail surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
$legacyRun = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => baselineCompareGapContext([
'baseline_compare' => [
'evidence_gaps' => [
'subjects' => null,
],
],
]),
'completed_at' => now(),
]);
$cleanRun = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'succeeded',
'context' => baselineCompareGapContext([
'baseline_compare' => [
'reason_code' => 'no_drift_detected',
'evidence_gaps' => [
'count' => 0,
'by_reason' => [],
'subjects' => [],
],
],
]),
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $legacyRun->getKey()]))
->assertOk()
->assertSee('Evidence gap details')
->assertSee('Detailed rows were not recorded for this run')
->assertSee('Baseline compare evidence');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $cleanRun->getKey()]))
->assertOk()
->assertDontSee('Evidence gap details')
->assertSee('Baseline compare evidence');
});
it('returns 404 for workspace members without tenant entitlement when evidence-gap details exist on the canonical surface', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => baselineCompareGapContext(),
'completed_at' => now(),
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
it('injects the unhandled rejection logger into Filament pages', function (): void {
$this->get('/admin/login')
->assertSuccessful()
->assertSee('js/tenantpilot/unhandled-rejection-logger.js', escape: false);
});
it('ships a window unhandledrejection logger with structured payload output', function (): void {
$js = file_get_contents(public_path('js/tenantpilot/unhandled-rejection-logger.js'));
expect($js)->toBeString();
expect($js)
->toContain('__tenantpilotUnhandledRejectionLoggerApplied')
->toContain("window.addEventListener('unhandledrejection'")
->toContain('TenantPilot unhandled promise rejection')
->toContain('JSON.stringify');
});

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\Baselines\OperatorActionCategory;
use App\Support\Baselines\ResolutionOutcome;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
use App\Support\Baselines\SubjectResolver;
it('derives truthful runtime capability and descriptors for supported policy and foundation types', function (): void {
$resolver = app(SubjectResolver::class);
$policyDescriptor = $resolver->describeForCompare('deviceConfiguration', 'policy-1', 'deviceconfiguration|policy-1');
$foundationDescriptor = $resolver->describeForCapture('roleScopeTag', 'scope-tag-1', 'rolescopetag|baseline');
$rbacDescriptor = $resolver->describeForCompare('intuneRoleDefinition', 'role-def-1', 'rbac-role');
expect($policyDescriptor->subjectClass)->toBe(SubjectClass::PolicyBacked)
->and($policyDescriptor->resolutionPath)->toBe(ResolutionPath::Policy)
->and($policyDescriptor->supportMode)->toBe('supported')
->and($policyDescriptor->sourceModelExpected)->toBe('policy');
expect($foundationDescriptor->subjectClass)->toBe(SubjectClass::FoundationBacked)
->and($foundationDescriptor->resolutionPath)->toBe(ResolutionPath::FoundationInventory)
->and($foundationDescriptor->supportMode)->toBe('limited')
->and($foundationDescriptor->sourceModelExpected)->toBe('inventory');
expect($rbacDescriptor->subjectClass)->toBe(SubjectClass::FoundationBacked)
->and($rbacDescriptor->resolutionPath)->toBe(ResolutionPath::FoundationPolicy)
->and($rbacDescriptor->supportMode)->toBe('supported')
->and($rbacDescriptor->sourceModelExpected)->toBe('policy');
});
it('maps structural and operational outcomes without flattening them into policy_not_found', function (): void {
$resolver = app(SubjectResolver::class);
$foundationDescriptor = $resolver->describeForCapture('notificationMessageTemplate', 'template-1', 'template-subject');
$policyDescriptor = $resolver->describeForCompare('deviceConfiguration', 'policy-1', 'policy-subject');
$structuralOutcome = $resolver->structuralInventoryOnly($foundationDescriptor);
$missingPolicyOutcome = $resolver->missingExpectedRecord($policyDescriptor);
$throttledOutcome = $resolver->throttled($policyDescriptor);
expect($structuralOutcome->resolutionOutcome)->toBe(ResolutionOutcome::FoundationInventoryOnly)
->and($structuralOutcome->reasonCode)->toBe('foundation_not_policy_backed')
->and($structuralOutcome->operatorActionCategory)->toBe(OperatorActionCategory::ProductFollowUp)
->and($structuralOutcome->structural)->toBeTrue();
expect($missingPolicyOutcome->resolutionOutcome)->toBe(ResolutionOutcome::PolicyRecordMissing)
->and($missingPolicyOutcome->reasonCode)->toBe('policy_record_missing')
->and($missingPolicyOutcome->operatorActionCategory)->toBe(OperatorActionCategory::RunPolicySyncOrBackup)
->and($missingPolicyOutcome->structural)->toBeFalse();
expect($throttledOutcome->resolutionOutcome)->toBe(ResolutionOutcome::Throttled)
->and($throttledOutcome->retryable)->toBeTrue()
->and($throttledOutcome->operatorActionCategory)->toBe(OperatorActionCategory::Retry);
});
it('guards unsupported or invalid support declarations before runtime work starts', function (): void {
$guard = app(BaselineSupportCapabilityGuard::class);
config()->set('tenantpilot.foundation_types', [
[
'type' => 'intuneRoleAssignment',
'label' => 'Intune RBAC Role Assignment',
'baseline_compare' => [
'supported' => false,
'identity_strategy' => 'external_id',
],
],
[
'type' => 'brokenFoundation',
'label' => 'Broken Foundation',
'baseline_compare' => [
'supported' => true,
'resolution' => [
'subject_class' => SubjectClass::FoundationBacked->value,
'resolution_path' => 'broken',
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'inventory',
],
],
],
]);
$result = $guard->guardTypes(['intuneRoleAssignment', 'brokenFoundation'], 'compare');
expect($result['allowed_types'])->toBe([])
->and($result['unsupported_types'])->toBe(['brokenFoundation', 'intuneRoleAssignment'])
->and($result['invalid_support_types'])->toBe(['brokenFoundation'])
->and(data_get($result, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config')
->and(data_get($result, 'capabilities.intuneRoleAssignment.support_mode'))->toBe('excluded');
});

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Support\Inventory\InventoryPolicyTypeMeta;
it('keeps display-name foundation support truthful as limited inventory-backed capability', function (): void {
$contract = InventoryPolicyTypeMeta::baselineSupportContract('roleScopeTag');
expect($contract['config_supported'])->toBeTrue()
->and($contract['runtime_valid'])->toBeTrue()
->and($contract['subject_class'])->toBe('foundation_backed')
->and($contract['resolution_path'])->toBe('foundation_inventory')
->and($contract['compare_capability'])->toBe('limited')
->and($contract['capture_capability'])->toBe('limited')
->and($contract['source_model_expected'])->toBe('inventory');
});
it('treats unknown baseline types as derived and excluded from support promises', function (): void {
$contract = InventoryPolicyTypeMeta::baselineSupportContract('unknownFoundation');
expect($contract['config_supported'])->toBeFalse()
->and($contract['runtime_valid'])->toBeTrue()
->and($contract['subject_class'])->toBe('derived')
->and($contract['resolution_path'])->toBe('derived')
->and($contract['compare_capability'])->toBe('unsupported')
->and($contract['capture_capability'])->toBe('unsupported')
->and($contract['source_model_expected'])->toBe('derived');
});
it('downgrades malformed baseline support declarations before they can overpromise runtime capability', function (): void {
$foundationTypes = is_array(config('tenantpilot.foundation_types')) ? config('tenantpilot.foundation_types') : [];
$foundationTypes[] = [
'type' => 'brokenFoundation',
'label' => 'Broken Foundation',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'external_id',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'inventory',
],
],
];
config()->set('tenantpilot.foundation_types', $foundationTypes);
$contract = InventoryPolicyTypeMeta::baselineSupportContract('brokenFoundation');
expect($contract['config_supported'])->toBeTrue()
->and($contract['runtime_valid'])->toBeFalse()
->and($contract['subject_class'])->toBe('foundation_backed')
->and($contract['resolution_path'])->toBe('foundation_policy')
->and($contract['compare_capability'])->toBe('unsupported')
->and($contract['capture_capability'])->toBe('unsupported')
->and($contract['source_model_expected'])->toBe('inventory');
});

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
use App\Support\Inventory\InventoryPolicyTypeMeta;
it('derives baseline subject resolution defaults for supported policy types', function (): void {
$contract = InventoryPolicyTypeMeta::baselineSupportContract('deviceConfiguration');
expect($contract['config_supported'])->toBeTrue()
->and($contract['runtime_valid'])->toBeTrue()
->and($contract['subject_class'])->toBe(SubjectClass::PolicyBacked->value)
->and($contract['resolution_path'])->toBe(ResolutionPath::Policy->value)
->and($contract['compare_capability'])->toBe('supported')
->and($contract['capture_capability'])->toBe('supported')
->and($contract['source_model_expected'])->toBe('policy');
});
it('derives limited inventory-backed foundation support from canonical metadata', function (): void {
$contract = InventoryPolicyTypeMeta::baselineSupportContract('roleScopeTag');
expect($contract['config_supported'])->toBeTrue()
->and($contract['runtime_valid'])->toBeTrue()
->and($contract['subject_class'])->toBe(SubjectClass::FoundationBacked->value)
->and($contract['resolution_path'])->toBe(ResolutionPath::FoundationInventory->value)
->and($contract['compare_capability'])->toBe('limited')
->and($contract['capture_capability'])->toBe('limited')
->and($contract['source_model_expected'])->toBe('inventory');
});
it('derives supported foundation policy resolution for intune role definitions', function (): void {
$contract = InventoryPolicyTypeMeta::baselineSupportContract('intuneRoleDefinition');
expect($contract['config_supported'])->toBeTrue()
->and($contract['runtime_valid'])->toBeTrue()
->and($contract['subject_class'])->toBe(SubjectClass::FoundationBacked->value)
->and($contract['resolution_path'])->toBe(ResolutionPath::FoundationPolicy->value)
->and($contract['compare_capability'])->toBe('supported')
->and($contract['capture_capability'])->toBe('supported')
->and($contract['source_model_expected'])->toBe('policy');
});
it('marks unsupported and malformed contracts deterministically', function (): void {
config()->set('tenantpilot.foundation_types', [
[
'type' => 'intuneRoleAssignment',
'label' => 'Intune RBAC Role Assignment',
'baseline_compare' => [
'supported' => false,
'identity_strategy' => 'external_id',
],
],
[
'type' => 'brokenFoundation',
'label' => 'Broken Foundation',
'baseline_compare' => [
'supported' => true,
'resolution' => [
'resolution_path' => 'broken',
],
],
],
]);
$unsupported = InventoryPolicyTypeMeta::baselineSupportContract('intuneRoleAssignment');
$invalid = InventoryPolicyTypeMeta::baselineSupportContract('brokenFoundation');
expect($unsupported['config_supported'])->toBeFalse()
->and($unsupported['compare_capability'])->toBe('unsupported')
->and($unsupported['capture_capability'])->toBe('unsupported');
expect($invalid['config_supported'])->toBeTrue()
->and($invalid['runtime_valid'])->toBeFalse()
->and($invalid['compare_capability'])->toBe('unsupported')
->and($invalid['capture_capability'])->toBe('unsupported');
});