feat(baselines): implement baseline compare result semantics #454
@ -11,8 +11,8 @@
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\BaselineAutoCloseService;
|
||||
@ -42,14 +42,15 @@
|
||||
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||
use App\Support\Baselines\Compare\CompareStrategySelection;
|
||||
use App\Support\Baselines\Compare\CompareSubjectResult;
|
||||
use App\Support\Baselines\Compare\StrategySelectionState;
|
||||
use App\Support\Baselines\CompareSemantics\BaselineCompareOutcomeClassifier;
|
||||
use App\Support\Baselines\CompareSemantics\BaselineCompareRunSummaryClassifier;
|
||||
use App\Support\Baselines\CompareSemantics\CompareResultReason;
|
||||
use App\Support\Baselines\Matching\BaselineSubjectDescriptor;
|
||||
use App\Support\Baselines\Matching\MatchingOutcome;
|
||||
use App\Support\Baselines\OperatorActionCategory;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Baselines\ResolutionOutcome;
|
||||
use App\Support\Baselines\ResolutionOutcomeRecord;
|
||||
use App\Support\Baselines\SubjectClass;
|
||||
use App\Support\Baselines\SubjectResolver;
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -606,7 +607,7 @@ public function handle(
|
||||
captureMode: $captureMode,
|
||||
reasonCode: BaselineCompareReasonCode::StrategyFailed,
|
||||
evidenceGapsByReason: [
|
||||
'strategy_failed' => max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
||||
CompareResultReason::CompareFailed->value => max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
||||
],
|
||||
);
|
||||
|
||||
@ -616,6 +617,7 @@ public function handle(
|
||||
$normalizedStrategyResults = $this->normalizeStrategySubjectResults($compareResult['subject_results'] ?? []);
|
||||
$driftResults = $normalizedStrategyResults['drift_results'];
|
||||
$driftGaps = $normalizedStrategyResults['gap_counts'];
|
||||
$strategySubjectOutcomes = $normalizedStrategyResults['subject_outcomes'];
|
||||
$rbacRoleDefinitionSummary = is_array($compareResult['diagnostics']['rbac_role_definitions'] ?? null)
|
||||
? $compareResult['diagnostics']['rbac_role_definitions']
|
||||
: $this->emptyRbacRoleDefinitionSummary();
|
||||
@ -644,6 +646,24 @@ public function handle(
|
||||
driftGapSubjects: $strategyGapSubjects,
|
||||
);
|
||||
|
||||
$semanticSubjectOutcomes = array_values(array_merge(
|
||||
$this->semanticOutcomesFromGapSubjects(array_merge($matchingGapSubjects, $phaseGapSubjects ?? [])),
|
||||
$strategySubjectOutcomes,
|
||||
));
|
||||
$semanticSummaryOutcomes = array_values(array_merge(
|
||||
$semanticSubjectOutcomes,
|
||||
$this->semanticOutcomesFromUnrepresentedGapCounts($gapsByReason, $semanticSubjectOutcomes),
|
||||
));
|
||||
$warningsRecorded = $uncoveredTypes !== [] || $resumeToken !== null || $gapsByReason !== [];
|
||||
$semanticSummary = app(BaselineCompareRunSummaryClassifier::class)->summarize(
|
||||
subjectOutcomes: $semanticSummaryOutcomes,
|
||||
driftFindingsCount: count($driftResults),
|
||||
warningsRecorded: $warningsRecorded,
|
||||
resumeTokenPresent: $resumeToken !== null,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
);
|
||||
$gapSemanticCounts = $this->semanticCountsForGapReasons($gapsByReason);
|
||||
|
||||
$summaryCounts = [
|
||||
'total' => count($driftResults),
|
||||
'processed' => count($driftResults),
|
||||
@ -658,8 +678,7 @@ public function handle(
|
||||
'findings_unchanged' => (int) $upsertResult['unchanged_count'],
|
||||
];
|
||||
|
||||
$warningsRecorded = $uncoveredTypes !== [] || $resumeToken !== null || $gapsByReason !== [];
|
||||
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
|
||||
$outcome = $semanticSummary['operation_outcome'];
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
@ -693,7 +712,7 @@ public function handle(
|
||||
|
||||
$overallFidelity = ($baselineCoverage['baseline_meta'] ?? 0) > 0
|
||||
|| ($coverageBreakdown['resolved_meta'] ?? 0) > 0
|
||||
|| ($gapsByReason['missing_current'] ?? 0) > 0
|
||||
|| ($gapsByReason[CompareResultReason::MissingLocalEvidence->value] ?? 0) > 0
|
||||
? EvidenceProvenance::FidelityMeta
|
||||
: EvidenceProvenance::FidelityContent;
|
||||
|
||||
@ -729,9 +748,21 @@ public function handle(
|
||||
'evidence_gaps' => [
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
'by_category' => $gapSemanticCounts['by_category'],
|
||||
'by_actionability' => $gapSemanticCounts['by_actionability'],
|
||||
'by_readiness_impact' => $gapSemanticCounts['by_readiness_impact'],
|
||||
...$gapsByReason,
|
||||
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
|
||||
],
|
||||
'result_semantics' => [
|
||||
'version' => 1,
|
||||
'run_outcome' => $semanticSummary['run_outcome'],
|
||||
'operation_outcome' => $semanticSummary['operation_outcome'],
|
||||
'counts' => $semanticSummary['counts'],
|
||||
'subject_outcomes' => $semanticSubjectOutcomes !== []
|
||||
? array_slice($semanticSubjectOutcomes, 0, self::GAP_SUBJECTS_LIMIT)
|
||||
: null,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
'coverage' => [
|
||||
'effective_types' => $effectiveTypes,
|
||||
@ -925,7 +956,6 @@ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult):
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
|
||||
private function completeWithCoverageWarning(
|
||||
OperationRunService $operationRunService,
|
||||
AuditLogger $auditLogger,
|
||||
@ -973,8 +1003,15 @@ private function completeWithCoverageWarning(
|
||||
];
|
||||
|
||||
$evidenceGapsByReason ??= [
|
||||
BaselineCompareReasonCode::CoverageUnproven->value => max(1, $errorsRecorded),
|
||||
CompareResultReason::MissingLocalEvidence->value => max(1, $errorsRecorded),
|
||||
];
|
||||
$gapSemanticCounts = $this->semanticCountsForGapReasons($evidenceGapsByReason);
|
||||
$semanticSummary = app(BaselineCompareRunSummaryClassifier::class)->summarize(
|
||||
subjectOutcomes: $this->semanticOutcomesFromReasonCounts($evidenceGapsByReason),
|
||||
driftFindingsCount: 0,
|
||||
warningsRecorded: true,
|
||||
uncoveredTypes: $uncoveredTypes,
|
||||
);
|
||||
|
||||
$updatedContext['baseline_compare'] = array_merge(
|
||||
is_array($updatedContext['baseline_compare'] ?? null) ? $updatedContext['baseline_compare'] : [],
|
||||
@ -985,8 +1022,18 @@ private function completeWithCoverageWarning(
|
||||
'evidence_gaps' => [
|
||||
'count' => array_sum($evidenceGapsByReason),
|
||||
'by_reason' => $evidenceGapsByReason,
|
||||
'by_category' => $gapSemanticCounts['by_category'],
|
||||
'by_actionability' => $gapSemanticCounts['by_actionability'],
|
||||
'by_readiness_impact' => $gapSemanticCounts['by_readiness_impact'],
|
||||
...$evidenceGapsByReason,
|
||||
],
|
||||
'result_semantics' => [
|
||||
'version' => 1,
|
||||
'run_outcome' => $semanticSummary['run_outcome'],
|
||||
'operation_outcome' => $semanticSummary['operation_outcome'],
|
||||
'counts' => $semanticSummary['counts'],
|
||||
'subject_outcomes' => null,
|
||||
],
|
||||
'resume_token' => null,
|
||||
'reason_code' => $reasonCode->value,
|
||||
'coverage' => [
|
||||
@ -1169,7 +1216,7 @@ private function loadCurrentInventoryCandidates(
|
||||
$descriptor = $this->providerResourceDescriptorFromInventoryItem($inventoryItem, $row);
|
||||
|
||||
if (! $descriptor instanceof ProviderResourceDescriptor) {
|
||||
$gaps['identity_required_current'] = ($gaps['identity_required_current'] ?? 0) + 1;
|
||||
$gaps[CompareResultReason::IdentityRequired->value] = ($gaps[CompareResultReason::IdentityRequired->value] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1182,7 +1229,7 @@ private function loadCurrentInventoryCandidates(
|
||||
);
|
||||
|
||||
if (! is_string($subjectKey) || $subjectKey === '') {
|
||||
$gaps['identity_required_current'] = ($gaps['identity_required_current'] ?? 0) + 1;
|
||||
$gaps[CompareResultReason::IdentityRequired->value] = ($gaps[CompareResultReason::IdentityRequired->value] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1439,12 +1486,13 @@ private function appendUnmatchedCurrentItems(
|
||||
if (isset($currentItems[$compareKey])) {
|
||||
unset($currentItems[$compareKey]);
|
||||
$ambiguousCurrentKeys[$compareKey] = true;
|
||||
$currentGaps['ambiguous_match'] = ($currentGaps['ambiguous_match'] ?? 0) + 1;
|
||||
$reasonCode = CompareResultReason::UnresolvedDuplicateCandidates->value;
|
||||
$currentGaps[$reasonCode] = ($currentGaps[$reasonCode] ?? 0) + 1;
|
||||
$gapSubjects[] = $this->structuredGapRecord(
|
||||
policyType: $policyType,
|
||||
subjectExternalId: (string) ($currentRow['subject_external_id'] ?? ''),
|
||||
subjectKey: $subjectKey,
|
||||
reasonCode: 'ambiguous_match',
|
||||
reasonCode: $reasonCode,
|
||||
);
|
||||
|
||||
continue;
|
||||
@ -1506,7 +1554,7 @@ private function blockOutcomeKeys(array &$blockedCompareKeys, MatchingOutcome $o
|
||||
*/
|
||||
private function gapRecordForMatchingOutcome(MatchingOutcome $outcome): array
|
||||
{
|
||||
return $this->structuredGapRecord(
|
||||
$record = $this->structuredGapRecord(
|
||||
policyType: $outcome->subject->subjectTypeKey,
|
||||
subjectExternalId: $outcome->subject->subjectExternalId,
|
||||
subjectKey: $outcome->matchedSubjectKey ?? $outcome->subject->comparisonSubjectKey(),
|
||||
@ -1515,6 +1563,10 @@ private function gapRecordForMatchingOutcome(MatchingOutcome $outcome): array
|
||||
matchingTrust: $outcome->trust,
|
||||
matchingProof: $outcome->toArray()['proof'] ?? [],
|
||||
);
|
||||
|
||||
$record['semantic_outcome'] = $this->compareOutcomeClassifier()->fromMatchingOutcome($outcome)->toArray();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1545,6 +1597,18 @@ private function structuredGapRecord(
|
||||
$matchingStatus,
|
||||
]))),
|
||||
]);
|
||||
$record['semantic_outcome'] = $this->compareOutcomeClassifier()->fromReason(
|
||||
reasonCode: $reasonCode,
|
||||
subject: [
|
||||
'policy_type' => $policyType,
|
||||
'subject_key' => $subjectKey,
|
||||
'subject_external_id' => $subjectExternalId,
|
||||
],
|
||||
proof: [
|
||||
'matching_status' => $matchingStatus,
|
||||
'matching_trust' => $matchingTrust,
|
||||
],
|
||||
)->toArray();
|
||||
|
||||
if ($matchingStatus !== null) {
|
||||
$record['matching_status'] = $matchingStatus;
|
||||
@ -1563,11 +1627,14 @@ private function structuredGapRecord(
|
||||
|
||||
private function matchingResolutionOutcome(\App\Support\Baselines\SubjectDescriptor $descriptor, string $reasonCode): ResolutionOutcomeRecord
|
||||
{
|
||||
if ($reasonCode === 'ambiguous_match') {
|
||||
if (in_array($reasonCode, [
|
||||
CompareResultReason::UnresolvedAmbiguousIdentity->value,
|
||||
CompareResultReason::UnresolvedDuplicateCandidates->value,
|
||||
], true)) {
|
||||
return $this->subjectResolver()->ambiguousMatch($descriptor);
|
||||
}
|
||||
|
||||
if ($reasonCode === 'missing_local_evidence') {
|
||||
if ($reasonCode === CompareResultReason::MissingLocalEvidence->value) {
|
||||
$missing = $this->subjectResolver()->missingExpectedRecord($descriptor);
|
||||
|
||||
return new ResolutionOutcomeRecord(
|
||||
@ -1582,7 +1649,7 @@ private function matchingResolutionOutcome(\App\Support\Baselines\SubjectDescrip
|
||||
}
|
||||
|
||||
return match ($reasonCode) {
|
||||
'identity_required', 'missing_provider_resource' => new ResolutionOutcomeRecord(
|
||||
CompareResultReason::IdentityRequired->value => new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::UnresolvableSubject,
|
||||
reasonCode: $reasonCode,
|
||||
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||
@ -1590,7 +1657,15 @@ private function matchingResolutionOutcome(\App\Support\Baselines\SubjectDescrip
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
),
|
||||
'foundation_not_policy_backed' => new ResolutionOutcomeRecord(
|
||||
CompareResultReason::MissingProviderResource->value => new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::MissingProviderResource,
|
||||
reasonCode: $reasonCode,
|
||||
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
),
|
||||
CompareResultReason::FoundationInventoryOnly->value => new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::FoundationInventoryOnly,
|
||||
reasonCode: $reasonCode,
|
||||
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||
@ -1599,15 +1674,34 @@ private function matchingResolutionOutcome(\App\Support\Baselines\SubjectDescrip
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
sourceModelFound: 'inventory',
|
||||
),
|
||||
'unsupported_subject' => new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::UnresolvableSubject,
|
||||
CompareResultReason::FoundationIdentityOnly->value => new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::FoundationIdentityOnly,
|
||||
reasonCode: $reasonCode,
|
||||
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||
structural: true,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
sourceModelFound: 'derived',
|
||||
),
|
||||
CompareResultReason::FoundationCanonicalOnly->value => new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::FoundationCanonicalOnly,
|
||||
reasonCode: $reasonCode,
|
||||
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||
structural: true,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
sourceModelFound: 'derived',
|
||||
),
|
||||
CompareResultReason::UnsupportedResourceClass->value,
|
||||
CompareResultReason::CompareNotSupported->value => new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::UnsupportedResourceClass,
|
||||
reasonCode: $reasonCode,
|
||||
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||
structural: true,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
),
|
||||
'accepted_limitation' => new ResolutionOutcomeRecord(
|
||||
CompareResultReason::AcceptedLimitation->value => new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::FoundationInventoryOnly,
|
||||
reasonCode: $reasonCode,
|
||||
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||
@ -1616,7 +1710,7 @@ private function matchingResolutionOutcome(\App\Support\Baselines\SubjectDescrip
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
sourceModelFound: 'inventory',
|
||||
),
|
||||
'excluded_non_governed' => new ResolutionOutcomeRecord(
|
||||
CompareResultReason::ExcludedNonGoverned->value => new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::PermissionOrScopeBlocked,
|
||||
reasonCode: $reasonCode,
|
||||
operatorActionCategory: OperatorActionCategory::None,
|
||||
@ -1944,18 +2038,16 @@ private function strategyContext(
|
||||
|
||||
private function strategySelectionGapReason(CompareStrategySelection $strategySelection): string
|
||||
{
|
||||
return $strategySelection->selectionState === StrategySelectionState::Mixed
|
||||
? 'mixed_scope'
|
||||
: 'unsupported_subjects';
|
||||
return CompareResultReason::CompareNotSupported->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $subjectResults
|
||||
* @return array{
|
||||
* drift_results: array<int, array{change_type: string, severity: string, evidence_fidelity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>,
|
||||
* gap_counts: array<string, int>,
|
||||
* gap_subjects: list<array<string, mixed>>,
|
||||
* state_counts: array<string, int>
|
||||
* state_counts: array<string, int>,
|
||||
* subject_outcomes: list<array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
private function normalizeStrategySubjectResults(mixed $subjectResults): array
|
||||
@ -1966,6 +2058,7 @@ private function normalizeStrategySubjectResults(mixed $subjectResults): array
|
||||
'gap_counts' => [],
|
||||
'gap_subjects' => [],
|
||||
'state_counts' => [],
|
||||
'subject_outcomes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@ -1973,6 +2066,8 @@ private function normalizeStrategySubjectResults(mixed $subjectResults): array
|
||||
$gapCounts = [];
|
||||
$gapSubjects = [];
|
||||
$stateCounts = [];
|
||||
$subjectOutcomes = [];
|
||||
$classifier = $this->compareOutcomeClassifier();
|
||||
|
||||
foreach ($subjectResults as $subjectResult) {
|
||||
if (! $subjectResult instanceof CompareSubjectResult) {
|
||||
@ -1981,6 +2076,18 @@ private function normalizeStrategySubjectResults(mixed $subjectResults): array
|
||||
|
||||
$state = $subjectResult->compareState->value;
|
||||
$stateCounts[$state] = ($stateCounts[$state] ?? 0) + 1;
|
||||
$subjectOutcome = $classifier->fromStrategyResult($subjectResult)->toArray();
|
||||
$subjectOutcomes[] = $subjectOutcome;
|
||||
|
||||
if ($this->shouldTreatStrategyResultAsSemanticGap($subjectResult, $subjectOutcome)) {
|
||||
$reasonCode = $this->reasonCodeFromSemanticOutcome($subjectOutcome);
|
||||
$gapCounts[$reasonCode] = ($gapCounts[$reasonCode] ?? 0) + 1;
|
||||
$gapRecord = $this->fallbackGapRecord($subjectResult, $reasonCode);
|
||||
$gapRecord['semantic_outcome'] = $subjectOutcome;
|
||||
$gapSubjects[] = $gapRecord;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($subjectResult->compareState === CompareState::Drift && $subjectResult->findingCandidate instanceof CompareFindingCandidate) {
|
||||
$driftResults[] = [
|
||||
@ -2009,7 +2116,9 @@ private function normalizeStrategySubjectResults(mixed $subjectResults): array
|
||||
|
||||
$reasonCode = $subjectResult->gapReasonCode() ?? $this->defaultGapReasonForState($subjectResult->compareState);
|
||||
$gapCounts[$reasonCode] = ($gapCounts[$reasonCode] ?? 0) + 1;
|
||||
$gapSubjects[] = $subjectResult->gapRecord() ?? $this->fallbackGapRecord($subjectResult, $reasonCode);
|
||||
$gapRecord = $subjectResult->gapRecord() ?? $this->fallbackGapRecord($subjectResult, $reasonCode);
|
||||
$gapRecord['semantic_outcome'] = $subjectOutcome;
|
||||
$gapSubjects[] = $gapRecord;
|
||||
}
|
||||
|
||||
ksort($gapCounts);
|
||||
@ -2020,16 +2129,46 @@ private function normalizeStrategySubjectResults(mixed $subjectResults): array
|
||||
'gap_counts' => $gapCounts,
|
||||
'gap_subjects' => $gapSubjects,
|
||||
'state_counts' => $stateCounts,
|
||||
'subject_outcomes' => $subjectOutcomes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subjectOutcome
|
||||
*/
|
||||
private function shouldTreatStrategyResultAsSemanticGap(
|
||||
CompareSubjectResult $subjectResult,
|
||||
array $subjectOutcome,
|
||||
): bool {
|
||||
if (! in_array($subjectResult->compareState, [CompareState::NoDrift, CompareState::Drift], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! in_array($subjectOutcome['reason'] ?? null, [
|
||||
CompareResultReason::VerifiedNoDrift->value,
|
||||
CompareResultReason::VerifiedDriftDetected->value,
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subjectOutcome
|
||||
*/
|
||||
private function reasonCodeFromSemanticOutcome(array $subjectOutcome): string
|
||||
{
|
||||
$reasonCode = $subjectOutcome['reason'] ?? null;
|
||||
|
||||
return is_string($reasonCode) && trim($reasonCode) !== ''
|
||||
? $reasonCode
|
||||
: CompareResultReason::UnresolvedLowTrustMatch->value;
|
||||
}
|
||||
|
||||
private function defaultGapReasonForState(CompareState $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
CompareState::Unsupported => 'unsupported_subject',
|
||||
CompareState::Ambiguous => 'ambiguous_match',
|
||||
CompareState::Failed => 'strategy_failed',
|
||||
default => 'missing_current',
|
||||
CompareState::Unsupported => CompareResultReason::CompareNotSupported->value,
|
||||
CompareState::Ambiguous => CompareResultReason::UnresolvedAmbiguousIdentity->value,
|
||||
CompareState::Failed => CompareResultReason::CompareFailed->value,
|
||||
default => CompareResultReason::MissingLocalEvidence->value,
|
||||
};
|
||||
}
|
||||
|
||||
@ -2045,12 +2184,14 @@ private function fallbackGapRecord(CompareSubjectResult $subjectResult, string $
|
||||
);
|
||||
|
||||
$outcome = match ($reasonCode) {
|
||||
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
|
||||
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
|
||||
CompareResultReason::MissingLocalEvidence->value => $this->subjectResolver()->missingExpectedRecord($descriptor),
|
||||
CompareResultReason::UnresolvedAmbiguousIdentity->value,
|
||||
CompareResultReason::UnresolvedLowTrustMatch->value,
|
||||
CompareResultReason::UnresolvedDuplicateCandidates->value => $this->subjectResolver()->ambiguousMatch($descriptor),
|
||||
default => $this->subjectResolver()->captureFailed($descriptor),
|
||||
};
|
||||
|
||||
return array_merge($descriptor->toArray(), $outcome->toArray(), [
|
||||
$record = array_merge($descriptor->toArray(), $outcome->toArray(), [
|
||||
'reason_code' => $reasonCode,
|
||||
'search_text' => strtolower(implode(' ', array_filter([
|
||||
$subjectResult->subjectIdentity->subjectTypeKey,
|
||||
@ -2059,6 +2200,18 @@ private function fallbackGapRecord(CompareSubjectResult $subjectResult, string $
|
||||
$subjectResult->projection->operatorLabel,
|
||||
]))),
|
||||
]);
|
||||
$record['semantic_outcome'] = $this->compareOutcomeClassifier()->fromReason(
|
||||
reasonCode: $reasonCode,
|
||||
subject: [
|
||||
'policy_type' => $subjectResult->subjectIdentity->subjectTypeKey,
|
||||
'subject_key' => $subjectResult->subjectIdentity->subjectKey,
|
||||
'subject_external_id' => $subjectResult->subjectIdentity->externalSubjectId,
|
||||
],
|
||||
state: $subjectResult->compareState,
|
||||
trustLevel: $subjectResult->trustLevel,
|
||||
)->toArray();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function resolveLatestInventorySyncRun(ManagedEnvironment $tenant): ?OperationRun
|
||||
@ -2188,6 +2341,13 @@ private function collectGapSubjects(array $ambiguousKeys, mixed $phaseGapSubject
|
||||
|
||||
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
||||
$record = array_merge($descriptor->toArray(), $this->subjectResolver()->ambiguousMatch($descriptor)->toArray());
|
||||
$record['semantic_outcome'] = $this->compareOutcomeClassifier()->fromReason(
|
||||
reasonCode: CompareResultReason::UnresolvedAmbiguousIdentity->value,
|
||||
subject: [
|
||||
'policy_type' => $policyType,
|
||||
'subject_key' => $subjectKey,
|
||||
],
|
||||
)->toArray();
|
||||
$fingerprint = md5(json_encode([$record['policy_type'], $record['subject_key'], $record['reason_code']]));
|
||||
|
||||
if (isset($seen[$fingerprint])) {
|
||||
@ -2250,6 +2410,151 @@ private function normalizeStructuredGapSubjects(mixed $value): array
|
||||
return $subjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $reasonCounts
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function semanticOutcomesFromReasonCounts(array $reasonCounts): array
|
||||
{
|
||||
$outcomes = [];
|
||||
|
||||
foreach ($reasonCounts as $reasonCode => $count) {
|
||||
if (! is_string($reasonCode) || ! is_int($count) || $count < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$outcome = $this->compareOutcomeClassifier()->fromReason($reasonCode)->toArray();
|
||||
|
||||
for ($index = 0; $index < $count; $index++) {
|
||||
$outcomes[] = $outcome;
|
||||
}
|
||||
}
|
||||
|
||||
return $outcomes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $reasonCounts
|
||||
* @param list<array<string, mixed>> $representedOutcomes
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function semanticOutcomesFromUnrepresentedGapCounts(array $reasonCounts, array $representedOutcomes): array
|
||||
{
|
||||
$representedCounts = [];
|
||||
|
||||
foreach ($representedOutcomes as $outcome) {
|
||||
$reasonCode = $outcome['reason'] ?? null;
|
||||
|
||||
if (! is_string($reasonCode) || ! array_key_exists($reasonCode, $reasonCounts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$representedCounts[$reasonCode] = ($representedCounts[$reasonCode] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$unrepresentedCounts = [];
|
||||
|
||||
foreach ($reasonCounts as $reasonCode => $count) {
|
||||
if (! is_string($reasonCode) || ! is_int($count) || $count < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missingCount = $count - ($representedCounts[$reasonCode] ?? 0);
|
||||
|
||||
if ($missingCount < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unrepresentedCounts[$reasonCode] = $missingCount;
|
||||
}
|
||||
|
||||
return $this->semanticOutcomesFromReasonCounts($unrepresentedCounts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $subjects
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function semanticOutcomesFromGapSubjects(array $subjects): array
|
||||
{
|
||||
$outcomes = [];
|
||||
|
||||
foreach ($this->normalizeStructuredGapSubjects($subjects) as $subject) {
|
||||
$semanticOutcome = $subject['semantic_outcome'] ?? null;
|
||||
|
||||
if (is_array($semanticOutcome)) {
|
||||
$outcomes[] = $semanticOutcome;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$reasonCode = $subject['reason_code'] ?? null;
|
||||
|
||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$outcomes[] = $this->compareOutcomeClassifier()->fromReason(
|
||||
reasonCode: $reasonCode,
|
||||
subject: [
|
||||
'policy_type' => $subject['policy_type'] ?? null,
|
||||
'subject_key' => $subject['subject_key'] ?? null,
|
||||
'subject_external_id' => $subject['subject_external_id'] ?? null,
|
||||
],
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
return $outcomes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $reasonCounts
|
||||
* @return array{
|
||||
* by_category: array<string, int>,
|
||||
* by_actionability: array<string, int>,
|
||||
* by_readiness_impact: array<string, int>
|
||||
* }
|
||||
*/
|
||||
private function semanticCountsForGapReasons(array $reasonCounts): array
|
||||
{
|
||||
$counts = [
|
||||
'by_category' => [],
|
||||
'by_actionability' => [],
|
||||
'by_readiness_impact' => [],
|
||||
];
|
||||
|
||||
foreach ($reasonCounts as $reasonCode => $count) {
|
||||
if (! is_string($reasonCode) || ! is_int($count) || $count < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$outcome = $this->compareOutcomeClassifier()->fromReason($reasonCode)->toArray();
|
||||
$this->incrementSemanticCount($counts['by_category'], $outcome['category'] ?? null, $count);
|
||||
$this->incrementSemanticCount($counts['by_actionability'], $outcome['actionability'] ?? null, $count);
|
||||
$this->incrementSemanticCount($counts['by_readiness_impact'], $outcome['readiness_impact'] ?? null, $count);
|
||||
}
|
||||
|
||||
foreach ($counts as &$bucket) {
|
||||
ksort($bucket);
|
||||
}
|
||||
unset($bucket);
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $counts
|
||||
*/
|
||||
private function incrementSemanticCount(array &$counts, mixed $key, int $amount): void
|
||||
{
|
||||
if (! is_string($key) || trim($key) === '' || $amount < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = trim($key);
|
||||
$counts[$key] = ($counts[$key] ?? 0) + $amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: ?string}
|
||||
*/
|
||||
@ -2277,6 +2582,11 @@ private function subjectResolver(): SubjectResolver
|
||||
return app(SubjectResolver::class);
|
||||
}
|
||||
|
||||
private function compareOutcomeClassifier(): BaselineCompareOutcomeClassifier
|
||||
{
|
||||
return app(BaselineCompareOutcomeClassifier::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||
@ -2607,8 +2917,7 @@ private function observeFinding(
|
||||
int $currentOperationRunId,
|
||||
string $severity,
|
||||
FindingSlaPolicy $slaPolicy,
|
||||
): void
|
||||
{
|
||||
): void {
|
||||
if ($finding->first_seen_at === null) {
|
||||
$finding->first_seen_at = $observedAt;
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ public function coverageFor(string $policyType, ?ResourceIdentity $identity = nu
|
||||
}
|
||||
|
||||
if ($supportMode === 'excluded') {
|
||||
return $this->record($policyType, 'unsupported', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, 'unsupported_subject', $identityKind);
|
||||
return $this->record($policyType, 'unsupported', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, 'unsupported_resource_class', $identityKind);
|
||||
}
|
||||
|
||||
if ($identity instanceof ResourceIdentity && in_array($identity->identityKind, [
|
||||
@ -46,15 +46,15 @@ public function coverageFor(string $policyType, ?ResourceIdentity $identity = nu
|
||||
ResourceIdentity::CanonicalDefault,
|
||||
ResourceIdentity::CanonicalVirtualTarget,
|
||||
], true)) {
|
||||
return $this->record($policyType, 'canonical_only', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, null, $identityKind);
|
||||
return $this->record($policyType, 'canonical_only', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, 'foundation_canonical_only', $identityKind);
|
||||
}
|
||||
|
||||
if ($isFoundation && $capability->sourceModelExpected === 'inventory') {
|
||||
return $this->record($policyType, 'inventory_only', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, 'foundation_not_policy_backed', $identityKind);
|
||||
return $this->record($policyType, 'inventory_only', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, 'foundation_inventory_only', $identityKind);
|
||||
}
|
||||
|
||||
if ($supportMode === 'limited') {
|
||||
return $this->record($policyType, 'identity_only', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, 'accepted_limitation', $identityKind);
|
||||
return $this->record($policyType, 'identity_only', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, 'foundation_identity_only', $identityKind);
|
||||
}
|
||||
|
||||
return $this->record($policyType, 'fully_comparable', $capability->compareCapability, $capability->captureCapability, $capability->sourceModelExpected, $supportMode, null, $identityKind);
|
||||
|
||||
@ -76,7 +76,7 @@ private function matchOne(BaselineSubjectDescriptor $subject, array $currentInde
|
||||
subject: $subject,
|
||||
matchedDescriptor: $byCanonical[0],
|
||||
matchedSubjectKey: $canonicalKey,
|
||||
reasonCode: 'canonical_subject_key',
|
||||
reasonCode: 'resolved_canonical_identity',
|
||||
trust: 'high',
|
||||
proof: [
|
||||
'match_stage' => 'canonical_subject_key',
|
||||
@ -102,7 +102,7 @@ private function matchOne(BaselineSubjectDescriptor $subject, array $currentInde
|
||||
subject: $subject,
|
||||
matchedDescriptor: $byCanonical[0],
|
||||
matchedSubjectKey: $canonicalKey ?? $this->currentCanonicalKey($byCanonical[0]) ?? $subject->comparisonSubjectKey(),
|
||||
reasonCode: 'provider_identity',
|
||||
reasonCode: 'resolved_provider_identity',
|
||||
trust: 'high',
|
||||
proof: [
|
||||
'match_stage' => 'provider_identity',
|
||||
@ -156,11 +156,11 @@ private function foundationCoverageOutcome(BaselineSubjectDescriptor $subject):
|
||||
|
||||
return match ($coverage['coverage']) {
|
||||
'unsupported' => MatchingOutcome::unsupported($subject, $proof + [
|
||||
'reason_code' => $coverage['reason_code'] ?? 'unsupported_subject',
|
||||
'reason_code' => $coverage['reason_code'] ?? 'unsupported_resource_class',
|
||||
]),
|
||||
'inventory_only' => MatchingOutcome::limited(
|
||||
subject: $subject,
|
||||
reasonCode: $coverage['reason_code'] ?? 'foundation_not_policy_backed',
|
||||
reasonCode: $coverage['reason_code'] ?? 'foundation_inventory_only',
|
||||
proof: $proof,
|
||||
),
|
||||
'identity_only', 'canonical_only' => MatchingOutcome::limited(
|
||||
@ -221,7 +221,7 @@ private function matchActiveBindingToCurrentDescriptor(BaselineSubjectDescriptor
|
||||
subject: $subject,
|
||||
matchedDescriptor: $candidates[0],
|
||||
matchedSubjectKey: (string) $binding->canonical_subject_key,
|
||||
reasonCode: 'active_provider_resource_binding',
|
||||
reasonCode: 'resolved_active_binding',
|
||||
trust: 'authoritative',
|
||||
proof: $proof,
|
||||
);
|
||||
|
||||
@ -156,6 +156,7 @@ public static function diagnosticsPayload(array $baselineCompare): array
|
||||
'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,
|
||||
'result_semantics' => is_array($baselineCompare['result_semantics'] ?? null) ? $baselineCompare['result_semantics'] : null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
||||
}
|
||||
|
||||
@ -164,10 +165,27 @@ 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',
|
||||
'verified_no_drift' => 'No drift verified',
|
||||
'verified_drift_detected' => 'Drift detected',
|
||||
'resolved_active_binding' => 'Resolved by active binding',
|
||||
'resolved_canonical_identity' => 'Resolved by canonical identity',
|
||||
'resolved_provider_identity' => 'Resolved by provider identity',
|
||||
'identity_required' => 'Identity required',
|
||||
'unresolved_duplicate_candidates' => 'Duplicate provider candidates',
|
||||
'unresolved_low_trust_match' => 'Low-trust identity match',
|
||||
'unresolved_ambiguous_identity' => 'Ambiguous identity',
|
||||
'missing_local_evidence' => 'Missing local evidence',
|
||||
'missing_provider_resource' => 'Missing provider resource',
|
||||
'unsupported_resource_class' => 'Unsupported resource class',
|
||||
'foundation_inventory_only' => 'Foundation inventory only',
|
||||
'foundation_identity_only' => 'Foundation identity only',
|
||||
'foundation_canonical_only' => 'Foundation canonical only',
|
||||
'accepted_limitation' => 'Accepted limitation',
|
||||
'excluded_non_governed' => 'Excluded from governance',
|
||||
'compare_not_supported' => 'Compare not supported',
|
||||
'compare_failed' => 'Compare failed',
|
||||
'compare_coverage_incomplete' => 'Compare coverage incomplete',
|
||||
'compare_evidence_incomplete' => 'Compare evidence incomplete',
|
||||
'invalid_subject' => 'Invalid subject',
|
||||
'duplicate_subject' => 'Duplicate subject',
|
||||
'capture_failed' => 'Evidence capture failed',
|
||||
@ -175,7 +193,6 @@ public static function reasonLabel(string $reason): string
|
||||
'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',
|
||||
@ -200,17 +217,22 @@ 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::MissingLocalEvidence->value => 'Missing local evidence',
|
||||
ResolutionOutcome::MissingProviderResource->value => 'Missing provider resource',
|
||||
ResolutionOutcome::FoundationInventoryOnly->value => 'Foundation inventory only',
|
||||
ResolutionOutcome::FoundationIdentityOnly->value => 'Foundation identity only',
|
||||
ResolutionOutcome::FoundationCanonicalOnly->value => 'Foundation canonical only',
|
||||
ResolutionOutcome::UnsupportedResourceClass->value => 'Unsupported resource class',
|
||||
ResolutionOutcome::UnresolvedDuplicateCandidates->value => 'Duplicate provider candidates',
|
||||
ResolutionOutcome::UnresolvedAmbiguousIdentity->value => 'Ambiguous identity',
|
||||
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',
|
||||
default => Str::of($resolutionOutcome)->replace('_', ' ')->trim()->ucfirst()->toString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -10,13 +10,13 @@
|
||||
enum BaselineCompareReasonCode: string
|
||||
{
|
||||
case NoSubjectsInScope = 'no_subjects_in_scope';
|
||||
case CoverageUnproven = 'coverage_unproven';
|
||||
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
||||
case UnsupportedSubjects = 'unsupported_subjects';
|
||||
case AmbiguousSubjects = 'ambiguous_subjects';
|
||||
case StrategyFailed = 'strategy_failed';
|
||||
case CoverageUnproven = 'compare_coverage_incomplete';
|
||||
case EvidenceCaptureIncomplete = 'compare_evidence_incomplete';
|
||||
case UnsupportedSubjects = 'compare_not_supported';
|
||||
case AmbiguousSubjects = 'unresolved_ambiguous_identity';
|
||||
case StrategyFailed = 'compare_failed';
|
||||
case RolloutDisabled = 'rollout_disabled';
|
||||
case NoDriftDetected = 'no_drift_detected';
|
||||
case NoDriftDetected = 'verified_no_drift';
|
||||
case OverdueFindingsRemain = 'overdue_findings_remain';
|
||||
case GovernanceExpiring = 'governance_expiring';
|
||||
case GovernanceLapsed = 'governance_lapsed';
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
namespace App\Support\Baselines\Compare;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
||||
@ -16,10 +16,6 @@
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
|
||||
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;
|
||||
use App\Support\Governance\GovernanceDomainKey;
|
||||
use App\Support\Governance\GovernanceSubjectClass;
|
||||
@ -183,7 +179,7 @@ public function compare(
|
||||
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? $baselineItem['subject_external_id'] ?? ''),
|
||||
operatorLabel: $this->operatorLabel($baselineItem, $currentItem),
|
||||
compareState: CompareState::Incomplete,
|
||||
reasonCode: 'missing_current',
|
||||
reasonCode: 'missing_local_evidence',
|
||||
baselineAvailability: 'available',
|
||||
currentStateAvailability: 'unknown',
|
||||
trustLevel: TrustworthinessLevel::Unusable->value,
|
||||
@ -354,7 +350,7 @@ public function compare(
|
||||
subjectExternalId: (string) ($currentItem['subject_external_id'] ?? ''),
|
||||
operatorLabel: $this->operatorLabel(null, $currentItem),
|
||||
compareState: CompareState::Incomplete,
|
||||
reasonCode: 'missing_current',
|
||||
reasonCode: 'missing_local_evidence',
|
||||
baselineAvailability: 'missing',
|
||||
currentStateAvailability: 'unknown',
|
||||
trustLevel: TrustworthinessLevel::Unusable->value,
|
||||
@ -511,8 +507,8 @@ private function gapResult(
|
||||
);
|
||||
|
||||
$outcome = match ($reasonCode) {
|
||||
'missing_current' => $this->subjectResolver->missingExpectedRecord($descriptor),
|
||||
'ambiguous_match' => $this->subjectResolver->ambiguousMatch($descriptor),
|
||||
'missing_local_evidence' => $this->subjectResolver->missingExpectedRecord($descriptor),
|
||||
'unresolved_ambiguous_identity', 'unresolved_duplicate_candidates' => $this->subjectResolver->ambiguousMatch($descriptor),
|
||||
default => $this->subjectResolver->captureFailed($descriptor),
|
||||
};
|
||||
|
||||
@ -1019,4 +1015,4 @@ private function baselineProvenanceFromMetaJsonb(array $metaJsonb): array
|
||||
observedOperationRunId: is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
use App\Support\Baselines\Compare\CompareState;
|
||||
use App\Support\Baselines\Compare\CompareSubjectResult;
|
||||
use App\Support\Baselines\Matching\MatchingOutcome;
|
||||
|
||||
final class BaselineCompareOutcomeClassifier
|
||||
{
|
||||
public function fromMatchingOutcome(MatchingOutcome $outcome): CompareSubjectOutcome
|
||||
{
|
||||
$explicitTrustLevel = $this->explicitTrustLevel($outcome->trust);
|
||||
$reason = CompareResultReason::tryFrom($outcome->reasonCode) ?? $this->defaultMatchingReason($outcome);
|
||||
$reason = $this->reasonAllowedByTrust($reason, $explicitTrustLevel);
|
||||
|
||||
return new CompareSubjectOutcome(
|
||||
reason: $reason,
|
||||
category: $reason->category(),
|
||||
actionability: $reason->actionability(),
|
||||
readinessImpact: $reason->readinessImpact(),
|
||||
identityStatus: $this->identityStatusForMatching($outcome, $reason),
|
||||
comparisonStatus: CompareResultComparisonStatus::NotCompared,
|
||||
coverageStatus: $reason->coverageStatus(),
|
||||
trustLevel: $this->trustLevel($outcome->trust, $reason),
|
||||
subject: $outcome->subject->toArray(),
|
||||
proof: $outcome->toArray()['proof'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subject
|
||||
* @param array<string, mixed> $proof
|
||||
*/
|
||||
public function fromReason(
|
||||
string $reasonCode,
|
||||
array $subject = [],
|
||||
array $proof = [],
|
||||
?CompareState $state = null,
|
||||
?string $trustLevel = null,
|
||||
): CompareSubjectOutcome {
|
||||
$explicitTrustLevel = $this->explicitTrustLevel($trustLevel);
|
||||
$reason = CompareResultReason::tryFrom($reasonCode)
|
||||
?? $this->defaultReasonForState($state, $explicitTrustLevel);
|
||||
$reason = $this->reasonAllowedByTrust($reason, $explicitTrustLevel);
|
||||
|
||||
return new CompareSubjectOutcome(
|
||||
reason: $reason,
|
||||
category: $reason->category(),
|
||||
actionability: $reason->actionability(),
|
||||
readinessImpact: $reason->readinessImpact(),
|
||||
identityStatus: $this->identityStatusForReason($reason),
|
||||
comparisonStatus: $this->comparisonStatusForReason($reason, $state),
|
||||
coverageStatus: $reason->coverageStatus(),
|
||||
trustLevel: $this->trustLevel($trustLevel, $reason),
|
||||
subject: $subject,
|
||||
proof: $proof,
|
||||
);
|
||||
}
|
||||
|
||||
public function fromStrategyResult(CompareSubjectResult $result): CompareSubjectOutcome
|
||||
{
|
||||
$explicitTrustLevel = $this->explicitTrustLevel($result->trustLevel);
|
||||
$reason = match ($result->compareState) {
|
||||
CompareState::NoDrift => $this->trustedVerifiedReason(
|
||||
$explicitTrustLevel,
|
||||
CompareResultReason::VerifiedNoDrift,
|
||||
),
|
||||
CompareState::Drift => $this->trustedVerifiedReason(
|
||||
$explicitTrustLevel,
|
||||
CompareResultReason::VerifiedDriftDetected,
|
||||
),
|
||||
default => CompareResultReason::tryFrom((string) ($result->gapReasonCode() ?? ''))
|
||||
?? $this->defaultReasonForState($result->compareState, $explicitTrustLevel),
|
||||
};
|
||||
$reason = $this->reasonAllowedByTrust($reason, $explicitTrustLevel);
|
||||
|
||||
return new CompareSubjectOutcome(
|
||||
reason: $reason,
|
||||
category: $reason->category(),
|
||||
actionability: $reason->actionability(),
|
||||
readinessImpact: $reason->readinessImpact(),
|
||||
identityStatus: $this->identityStatusForReason($reason),
|
||||
comparisonStatus: $this->comparisonStatusForReason($reason, $result->compareState),
|
||||
coverageStatus: $reason->coverageStatus(),
|
||||
trustLevel: $this->trustLevel($result->trustLevel, $reason),
|
||||
subject: [
|
||||
'domain_key' => $result->subjectIdentity->domainKey,
|
||||
'subject_class' => $result->subjectIdentity->subjectClass,
|
||||
'subject_type_key' => $result->subjectIdentity->subjectTypeKey,
|
||||
'external_subject_id' => $result->subjectIdentity->externalSubjectId,
|
||||
'subject_key' => $result->subjectIdentity->subjectKey,
|
||||
'operator_label' => $result->projection->operatorLabel,
|
||||
],
|
||||
proof: [
|
||||
'compare_state' => $result->compareState->value,
|
||||
'baseline_availability' => $result->baselineAvailability,
|
||||
'current_state_availability' => $result->currentStateAvailability,
|
||||
'evidence_quality' => $result->evidenceQuality,
|
||||
'strategy_key' => is_string($result->diagnostics['strategy_key'] ?? null)
|
||||
? $result->diagnostics['strategy_key']
|
||||
: null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function defaultMatchingReason(MatchingOutcome $outcome): CompareResultReason
|
||||
{
|
||||
return match ($outcome->status) {
|
||||
MatchingOutcome::Resolved => CompareResultReason::ResolvedProviderIdentity,
|
||||
MatchingOutcome::Ambiguous => CompareResultReason::UnresolvedDuplicateCandidates,
|
||||
MatchingOutcome::MissingProviderResource => CompareResultReason::MissingProviderResource,
|
||||
MatchingOutcome::MissingLocalEvidence => CompareResultReason::MissingLocalEvidence,
|
||||
MatchingOutcome::UnresolvedIdentity => CompareResultReason::IdentityRequired,
|
||||
MatchingOutcome::Unsupported => CompareResultReason::UnsupportedResourceClass,
|
||||
MatchingOutcome::Limited => CompareResultReason::AcceptedLimitation,
|
||||
MatchingOutcome::Excluded => CompareResultReason::ExcludedNonGoverned,
|
||||
default => CompareResultReason::CompareFailed,
|
||||
};
|
||||
}
|
||||
|
||||
private function defaultReasonForState(
|
||||
?CompareState $state,
|
||||
?CompareResultTrustLevel $explicitTrustLevel = null,
|
||||
): CompareResultReason {
|
||||
if ($state === CompareState::NoDrift) {
|
||||
return $this->trustedVerifiedReason($explicitTrustLevel, CompareResultReason::VerifiedNoDrift);
|
||||
}
|
||||
|
||||
if ($state === CompareState::Drift) {
|
||||
return $this->trustedVerifiedReason($explicitTrustLevel, CompareResultReason::VerifiedDriftDetected);
|
||||
}
|
||||
|
||||
return match ($state) {
|
||||
CompareState::Unsupported => CompareResultReason::CompareNotSupported,
|
||||
CompareState::Ambiguous => CompareResultReason::UnresolvedAmbiguousIdentity,
|
||||
CompareState::Failed => CompareResultReason::CompareFailed,
|
||||
default => CompareResultReason::MissingLocalEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
private function trustedVerifiedReason(
|
||||
?CompareResultTrustLevel $explicitTrustLevel,
|
||||
CompareResultReason $verifiedReason,
|
||||
): CompareResultReason {
|
||||
return $this->reasonAllowedByTrust($verifiedReason, $explicitTrustLevel);
|
||||
}
|
||||
|
||||
private function reasonAllowedByTrust(
|
||||
CompareResultReason $reason,
|
||||
?CompareResultTrustLevel $explicitTrustLevel,
|
||||
): CompareResultReason {
|
||||
if (! $this->requiresTrustedComparison($reason)) {
|
||||
return $reason;
|
||||
}
|
||||
|
||||
return $this->allowsVerifiedComparison($explicitTrustLevel)
|
||||
? $reason
|
||||
: CompareResultReason::UnresolvedLowTrustMatch;
|
||||
}
|
||||
|
||||
private function allowsVerifiedComparison(?CompareResultTrustLevel $trustLevel): bool
|
||||
{
|
||||
return in_array($trustLevel, [
|
||||
CompareResultTrustLevel::High,
|
||||
CompareResultTrustLevel::Medium,
|
||||
], true);
|
||||
}
|
||||
|
||||
private function requiresTrustedComparison(CompareResultReason $reason): bool
|
||||
{
|
||||
return in_array($reason, [
|
||||
CompareResultReason::VerifiedNoDrift,
|
||||
CompareResultReason::VerifiedDriftDetected,
|
||||
CompareResultReason::ResolvedActiveBinding,
|
||||
CompareResultReason::ResolvedCanonicalIdentity,
|
||||
CompareResultReason::ResolvedProviderIdentity,
|
||||
], true);
|
||||
}
|
||||
|
||||
private function identityStatusForMatching(MatchingOutcome $outcome, CompareResultReason $reason): CompareResultIdentityStatus
|
||||
{
|
||||
return match ($reason) {
|
||||
CompareResultReason::ResolvedActiveBinding => CompareResultIdentityStatus::BindingResolved,
|
||||
CompareResultReason::ResolvedCanonicalIdentity => CompareResultIdentityStatus::CanonicalizationResolved,
|
||||
CompareResultReason::ResolvedProviderIdentity,
|
||||
CompareResultReason::VerifiedNoDrift,
|
||||
CompareResultReason::VerifiedDriftDetected => CompareResultIdentityStatus::Resolved,
|
||||
CompareResultReason::MissingLocalEvidence,
|
||||
CompareResultReason::MissingProviderResource => CompareResultIdentityStatus::Missing,
|
||||
CompareResultReason::UnsupportedResourceClass,
|
||||
CompareResultReason::CompareNotSupported => CompareResultIdentityStatus::Unsupported,
|
||||
default => $outcome->isComparable()
|
||||
? CompareResultIdentityStatus::Resolved
|
||||
: CompareResultIdentityStatus::Unresolved,
|
||||
};
|
||||
}
|
||||
|
||||
private function identityStatusForReason(CompareResultReason $reason): CompareResultIdentityStatus
|
||||
{
|
||||
return match ($reason) {
|
||||
CompareResultReason::ResolvedActiveBinding => CompareResultIdentityStatus::BindingResolved,
|
||||
CompareResultReason::ResolvedCanonicalIdentity => CompareResultIdentityStatus::CanonicalizationResolved,
|
||||
CompareResultReason::ResolvedProviderIdentity,
|
||||
CompareResultReason::VerifiedNoDrift,
|
||||
CompareResultReason::VerifiedDriftDetected => CompareResultIdentityStatus::Resolved,
|
||||
CompareResultReason::MissingLocalEvidence,
|
||||
CompareResultReason::MissingProviderResource => CompareResultIdentityStatus::Missing,
|
||||
CompareResultReason::UnsupportedResourceClass,
|
||||
CompareResultReason::CompareNotSupported => CompareResultIdentityStatus::Unsupported,
|
||||
default => CompareResultIdentityStatus::Unresolved,
|
||||
};
|
||||
}
|
||||
|
||||
private function comparisonStatusForReason(CompareResultReason $reason, ?CompareState $state): CompareResultComparisonStatus
|
||||
{
|
||||
return match ($reason) {
|
||||
CompareResultReason::VerifiedNoDrift => CompareResultComparisonStatus::NoDrift,
|
||||
CompareResultReason::VerifiedDriftDetected => CompareResultComparisonStatus::DriftDetected,
|
||||
CompareResultReason::CompareFailed => CompareResultComparisonStatus::CompareFailed,
|
||||
CompareResultReason::CompareNotSupported,
|
||||
CompareResultReason::UnsupportedResourceClass => CompareResultComparisonStatus::CompareNotSupported,
|
||||
default => CompareResultComparisonStatus::NotCompared,
|
||||
};
|
||||
}
|
||||
|
||||
private function trustLevel(?string $trustLevel, CompareResultReason $reason): CompareResultTrustLevel
|
||||
{
|
||||
return match (strtolower((string) $trustLevel)) {
|
||||
'authoritative',
|
||||
'trustworthy',
|
||||
'high' => CompareResultTrustLevel::High,
|
||||
'limited_confidence',
|
||||
'limited',
|
||||
'medium' => CompareResultTrustLevel::Medium,
|
||||
'low' => CompareResultTrustLevel::Low,
|
||||
'unusable',
|
||||
'none',
|
||||
'untrusted' => CompareResultTrustLevel::Untrusted,
|
||||
'failed' => CompareResultTrustLevel::Failed,
|
||||
'not_applicable' => CompareResultTrustLevel::NotApplicable,
|
||||
default => $reason->defaultTrustLevel(),
|
||||
};
|
||||
}
|
||||
|
||||
private function explicitTrustLevel(?string $trustLevel): ?CompareResultTrustLevel
|
||||
{
|
||||
return match (strtolower((string) $trustLevel)) {
|
||||
'authoritative',
|
||||
'trustworthy',
|
||||
'high' => CompareResultTrustLevel::High,
|
||||
'limited_confidence',
|
||||
'limited',
|
||||
'medium' => CompareResultTrustLevel::Medium,
|
||||
'low' => CompareResultTrustLevel::Low,
|
||||
'unusable',
|
||||
'none',
|
||||
'untrusted' => CompareResultTrustLevel::Untrusted,
|
||||
'failed' => CompareResultTrustLevel::Failed,
|
||||
'not_applicable' => CompareResultTrustLevel::NotApplicable,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
use App\Support\OperationRunOutcome;
|
||||
|
||||
final class BaselineCompareRunSummaryClassifier
|
||||
{
|
||||
/**
|
||||
* @param list<CompareSubjectOutcome|array<string, mixed>> $subjectOutcomes
|
||||
* @param list<string> $uncoveredTypes
|
||||
* @return array{
|
||||
* run_outcome: string,
|
||||
* operation_outcome: string,
|
||||
* counts: array<string, array<string, int>>
|
||||
* }
|
||||
*/
|
||||
public function summarize(
|
||||
array $subjectOutcomes,
|
||||
int $driftFindingsCount,
|
||||
bool $warningsRecorded,
|
||||
bool $resumeTokenPresent = false,
|
||||
array $uncoveredTypes = [],
|
||||
): array {
|
||||
$counts = [
|
||||
'by_reason' => [],
|
||||
'by_category' => [],
|
||||
'by_actionability' => [],
|
||||
'by_readiness_impact' => [],
|
||||
];
|
||||
|
||||
$verifiedSubjectCount = 0;
|
||||
$blockingSubjectCount = 0;
|
||||
$failedSubjectCount = 0;
|
||||
|
||||
foreach ($subjectOutcomes as $outcome) {
|
||||
$outcome = $outcome instanceof CompareSubjectOutcome ? $outcome->toArray() : $outcome;
|
||||
|
||||
if (! is_array($outcome)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reason = $this->nonEmptyString($outcome['reason'] ?? null);
|
||||
$category = $this->nonEmptyString($outcome['category'] ?? null);
|
||||
$actionability = $this->nonEmptyString($outcome['actionability'] ?? null);
|
||||
$readinessImpact = $this->nonEmptyString($outcome['readiness_impact'] ?? null);
|
||||
|
||||
$this->increment($counts['by_reason'], $reason);
|
||||
$this->increment($counts['by_category'], $category);
|
||||
$this->increment($counts['by_actionability'], $actionability);
|
||||
$this->increment($counts['by_readiness_impact'], $readinessImpact);
|
||||
|
||||
if (in_array($category, [CompareResultCategory::Verified->value, CompareResultCategory::DriftDetected->value], true)) {
|
||||
$verifiedSubjectCount++;
|
||||
}
|
||||
|
||||
if (in_array($readinessImpact, [CompareResultReadinessImpact::CustomerBlocker->value, CompareResultReadinessImpact::InternalBlocker->value], true)) {
|
||||
$blockingSubjectCount++;
|
||||
}
|
||||
|
||||
if ($category === CompareResultCategory::Failed->value) {
|
||||
$failedSubjectCount++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($counts as &$bucket) {
|
||||
ksort($bucket);
|
||||
}
|
||||
unset($bucket);
|
||||
|
||||
$hasWarnings = $warningsRecorded || $resumeTokenPresent || $uncoveredTypes !== [];
|
||||
$runOutcome = $this->runOutcome(
|
||||
driftFindingsCount: $driftFindingsCount,
|
||||
verifiedSubjectCount: $verifiedSubjectCount,
|
||||
blockingSubjectCount: $blockingSubjectCount,
|
||||
failedSubjectCount: $failedSubjectCount,
|
||||
hasWarnings: $hasWarnings,
|
||||
);
|
||||
|
||||
return [
|
||||
'run_outcome' => $runOutcome->value,
|
||||
'operation_outcome' => in_array($runOutcome, [CompareRunOutcome::Completed, CompareRunOutcome::CompletedWithDrift], true)
|
||||
? OperationRunOutcome::Succeeded->value
|
||||
: OperationRunOutcome::PartiallySucceeded->value,
|
||||
'counts' => $counts,
|
||||
];
|
||||
}
|
||||
|
||||
private function runOutcome(
|
||||
int $driftFindingsCount,
|
||||
int $verifiedSubjectCount,
|
||||
int $blockingSubjectCount,
|
||||
int $failedSubjectCount,
|
||||
bool $hasWarnings,
|
||||
): CompareRunOutcome {
|
||||
if ($failedSubjectCount > 0 && $verifiedSubjectCount === 0 && $driftFindingsCount === 0) {
|
||||
return CompareRunOutcome::Failed;
|
||||
}
|
||||
|
||||
if ($blockingSubjectCount > 0 && $verifiedSubjectCount === 0 && $driftFindingsCount === 0) {
|
||||
return CompareRunOutcome::Blocked;
|
||||
}
|
||||
|
||||
if ($hasWarnings || $blockingSubjectCount > 0 || $failedSubjectCount > 0) {
|
||||
return CompareRunOutcome::Partial;
|
||||
}
|
||||
|
||||
return $driftFindingsCount > 0
|
||||
? CompareRunOutcome::CompletedWithDrift
|
||||
: CompareRunOutcome::Completed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $counts
|
||||
*/
|
||||
private function increment(array &$counts, ?string $key): void
|
||||
{
|
||||
if ($key === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$counts[$key] = ($counts[$key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
private function nonEmptyString(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
enum CompareResultActionability: string
|
||||
{
|
||||
case None = 'none';
|
||||
case OperatorActionRequired = 'operator_action_required';
|
||||
case ProviderDataRefreshRequired = 'provider_data_refresh_required';
|
||||
case BindingRequired = 'binding_required';
|
||||
case ScopeDecisionRequired = 'scope_decision_required';
|
||||
case ImplementationGap = 'implementation_gap';
|
||||
case Accepted = 'accepted';
|
||||
case Excluded = 'excluded';
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
enum CompareResultCategory: string
|
||||
{
|
||||
case Verified = 'verified';
|
||||
case DriftDetected = 'drift_detected';
|
||||
case ActionRequired = 'action_required';
|
||||
case MissingEvidence = 'missing_evidence';
|
||||
case MissingProviderResource = 'missing_provider_resource';
|
||||
case Unsupported = 'unsupported';
|
||||
case Limitation = 'limitation';
|
||||
case Excluded = 'excluded';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
enum CompareResultComparisonStatus: string
|
||||
{
|
||||
case NotCompared = 'not_compared';
|
||||
case NoDrift = 'no_drift';
|
||||
case DriftDetected = 'drift_detected';
|
||||
case CompareFailed = 'compare_failed';
|
||||
case CompareNotSupported = 'compare_not_supported';
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
enum CompareResultCoverageStatus: string
|
||||
{
|
||||
case FullyVerified = 'fully_verified';
|
||||
case VerifiedWithLimitations = 'verified_with_limitations';
|
||||
case InventoryOnly = 'inventory_only';
|
||||
case IdentityOnly = 'identity_only';
|
||||
case CanonicalOnly = 'canonical_only';
|
||||
case Unsupported = 'unsupported';
|
||||
case MissingLocalEvidence = 'missing_local_evidence';
|
||||
case MissingProviderResource = 'missing_provider_resource';
|
||||
case Excluded = 'excluded';
|
||||
case AcceptedLimitation = 'accepted_limitation';
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
enum CompareResultIdentityStatus: string
|
||||
{
|
||||
case Resolved = 'resolved';
|
||||
case BindingResolved = 'binding_resolved';
|
||||
case CanonicalizationResolved = 'canonicalization_resolved';
|
||||
case Unresolved = 'unresolved';
|
||||
case Missing = 'missing';
|
||||
case Unsupported = 'unsupported';
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
enum CompareResultReadinessImpact: string
|
||||
{
|
||||
case NoImpact = 'no_impact';
|
||||
case InternalLimitation = 'internal_limitation';
|
||||
case CustomerLimitation = 'customer_limitation';
|
||||
case CustomerBlocker = 'customer_blocker';
|
||||
case InternalBlocker = 'internal_blocker';
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
enum CompareResultReason: string
|
||||
{
|
||||
case VerifiedNoDrift = 'verified_no_drift';
|
||||
case VerifiedDriftDetected = 'verified_drift_detected';
|
||||
case ResolvedActiveBinding = 'resolved_active_binding';
|
||||
case ResolvedCanonicalIdentity = 'resolved_canonical_identity';
|
||||
case ResolvedProviderIdentity = 'resolved_provider_identity';
|
||||
case IdentityRequired = 'identity_required';
|
||||
case UnresolvedDuplicateCandidates = 'unresolved_duplicate_candidates';
|
||||
case UnresolvedLowTrustMatch = 'unresolved_low_trust_match';
|
||||
case UnresolvedAmbiguousIdentity = 'unresolved_ambiguous_identity';
|
||||
case MissingLocalEvidence = 'missing_local_evidence';
|
||||
case MissingProviderResource = 'missing_provider_resource';
|
||||
case UnsupportedResourceClass = 'unsupported_resource_class';
|
||||
case FoundationInventoryOnly = 'foundation_inventory_only';
|
||||
case FoundationIdentityOnly = 'foundation_identity_only';
|
||||
case FoundationCanonicalOnly = 'foundation_canonical_only';
|
||||
case AcceptedLimitation = 'accepted_limitation';
|
||||
case ExcludedNonGoverned = 'excluded_non_governed';
|
||||
case CompareNotSupported = 'compare_not_supported';
|
||||
case CompareFailed = 'compare_failed';
|
||||
|
||||
public function category(): CompareResultCategory
|
||||
{
|
||||
return match ($this) {
|
||||
self::VerifiedNoDrift => CompareResultCategory::Verified,
|
||||
self::VerifiedDriftDetected => CompareResultCategory::DriftDetected,
|
||||
self::IdentityRequired,
|
||||
self::UnresolvedDuplicateCandidates,
|
||||
self::UnresolvedLowTrustMatch,
|
||||
self::UnresolvedAmbiguousIdentity => CompareResultCategory::ActionRequired,
|
||||
self::MissingLocalEvidence => CompareResultCategory::MissingEvidence,
|
||||
self::MissingProviderResource => CompareResultCategory::MissingProviderResource,
|
||||
self::UnsupportedResourceClass,
|
||||
self::CompareNotSupported => CompareResultCategory::Unsupported,
|
||||
self::FoundationInventoryOnly,
|
||||
self::FoundationIdentityOnly,
|
||||
self::FoundationCanonicalOnly,
|
||||
self::AcceptedLimitation => CompareResultCategory::Limitation,
|
||||
self::ExcludedNonGoverned => CompareResultCategory::Excluded,
|
||||
self::CompareFailed => CompareResultCategory::Failed,
|
||||
self::ResolvedActiveBinding,
|
||||
self::ResolvedCanonicalIdentity,
|
||||
self::ResolvedProviderIdentity => CompareResultCategory::Verified,
|
||||
};
|
||||
}
|
||||
|
||||
public function actionability(): CompareResultActionability
|
||||
{
|
||||
return match ($this) {
|
||||
self::VerifiedNoDrift,
|
||||
self::VerifiedDriftDetected,
|
||||
self::ResolvedActiveBinding,
|
||||
self::ResolvedCanonicalIdentity,
|
||||
self::ResolvedProviderIdentity => CompareResultActionability::None,
|
||||
self::IdentityRequired,
|
||||
self::UnresolvedDuplicateCandidates,
|
||||
self::UnresolvedLowTrustMatch,
|
||||
self::UnresolvedAmbiguousIdentity => CompareResultActionability::BindingRequired,
|
||||
self::MissingLocalEvidence => CompareResultActionability::ProviderDataRefreshRequired,
|
||||
self::MissingProviderResource => CompareResultActionability::OperatorActionRequired,
|
||||
self::UnsupportedResourceClass,
|
||||
self::CompareNotSupported => CompareResultActionability::ImplementationGap,
|
||||
self::FoundationInventoryOnly,
|
||||
self::FoundationIdentityOnly,
|
||||
self::FoundationCanonicalOnly,
|
||||
self::AcceptedLimitation => CompareResultActionability::Accepted,
|
||||
self::ExcludedNonGoverned => CompareResultActionability::Excluded,
|
||||
self::CompareFailed => CompareResultActionability::ImplementationGap,
|
||||
};
|
||||
}
|
||||
|
||||
public function readinessImpact(): CompareResultReadinessImpact
|
||||
{
|
||||
return match ($this) {
|
||||
self::VerifiedNoDrift,
|
||||
self::VerifiedDriftDetected,
|
||||
self::ResolvedActiveBinding,
|
||||
self::ResolvedCanonicalIdentity,
|
||||
self::ResolvedProviderIdentity,
|
||||
self::ExcludedNonGoverned => CompareResultReadinessImpact::NoImpact,
|
||||
self::FoundationInventoryOnly,
|
||||
self::FoundationIdentityOnly,
|
||||
self::FoundationCanonicalOnly,
|
||||
self::AcceptedLimitation => CompareResultReadinessImpact::CustomerLimitation,
|
||||
self::IdentityRequired,
|
||||
self::UnresolvedDuplicateCandidates,
|
||||
self::UnresolvedLowTrustMatch,
|
||||
self::UnresolvedAmbiguousIdentity,
|
||||
self::MissingLocalEvidence,
|
||||
self::MissingProviderResource => CompareResultReadinessImpact::CustomerBlocker,
|
||||
self::UnsupportedResourceClass,
|
||||
self::CompareNotSupported => CompareResultReadinessImpact::InternalLimitation,
|
||||
self::CompareFailed => CompareResultReadinessImpact::InternalBlocker,
|
||||
};
|
||||
}
|
||||
|
||||
public function coverageStatus(): CompareResultCoverageStatus
|
||||
{
|
||||
return match ($this) {
|
||||
self::VerifiedNoDrift,
|
||||
self::VerifiedDriftDetected,
|
||||
self::ResolvedActiveBinding,
|
||||
self::ResolvedCanonicalIdentity,
|
||||
self::ResolvedProviderIdentity => CompareResultCoverageStatus::FullyVerified,
|
||||
self::MissingLocalEvidence,
|
||||
self::IdentityRequired,
|
||||
self::UnresolvedDuplicateCandidates,
|
||||
self::UnresolvedLowTrustMatch,
|
||||
self::UnresolvedAmbiguousIdentity,
|
||||
self::CompareFailed => CompareResultCoverageStatus::MissingLocalEvidence,
|
||||
self::MissingProviderResource => CompareResultCoverageStatus::MissingProviderResource,
|
||||
self::UnsupportedResourceClass,
|
||||
self::CompareNotSupported => CompareResultCoverageStatus::Unsupported,
|
||||
self::FoundationInventoryOnly => CompareResultCoverageStatus::InventoryOnly,
|
||||
self::FoundationIdentityOnly => CompareResultCoverageStatus::IdentityOnly,
|
||||
self::FoundationCanonicalOnly => CompareResultCoverageStatus::CanonicalOnly,
|
||||
self::AcceptedLimitation => CompareResultCoverageStatus::AcceptedLimitation,
|
||||
self::ExcludedNonGoverned => CompareResultCoverageStatus::Excluded,
|
||||
};
|
||||
}
|
||||
|
||||
public function defaultTrustLevel(): CompareResultTrustLevel
|
||||
{
|
||||
return match ($this) {
|
||||
self::VerifiedNoDrift,
|
||||
self::VerifiedDriftDetected,
|
||||
self::ResolvedActiveBinding => CompareResultTrustLevel::High,
|
||||
self::ResolvedCanonicalIdentity,
|
||||
self::ResolvedProviderIdentity,
|
||||
self::FoundationInventoryOnly,
|
||||
self::FoundationIdentityOnly,
|
||||
self::FoundationCanonicalOnly,
|
||||
self::AcceptedLimitation => CompareResultTrustLevel::Medium,
|
||||
self::IdentityRequired,
|
||||
self::UnresolvedDuplicateCandidates,
|
||||
self::UnresolvedLowTrustMatch,
|
||||
self::UnresolvedAmbiguousIdentity,
|
||||
self::MissingLocalEvidence,
|
||||
self::MissingProviderResource,
|
||||
self::UnsupportedResourceClass,
|
||||
self::CompareNotSupported => CompareResultTrustLevel::Untrusted,
|
||||
self::ExcludedNonGoverned => CompareResultTrustLevel::NotApplicable,
|
||||
self::CompareFailed => CompareResultTrustLevel::Failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
enum CompareResultTrustLevel: string
|
||||
{
|
||||
case High = 'high';
|
||||
case Medium = 'medium';
|
||||
case Low = 'low';
|
||||
case Untrusted = 'untrusted';
|
||||
case NotApplicable = 'not_applicable';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
enum CompareRunOutcome: string
|
||||
{
|
||||
case Completed = 'completed';
|
||||
case CompletedWithDrift = 'completed_with_drift';
|
||||
case Partial = 'partial';
|
||||
case Blocked = 'blocked';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines\CompareSemantics;
|
||||
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* @implements Arrayable<string, mixed>
|
||||
*/
|
||||
final readonly class CompareSubjectOutcome implements Arrayable, JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $subject
|
||||
* @param array<string, mixed> $proof
|
||||
*/
|
||||
public function __construct(
|
||||
public CompareResultReason $reason,
|
||||
public CompareResultCategory $category,
|
||||
public CompareResultActionability $actionability,
|
||||
public CompareResultReadinessImpact $readinessImpact,
|
||||
public CompareResultIdentityStatus $identityStatus,
|
||||
public CompareResultComparisonStatus $comparisonStatus,
|
||||
public CompareResultCoverageStatus $coverageStatus,
|
||||
public CompareResultTrustLevel $trustLevel,
|
||||
public array $subject = [],
|
||||
public array $proof = [],
|
||||
) {}
|
||||
|
||||
public function isBlocking(): bool
|
||||
{
|
||||
return in_array($this->readinessImpact, [
|
||||
CompareResultReadinessImpact::CustomerBlocker,
|
||||
CompareResultReadinessImpact::InternalBlocker,
|
||||
], true);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'reason' => $this->reason->value,
|
||||
'category' => $this->category->value,
|
||||
'actionability' => $this->actionability->value,
|
||||
'readiness_impact' => $this->readinessImpact->value,
|
||||
'identity_status' => $this->identityStatus->value,
|
||||
'comparison_status' => $this->comparisonStatus->value,
|
||||
'coverage_status' => $this->coverageStatus->value,
|
||||
'trust_level' => $this->trustLevel->value,
|
||||
'subject' => $this->safeArray($this->subject),
|
||||
'proof' => $this->safeArray($this->proof),
|
||||
];
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $value
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
private function safeArray(array $value, bool $topLevel = true): array
|
||||
{
|
||||
$safe = [];
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
if (! $this->isSafeArrayKey($key, $topLevel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$safe[$key] = $this->safeValue($item);
|
||||
}
|
||||
|
||||
return $safe;
|
||||
}
|
||||
|
||||
private function safeValue(mixed $value): mixed
|
||||
{
|
||||
if (is_scalar($value) || $value === null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $this->safeArray($value, false);
|
||||
}
|
||||
|
||||
if ($value instanceof \Stringable) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
return get_debug_type($value);
|
||||
}
|
||||
|
||||
if (is_resource($value)) {
|
||||
return 'resource:'.get_resource_type($value);
|
||||
}
|
||||
|
||||
return get_debug_type($value);
|
||||
}
|
||||
|
||||
private function isSafeArrayKey(string|int $key, bool $topLevel): bool
|
||||
{
|
||||
if (is_string($key)) {
|
||||
return $key !== '';
|
||||
}
|
||||
|
||||
return ! $topLevel;
|
||||
}
|
||||
}
|
||||
@ -71,7 +71,7 @@ public static function ambiguous(BaselineSubjectDescriptor $subject, array $proo
|
||||
{
|
||||
return new self(
|
||||
status: self::Ambiguous,
|
||||
reasonCode: 'ambiguous_match',
|
||||
reasonCode: 'unresolved_duplicate_candidates',
|
||||
subject: $subject,
|
||||
trust: 'none',
|
||||
proof: $proof,
|
||||
@ -125,9 +125,13 @@ public static function unresolvedIdentity(BaselineSubjectDescriptor $subject, ar
|
||||
*/
|
||||
public static function unsupported(BaselineSubjectDescriptor $subject, array $proof = []): self
|
||||
{
|
||||
$reasonCode = is_string($proof['reason_code'] ?? null) && trim((string) $proof['reason_code']) !== ''
|
||||
? trim((string) $proof['reason_code'])
|
||||
: 'unsupported_resource_class';
|
||||
|
||||
return new self(
|
||||
status: self::Unsupported,
|
||||
reasonCode: 'unsupported_subject',
|
||||
reasonCode: $reasonCode,
|
||||
subject: $subject,
|
||||
trust: 'none',
|
||||
proof: $proof,
|
||||
|
||||
@ -8,9 +8,16 @@ enum ResolutionOutcome: string
|
||||
{
|
||||
case ResolvedPolicy = 'resolved_policy';
|
||||
case ResolvedInventory = 'resolved_inventory';
|
||||
case MissingLocalEvidence = 'missing_local_evidence';
|
||||
case MissingProviderResource = 'missing_provider_resource';
|
||||
case PolicyRecordMissing = 'policy_record_missing';
|
||||
case InventoryRecordMissing = 'inventory_record_missing';
|
||||
case FoundationInventoryOnly = 'foundation_inventory_only';
|
||||
case FoundationIdentityOnly = 'foundation_identity_only';
|
||||
case FoundationCanonicalOnly = 'foundation_canonical_only';
|
||||
case UnsupportedResourceClass = 'unsupported_resource_class';
|
||||
case UnresolvedDuplicateCandidates = 'unresolved_duplicate_candidates';
|
||||
case UnresolvedAmbiguousIdentity = 'unresolved_ambiguous_identity';
|
||||
case ResolutionTypeMismatch = 'resolution_type_mismatch';
|
||||
case UnresolvableSubject = 'unresolvable_subject';
|
||||
case InvalidSupportConfig = 'invalid_support_config';
|
||||
|
||||
@ -53,12 +53,10 @@ public function resolved(SubjectDescriptor $descriptor, ?string $sourceModelFoun
|
||||
|
||||
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,
|
||||
resolutionOutcome: ResolutionOutcome::MissingLocalEvidence,
|
||||
reasonCode: 'missing_local_evidence',
|
||||
operatorActionCategory: $descriptor->expectsPolicy() ? OperatorActionCategory::RunPolicySyncOrBackup : OperatorActionCategory::RunInventorySync,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
sourceModelExpected: $descriptor->sourceModelExpected,
|
||||
@ -69,7 +67,7 @@ public function structuralInventoryOnly(SubjectDescriptor $descriptor): Resoluti
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::FoundationInventoryOnly,
|
||||
reasonCode: 'foundation_not_policy_backed',
|
||||
reasonCode: 'foundation_inventory_only',
|
||||
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
||||
structural: true,
|
||||
retryable: false,
|
||||
@ -105,8 +103,8 @@ public function duplicateSubject(SubjectDescriptor $descriptor): ResolutionOutco
|
||||
public function ambiguousMatch(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
||||
{
|
||||
return new ResolutionOutcomeRecord(
|
||||
resolutionOutcome: ResolutionOutcome::AmbiguousMatch,
|
||||
reasonCode: 'ambiguous_match',
|
||||
resolutionOutcome: ResolutionOutcome::UnresolvedAmbiguousIdentity,
|
||||
reasonCode: 'unresolved_ambiguous_identity',
|
||||
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
||||
structural: false,
|
||||
retryable: false,
|
||||
|
||||
@ -261,12 +261,9 @@ private function reviewComposeHeadline(
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
): string {
|
||||
return match (true) {
|
||||
$artifactTruth?->contentState === 'partial' && $artifactTruth?->freshnessState === 'stale'
|
||||
=> 'The review was generated, but missing sections and stale evidence keep it from being decision-grade.',
|
||||
$artifactTruth?->contentState === 'partial'
|
||||
=> 'The review was generated, but required sections are still incomplete.',
|
||||
$artifactTruth?->freshnessState === 'stale'
|
||||
=> 'The review was generated, but it relies on stale evidence.',
|
||||
$artifactTruth?->contentState === 'partial' && $artifactTruth?->freshnessState === 'stale' => 'The review was generated, but missing sections and stale evidence keep it from being decision-grade.',
|
||||
$artifactTruth?->contentState === 'partial' => 'The review was generated, but required sections are still incomplete.',
|
||||
$artifactTruth?->freshnessState === 'stale' => 'The review was generated, but it relies on stale evidence.',
|
||||
default => $operatorExplanation?->headline
|
||||
?? $dominantCause['explanation']
|
||||
?? 'The review needs follow-up before it should guide action.',
|
||||
@ -282,10 +279,8 @@ private function reviewPackHeadline(
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
): string {
|
||||
return match (true) {
|
||||
$artifactTruth?->publicationReadiness === 'blocked'
|
||||
=> 'The pack did not produce a shareable artifact yet.',
|
||||
$artifactTruth?->publicationReadiness === 'internal_only'
|
||||
=> 'The pack finished, but it should stay internal until the source review is refreshed.',
|
||||
$artifactTruth?->publicationReadiness === 'blocked' => 'The pack did not produce a shareable artifact yet.',
|
||||
$artifactTruth?->publicationReadiness === 'internal_only' => 'The pack finished, but it should stay internal until the source review is refreshed.',
|
||||
default => $operatorExplanation?->headline
|
||||
?? $dominantCause['explanation']
|
||||
?? 'The review pack needs follow-up before it is shared.',
|
||||
@ -751,7 +746,7 @@ private function baselineCompareCandidates(array &$candidates, array $context):
|
||||
? "{$count} policy types were left without proven compare coverage."
|
||||
: 'Coverage proof was missing, so TenantPilot suppressed part of the normal compare output.';
|
||||
|
||||
$this->pushCandidate($candidates, 'coverage_unproven', 'Coverage proof missing', $explanation, 81);
|
||||
$this->pushCandidate($candidates, BaselineCompareReasonCode::CoverageUnproven->value, 'Coverage proof missing', $explanation, 81);
|
||||
}
|
||||
|
||||
if (is_string($resumeToken) && trim($resumeToken) !== '') {
|
||||
|
||||
@ -251,5 +251,5 @@
|
||||
expect(data_get($context, 'baseline_compare.fidelity'))->toBe('meta');
|
||||
expect(data_get($context, 'baseline_compare.coverage.resolved_meta'))->toBe(0);
|
||||
expect(data_get($context, 'baseline_compare.coverage.baseline_content'))->toBe(1);
|
||||
expect(data_get($context, 'baseline_compare.evidence_gaps.missing_current'))->toBe(1);
|
||||
expect(data_get($context, 'baseline_compare.evidence_gaps.missing_local_evidence'))->toBe(1);
|
||||
});
|
||||
|
||||
@ -109,19 +109,21 @@
|
||||
)->toBe(0);
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
expect(data_get($context, 'baseline_compare.evidence_gaps.by_reason.ambiguous_match'))->toBe(1);
|
||||
expect(data_get($context, 'baseline_compare.evidence_gaps.by_reason.unresolved_duplicate_candidates'))->toBe(1)
|
||||
->and(data_get($context, 'baseline_compare.evidence_gaps.by_actionability.binding_required'))->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');
|
||||
$ambiguousSubject = collect($gapSubjects)->firstWhere('reason_code', 'unresolved_duplicate_candidates');
|
||||
|
||||
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, 'resolution_outcome'))->toBe('unresolved_ambiguous_identity')
|
||||
->and(data_get($ambiguousSubject, 'operator_action_category'))->toBe('inspect_subject_mapping')
|
||||
->and(data_get($ambiguousSubject, 'semantic_outcome.reason'))->toBe('unresolved_duplicate_candidates')
|
||||
->and(data_get($ambiguousSubject, 'subject_key'))->toBe($subjectKey);
|
||||
});
|
||||
|
||||
|
||||
@ -466,7 +466,8 @@
|
||||
$compareRun->refresh();
|
||||
|
||||
expect($compareRun->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe('coverage_unproven');
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBeNull();
|
||||
expect(data_get($compareRun->context, 'baseline_compare.result_semantics.run_outcome'))->toBe('partial');
|
||||
expect(data_get($compareRun->context, 'baseline_compare.coverage.uncovered_types'))->toBe(['intuneRoleDefinition']);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.rbac_role_definitions'))->toBe([
|
||||
'total_compared' => 0,
|
||||
@ -475,10 +476,12 @@
|
||||
'missing' => 0,
|
||||
'unexpected' => 0,
|
||||
]);
|
||||
|
||||
expect(
|
||||
Finding::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->where('source', 'baseline.compare')
|
||||
->where('policy_type', 'intuneRoleDefinition')
|
||||
->count()
|
||||
)->toBe(0);
|
||||
});
|
||||
|
||||
@ -206,5 +206,6 @@
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.execution_diagnostics.failed'))->toBeTrue()
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.execution_diagnostics.exception_class'))->toBe(RuntimeException::class)
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.state_counts'))->toBe([])
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.strategy_failed'))->toBe(1);
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.compare_failed'))->toBe(1)
|
||||
->and(data_get($run->context, 'baseline_compare.result_semantics.counts.by_reason.compare_failed'))->toBe(1);
|
||||
});
|
||||
|
||||
@ -129,8 +129,10 @@
|
||||
->and(data_get($run->context, 'baseline_compare.provider_resource_bindings'))->toBeNull()
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||
->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);
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.missing_local_evidence'))->toBe(1)
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.foundation_inventory_only'))->toBe(1)
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_category.missing_evidence'))->toBe(1)
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_category.limitation'))->toBe(1);
|
||||
|
||||
$subjects = data_get($run->context, 'baseline_compare.evidence_gaps.subjects');
|
||||
|
||||
@ -139,12 +141,12 @@
|
||||
|
||||
$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')
|
||||
expect(data_get($subjectsByType['deviceConfiguration'], 'resolution_outcome'))->toBe('missing_local_evidence')
|
||||
->and(data_get($subjectsByType['deviceConfiguration'], 'reason_code'))->toBe('missing_local_evidence')
|
||||
->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'], 'reason_code'))->toBe('foundation_inventory_only')
|
||||
->and(data_get($subjectsByType['roleScopeTag'], 'operator_action_category'))->toBe('product_follow_up');
|
||||
});
|
||||
|
||||
|
||||
@ -133,11 +133,11 @@
|
||||
'baseline_compare' => [
|
||||
'evidence_gaps' => [
|
||||
'count' => 1,
|
||||
'by_reason' => ['ambiguous_match' => 1],
|
||||
'by_reason' => ['unresolved_duplicate_candidates' => 1],
|
||||
'subjects' => [
|
||||
$this->baselineCompareMatrixGap('deviceConfiguration', 'wifi-corp-profile', [
|
||||
'reason_code' => 'ambiguous_match',
|
||||
'resolution_outcome' => 'ambiguous_match',
|
||||
'reason_code' => 'unresolved_duplicate_candidates',
|
||||
'resolution_outcome' => 'unresolved_ambiguous_identity',
|
||||
]),
|
||||
],
|
||||
],
|
||||
@ -212,7 +212,7 @@
|
||||
->and($cellsByTenant[(int) $missingTenant->getKey()]['state'] ?? null)->toBe('missing')
|
||||
->and($cellsByTenant[(int) $missingTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
|
||||
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['state'] ?? null)->toBe('ambiguous')
|
||||
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match')
|
||||
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Unresolved Duplicate Candidates')
|
||||
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared')
|
||||
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended')
|
||||
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Governed subject coverage was not proven')
|
||||
|
||||
@ -77,8 +77,8 @@
|
||||
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
||||
->and(data_get($context, 'baseline_compare.matching.active_bindings_considered'))->toBe(1)
|
||||
->and(data_get($context, 'baseline_compare.matching.by_reason.active_provider_resource_binding'))->toBe(1)
|
||||
->and(data_get($context, 'baseline_compare.evidence_gaps.by_reason.ambiguous_match'))->toBeNull()
|
||||
->and(data_get($context, 'baseline_compare.matching.by_reason.resolved_active_binding'))->toBe(1)
|
||||
->and(data_get($context, 'baseline_compare.evidence_gaps.by_reason.unresolved_duplicate_candidates'))->toBeNull()
|
||||
->and(data_get($context, 'baseline_compare.evidence_gaps.by_reason.identity_required'))->toBeNull()
|
||||
->and(Finding::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
@ -122,6 +122,18 @@
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
InventoryItem::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'external_id' => '',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Unresolved current inventory row',
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Unresolved current inventory row',
|
||||
],
|
||||
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$run = spec382RunCompare($tenant, $user, $profile, $snapshot);
|
||||
|
||||
@ -130,8 +142,12 @@
|
||||
|
||||
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value)
|
||||
->and(data_get($context, 'baseline_compare.matching.by_reason.identity_required'))->toBe(1)
|
||||
->and(data_get($context, 'baseline_compare.evidence_gaps.by_reason.identity_required'))->toBe(1)
|
||||
->and(data_get($context, 'baseline_compare.matching.by_reason.canonical_subject_key'))->toBeNull();
|
||||
->and(data_get($context, 'baseline_compare.evidence_gaps.by_reason.identity_required'))->toBe(2)
|
||||
->and(data_get($context, 'baseline_compare.result_semantics.counts.by_reason.identity_required'))->toBe(2)
|
||||
->and(data_get($context, 'baseline_compare.result_semantics.counts.by_category.action_required'))->toBe(2)
|
||||
->and(data_get($context, 'baseline_compare.result_semantics.counts.by_actionability.binding_required'))->toBe(2)
|
||||
->and(data_get($context, 'baseline_compare.result_semantics.counts.by_readiness_impact.customer_blocker'))->toBe(2)
|
||||
->and(data_get($context, 'baseline_compare.matching.by_reason.resolved_canonical_identity'))->toBeNull();
|
||||
});
|
||||
|
||||
function spec382CompareProfileAndSnapshot($tenant): array
|
||||
|
||||
@ -276,19 +276,19 @@ public function capture(
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1);
|
||||
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.missing_local_evidence'))->toBe(2);
|
||||
|
||||
$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');
|
||||
$missingSubject = collect($subjects)->firstWhere('subject_external_id', 'missing-capture-policy');
|
||||
|
||||
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_key'))->toStartWith('provider-resource:v1:baseline:policy_backed:deviceconfiguration:')
|
||||
->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, 'resolution_outcome'))->toBe('missing_local_evidence')
|
||||
->and(data_get($missingSubject, 'operator_action_category'))->toBe('run_policy_sync_or_backup');
|
||||
});
|
||||
|
||||
@ -569,7 +569,7 @@ public function capture(
|
||||
)->toBe(0);
|
||||
});
|
||||
|
||||
it('records coverage_unproven when findings are suppressed due to missing coverage proof', function (): void {
|
||||
it('records compare coverage incomplete when findings are suppressed due to missing coverage proof', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
|
||||
@ -63,7 +63,10 @@
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'context' => BaselineSubjectResolutionFixtures::compareContext([
|
||||
BaselineSubjectResolutionFixtures::structuredGap(),
|
||||
BaselineSubjectResolutionFixtures::structuredGap([
|
||||
'resolution_outcome' => 'missing_local_evidence',
|
||||
'reason_code' => 'missing_local_evidence',
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
@ -112,7 +115,10 @@
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'context' => BaselineSubjectResolutionFixtures::compareContext([
|
||||
BaselineSubjectResolutionFixtures::structuredGap(),
|
||||
BaselineSubjectResolutionFixtures::structuredGap([
|
||||
'resolution_outcome' => 'missing_local_evidence',
|
||||
'reason_code' => 'missing_local_evidence',
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@ -79,7 +79,7 @@ public function compare(
|
||||
externalSubjectId: (string) $baselineItem['subject_external_id'],
|
||||
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
|
||||
compareState: CompareState::Incomplete,
|
||||
reasonCode: 'missing_current',
|
||||
reasonCode: 'missing_local_evidence',
|
||||
baselineAvailability: 'available',
|
||||
currentStateAvailability: 'unknown',
|
||||
trustLevel: 'unusable',
|
||||
@ -137,7 +137,7 @@ public function compare(
|
||||
externalSubjectId: (string) $currentItem['subject_external_id'],
|
||||
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $currentItem['subject_key']) ?: $currentItem['subject_key']),
|
||||
compareState: CompareState::Incomplete,
|
||||
reasonCode: 'missing_current',
|
||||
reasonCode: 'missing_local_evidence',
|
||||
baselineAvailability: 'missing',
|
||||
currentStateAvailability: 'unknown',
|
||||
trustLevel: 'unusable',
|
||||
@ -300,7 +300,7 @@ private function gapResult(
|
||||
'resolution_outcome' => ResolutionOutcome::CaptureFailed->value,
|
||||
'operator_action_category' => OperatorActionCategory::RunInventorySync->value,
|
||||
'structural' => false,
|
||||
'retryable' => $reasonCode === 'missing_current',
|
||||
'retryable' => $reasonCode === 'missing_local_evidence',
|
||||
'reason_code' => $reasonCode,
|
||||
'search_text' => strtolower(implode(' ', [$policyType, $subjectKey, $reasonCode])),
|
||||
],
|
||||
@ -382,4 +382,4 @@ public function all(): array
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,8 +30,8 @@ function baselineCompareEvidenceGapBuckets(): array
|
||||
'subject_key' => 'WiFi-Corp-Profile',
|
||||
'subject_class' => 'policy_backed',
|
||||
'resolution_path' => 'policy',
|
||||
'resolution_outcome' => 'ambiguous_match',
|
||||
'reason_code' => 'ambiguous_match',
|
||||
'resolution_outcome' => 'unresolved_ambiguous_identity',
|
||||
'reason_code' => 'unresolved_duplicate_candidates',
|
||||
'operator_action_category' => 'inspect_subject_mapping',
|
||||
]),
|
||||
BaselineSubjectResolutionFixtures::structuredGap([
|
||||
@ -39,8 +39,8 @@ function baselineCompareEvidenceGapBuckets(): array
|
||||
'subject_key' => 'VPN-Always-On',
|
||||
'subject_class' => 'policy_backed',
|
||||
'resolution_path' => 'policy',
|
||||
'resolution_outcome' => 'ambiguous_match',
|
||||
'reason_code' => 'ambiguous_match',
|
||||
'resolution_outcome' => 'unresolved_ambiguous_identity',
|
||||
'reason_code' => 'unresolved_duplicate_candidates',
|
||||
'operator_action_category' => 'inspect_subject_mapping',
|
||||
]),
|
||||
BaselineSubjectResolutionFixtures::structuredGap([
|
||||
@ -48,22 +48,22 @@ function baselineCompareEvidenceGapBuckets(): array
|
||||
'subject_key' => 'Windows-Encryption-Required',
|
||||
'subject_class' => 'policy_backed',
|
||||
'resolution_path' => 'policy',
|
||||
'resolution_outcome' => 'ambiguous_match',
|
||||
'reason_code' => 'ambiguous_match',
|
||||
'resolution_outcome' => 'unresolved_ambiguous_identity',
|
||||
'reason_code' => 'unresolved_duplicate_candidates',
|
||||
'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',
|
||||
'resolution_outcome' => 'missing_local_evidence',
|
||||
'reason_code' => 'missing_local_evidence',
|
||||
'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',
|
||||
'resolution_outcome' => 'missing_local_evidence',
|
||||
'reason_code' => 'missing_local_evidence',
|
||||
'operator_action_category' => 'run_policy_sync_or_backup',
|
||||
]),
|
||||
]))['buckets'];
|
||||
@ -96,7 +96,7 @@ function baselineCompareEvidenceGapBuckets(): array
|
||||
->assertSee('Next action')
|
||||
->assertSee('Subject key')
|
||||
->assertSee('Policy-backed')
|
||||
->assertSee('Policy record missing')
|
||||
->assertSee('Missing local evidence')
|
||||
->assertSee('Run policy sync or backup')
|
||||
->assertSee(TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceConfiguration')->label)
|
||||
->assertSee(TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceCompliancePolicy')->label);
|
||||
@ -115,7 +115,7 @@ function baselineCompareEvidenceGapBuckets(): array
|
||||
'buckets' => baselineCompareEvidenceGapBuckets(),
|
||||
'context' => 'tenant-landing-filters',
|
||||
])
|
||||
->filterTable('reason_code', 'policy_record_missing')
|
||||
->filterTable('reason_code', 'missing_local_evidence')
|
||||
->assertSee('Retired-Compliance-Policy')
|
||||
->assertDontSee('VPN-Always-On')
|
||||
->filterTable('policy_type', 'deviceCompliancePolicy')
|
||||
@ -134,7 +134,7 @@ function baselineCompareEvidenceGapBuckets(): array
|
||||
'evidence_gaps' => [
|
||||
'count' => 2,
|
||||
'by_reason' => [
|
||||
'policy_record_missing' => 2,
|
||||
'missing_local_evidence' => 2,
|
||||
],
|
||||
'subjects' => [],
|
||||
],
|
||||
|
||||
@ -26,28 +26,28 @@ function baselineCompareLandingGapContext(): array
|
||||
BaselineSubjectResolutionFixtures::structuredGap([
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'subject_key' => 'WiFi-Corp-Profile',
|
||||
'resolution_outcome' => 'ambiguous_match',
|
||||
'reason_code' => 'ambiguous_match',
|
||||
'resolution_outcome' => 'unresolved_ambiguous_identity',
|
||||
'reason_code' => 'unresolved_duplicate_candidates',
|
||||
'operator_action_category' => 'inspect_subject_mapping',
|
||||
]),
|
||||
BaselineSubjectResolutionFixtures::structuredGap([
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'subject_key' => 'VPN-Always-On',
|
||||
'resolution_outcome' => 'ambiguous_match',
|
||||
'reason_code' => 'ambiguous_match',
|
||||
'resolution_outcome' => 'unresolved_ambiguous_identity',
|
||||
'reason_code' => 'unresolved_duplicate_candidates',
|
||||
'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',
|
||||
'resolution_outcome' => 'missing_local_evidence',
|
||||
'reason_code' => 'missing_local_evidence',
|
||||
'operator_action_category' => 'run_policy_sync_or_backup',
|
||||
]),
|
||||
]), [
|
||||
'baseline_compare' => [
|
||||
'subjects_total' => 50,
|
||||
'reason_code' => 'evidence_capture_incomplete',
|
||||
'reason_code' => 'compare_evidence_incomplete',
|
||||
'fidelity' => 'meta',
|
||||
'coverage' => [
|
||||
'proof' => true,
|
||||
@ -129,8 +129,8 @@ function seedBaselineCompareLandingGapRun(\App\Models\ManagedEnvironment $tenant
|
||||
->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('Duplicate provider candidates')
|
||||
->assertSee('Missing local evidence')
|
||||
->assertSee('Subject class')
|
||||
->assertSee('Outcome')
|
||||
->assertSee('Next action')
|
||||
|
||||
@ -111,7 +111,7 @@
|
||||
'evidence_gaps' => [
|
||||
'count' => 2,
|
||||
'by_reason' => [
|
||||
'policy_record_missing' => 2,
|
||||
'missing_local_evidence' => 2,
|
||||
],
|
||||
],
|
||||
],
|
||||
@ -239,7 +239,7 @@
|
||||
'evidence_gaps' => [
|
||||
'count' => 1,
|
||||
'by_reason' => [
|
||||
'strategy_failed' => 1,
|
||||
'compare_failed' => 1,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
@ -13,7 +12,6 @@
|
||||
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);
|
||||
@ -29,8 +27,8 @@ function structuredGapSurfaceContext(): array
|
||||
'subject_key' => 'WiFi-Corp-Profile',
|
||||
'subject_class' => 'policy_backed',
|
||||
'resolution_path' => 'policy',
|
||||
'resolution_outcome' => 'policy_record_missing',
|
||||
'reason_code' => 'policy_record_missing',
|
||||
'resolution_outcome' => 'missing_local_evidence',
|
||||
'reason_code' => 'missing_local_evidence',
|
||||
'operator_action_category' => 'run_policy_sync_or_backup',
|
||||
]),
|
||||
BaselineSubjectResolutionFixtures::structuredGap([
|
||||
@ -39,13 +37,13 @@ function structuredGapSurfaceContext(): array
|
||||
'subject_class' => 'foundation_backed',
|
||||
'resolution_path' => 'foundation_inventory',
|
||||
'resolution_outcome' => 'foundation_inventory_only',
|
||||
'reason_code' => 'foundation_not_policy_backed',
|
||||
'reason_code' => 'foundation_inventory_only',
|
||||
'operator_action_category' => 'product_follow_up',
|
||||
'structural' => true,
|
||||
]),
|
||||
]), [
|
||||
'baseline_compare' => [
|
||||
'reason_code' => 'evidence_capture_incomplete',
|
||||
'reason_code' => 'compare_evidence_incomplete',
|
||||
'coverage' => [
|
||||
'proof' => true,
|
||||
'covered_types' => ['deviceConfiguration', 'roleScopeTag'],
|
||||
@ -80,8 +78,8 @@ function structuredGapSurfaceContext(): array
|
||||
->assertSee('Evidence gap details')
|
||||
->assertSee('Policy-backed')
|
||||
->assertSee('Foundation-backed')
|
||||
->assertSee('Policy record missing')
|
||||
->assertSee('Foundation not policy-backed')
|
||||
->assertSee('Missing local evidence')
|
||||
->assertSee('Foundation inventory only')
|
||||
->assertSee('Run policy sync or backup')
|
||||
->assertSee('Product follow-up')
|
||||
->assertSee('WiFi-Corp-Profile')
|
||||
@ -129,7 +127,7 @@ function structuredGapSurfaceContext(): array
|
||||
->assertSee('Outcome')
|
||||
->assertSee('Next action')
|
||||
->assertSee('Foundation-backed')
|
||||
->assertSee('Foundation not policy-backed')
|
||||
->assertSee('Foundation inventory only')
|
||||
->assertSee('Product follow-up')
|
||||
->assertSee('scope-tag-finance');
|
||||
});
|
||||
|
||||
@ -154,7 +154,7 @@ function makeHealthyBackupForRecoveryNeedsAttention(\App\Models\ManagedEnvironme
|
||||
'evidence_gaps' => [
|
||||
'count' => 2,
|
||||
'by_reason' => [
|
||||
'policy_record_missing' => 2,
|
||||
'missing_local_evidence' => 2,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@ -142,7 +142,7 @@ function visibleLivewireText(Testable $component): string
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => [
|
||||
'baseline_compare' => [
|
||||
'reason_code' => 'evidence_capture_incomplete',
|
||||
'reason_code' => 'compare_evidence_incomplete',
|
||||
'coverage' => [
|
||||
'proof' => false,
|
||||
],
|
||||
@ -209,7 +209,7 @@ function visibleLivewireText(Testable $component): string
|
||||
'evidence_gaps' => [
|
||||
'count' => 1,
|
||||
'by_reason' => [
|
||||
'strategy_failed' => 1,
|
||||
'compare_failed' => 1,
|
||||
],
|
||||
],
|
||||
'strategy' => [
|
||||
|
||||
@ -3,15 +3,15 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
@ -48,42 +48,42 @@ function baselineCompareGapContext(array $overrides = []): array
|
||||
BaselineSubjectResolutionFixtures::structuredGap([
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'subject_key' => 'WiFi-Corp-Profile',
|
||||
'resolution_outcome' => 'ambiguous_match',
|
||||
'reason_code' => 'ambiguous_match',
|
||||
'resolution_outcome' => 'unresolved_ambiguous_identity',
|
||||
'reason_code' => 'unresolved_duplicate_candidates',
|
||||
'operator_action_category' => 'inspect_subject_mapping',
|
||||
]),
|
||||
BaselineSubjectResolutionFixtures::structuredGap([
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'subject_key' => 'VPN-Always-On',
|
||||
'resolution_outcome' => 'ambiguous_match',
|
||||
'reason_code' => 'ambiguous_match',
|
||||
'resolution_outcome' => 'unresolved_ambiguous_identity',
|
||||
'reason_code' => 'unresolved_duplicate_candidates',
|
||||
'operator_action_category' => 'inspect_subject_mapping',
|
||||
]),
|
||||
BaselineSubjectResolutionFixtures::structuredGap([
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'subject_key' => 'Email-Exchange-Config',
|
||||
'resolution_outcome' => 'ambiguous_match',
|
||||
'reason_code' => 'ambiguous_match',
|
||||
'resolution_outcome' => 'unresolved_ambiguous_identity',
|
||||
'reason_code' => 'unresolved_duplicate_candidates',
|
||||
'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',
|
||||
'resolution_outcome' => 'missing_local_evidence',
|
||||
'reason_code' => 'missing_local_evidence',
|
||||
'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',
|
||||
'resolution_outcome' => 'missing_local_evidence',
|
||||
'reason_code' => 'missing_local_evidence',
|
||||
'operator_action_category' => 'run_policy_sync_or_backup',
|
||||
]),
|
||||
]), [
|
||||
'baseline_compare' => [
|
||||
'subjects_total' => 50,
|
||||
'reason_code' => 'evidence_capture_incomplete',
|
||||
'reason_code' => 'compare_evidence_incomplete',
|
||||
'fidelity' => 'meta',
|
||||
'coverage' => [
|
||||
'proof' => true,
|
||||
@ -413,8 +413,8 @@ function baselineCompareGapContext(array $overrides = []): array
|
||||
->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('Duplicate provider candidates')
|
||||
->assertSee('Missing local evidence')
|
||||
->assertSee('3 affected')
|
||||
->assertSee('2 affected')
|
||||
->assertSee('WiFi-Corp-Profile')
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
@ -49,7 +49,7 @@ function governanceRunViewer(TestCase $testCase, $user, ManagedEnvironment $tena
|
||||
'outcome' => 'partially_succeeded',
|
||||
'context' => [
|
||||
'baseline_compare' => [
|
||||
'reason_code' => 'coverage_unproven',
|
||||
'reason_code' => 'compare_coverage_incomplete',
|
||||
'coverage' => [
|
||||
'proof' => false,
|
||||
],
|
||||
|
||||
@ -110,13 +110,13 @@
|
||||
trustLevel: 'unusable',
|
||||
evidenceQuality: 'missing',
|
||||
diagnostics: [
|
||||
'reason_code' => 'strategy_failed',
|
||||
'gap_record' => ['reason_code' => 'strategy_failed'],
|
||||
'reason_code' => 'compare_failed',
|
||||
'gap_record' => ['reason_code' => 'compare_failed'],
|
||||
],
|
||||
);
|
||||
|
||||
expect($result->isGapState())->toBeTrue()
|
||||
->and($result->gapReasonCode())->toBe('strategy_failed')
|
||||
->and($result->gapRecord())->toBe(['reason_code' => 'strategy_failed']);
|
||||
->and($result->gapReasonCode())->toBe('compare_failed')
|
||||
->and($result->gapRecord())->toBe(['reason_code' => 'compare_failed']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,9 +16,10 @@
|
||||
$unsupported = $resolver->coverageFor('intuneRoleAssignment');
|
||||
|
||||
expect($inventoryOnly['coverage'])->toBe('inventory_only')
|
||||
->and($inventoryOnly['reason_code'])->toBe('foundation_not_policy_backed')
|
||||
->and($inventoryOnly['reason_code'])->toBe('foundation_inventory_only')
|
||||
->and($canonicalOnly['coverage'])->toBe('canonical_only')
|
||||
->and($canonicalOnly['reason_code'])->toBe('foundation_canonical_only')
|
||||
->and($canonicalOnly['identity_kind'])->toBe(ResourceIdentity::CanonicalBuiltin)
|
||||
->and($unsupported['coverage'])->toBe('unsupported')
|
||||
->and($unsupported['reason_code'])->toBe('unsupported_subject');
|
||||
->and($unsupported['reason_code'])->toBe('unsupported_resource_class');
|
||||
});
|
||||
|
||||
@ -0,0 +1,459 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Baselines\Compare\CompareFindingCandidate;
|
||||
use App\Support\Baselines\Compare\CompareState;
|
||||
use App\Support\Baselines\Compare\CompareSubjectIdentity;
|
||||
use App\Support\Baselines\Compare\CompareSubjectProjection;
|
||||
use App\Support\Baselines\Compare\CompareSubjectResult;
|
||||
use App\Support\Baselines\CompareSemantics\BaselineCompareOutcomeClassifier;
|
||||
use App\Support\Baselines\CompareSemantics\BaselineCompareRunSummaryClassifier;
|
||||
use App\Support\Baselines\CompareSemantics\CompareResultReason;
|
||||
use App\Support\Baselines\Matching\BaselineSubjectDescriptor;
|
||||
use App\Support\Baselines\Matching\MatchingOutcome;
|
||||
use App\Support\Baselines\SubjectClass;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\Resources\ProviderResourceDescriptor;
|
||||
use App\Support\Resources\ResourceIdentity;
|
||||
|
||||
it('defines the v1 provider-neutral compare result reason set', function (): void {
|
||||
expect(array_map(static fn (CompareResultReason $reason): string => $reason->value, CompareResultReason::cases()))
|
||||
->toBe([
|
||||
'verified_no_drift',
|
||||
'verified_drift_detected',
|
||||
'resolved_active_binding',
|
||||
'resolved_canonical_identity',
|
||||
'resolved_provider_identity',
|
||||
'identity_required',
|
||||
'unresolved_duplicate_candidates',
|
||||
'unresolved_low_trust_match',
|
||||
'unresolved_ambiguous_identity',
|
||||
'missing_local_evidence',
|
||||
'missing_provider_resource',
|
||||
'unsupported_resource_class',
|
||||
'foundation_inventory_only',
|
||||
'foundation_identity_only',
|
||||
'foundation_canonical_only',
|
||||
'accepted_limitation',
|
||||
'excluded_non_governed',
|
||||
'compare_not_supported',
|
||||
'compare_failed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps every v1 reason to category actionability readiness and default trust', function (): void {
|
||||
$expectations = [
|
||||
'verified_no_drift' => ['verified', 'none', 'no_impact', 'high'],
|
||||
'verified_drift_detected' => ['drift_detected', 'none', 'no_impact', 'high'],
|
||||
'resolved_active_binding' => ['verified', 'none', 'no_impact', 'high'],
|
||||
'resolved_canonical_identity' => ['verified', 'none', 'no_impact', 'medium'],
|
||||
'resolved_provider_identity' => ['verified', 'none', 'no_impact', 'medium'],
|
||||
'identity_required' => ['action_required', 'binding_required', 'customer_blocker', 'untrusted'],
|
||||
'unresolved_duplicate_candidates' => ['action_required', 'binding_required', 'customer_blocker', 'untrusted'],
|
||||
'unresolved_low_trust_match' => ['action_required', 'binding_required', 'customer_blocker', 'untrusted'],
|
||||
'unresolved_ambiguous_identity' => ['action_required', 'binding_required', 'customer_blocker', 'untrusted'],
|
||||
'missing_local_evidence' => ['missing_evidence', 'provider_data_refresh_required', 'customer_blocker', 'untrusted'],
|
||||
'missing_provider_resource' => ['missing_provider_resource', 'operator_action_required', 'customer_blocker', 'untrusted'],
|
||||
'unsupported_resource_class' => ['unsupported', 'implementation_gap', 'internal_limitation', 'untrusted'],
|
||||
'foundation_inventory_only' => ['limitation', 'accepted', 'customer_limitation', 'medium'],
|
||||
'foundation_identity_only' => ['limitation', 'accepted', 'customer_limitation', 'medium'],
|
||||
'foundation_canonical_only' => ['limitation', 'accepted', 'customer_limitation', 'medium'],
|
||||
'accepted_limitation' => ['limitation', 'accepted', 'customer_limitation', 'medium'],
|
||||
'excluded_non_governed' => ['excluded', 'excluded', 'no_impact', 'not_applicable'],
|
||||
'compare_not_supported' => ['unsupported', 'implementation_gap', 'internal_limitation', 'untrusted'],
|
||||
'compare_failed' => ['failed', 'implementation_gap', 'internal_blocker', 'failed'],
|
||||
];
|
||||
|
||||
foreach ($expectations as $reasonValue => [$category, $actionability, $readinessImpact, $trustLevel]) {
|
||||
$reason = CompareResultReason::from($reasonValue);
|
||||
|
||||
expect($reason->category()->value)->toBe($category)
|
||||
->and($reason->actionability()->value)->toBe($actionability)
|
||||
->and($reason->readinessImpact()->value)->toBe($readinessImpact)
|
||||
->and($reason->defaultTrustLevel()->value)->toBe($trustLevel);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not retain overloaded legacy reason values in the authoritative enum', function (): void {
|
||||
$legacyReasons = [
|
||||
'ambiguous_match',
|
||||
'policy_record_missing',
|
||||
'foundation_not_policy_backed',
|
||||
'missing_policy',
|
||||
'missing_current',
|
||||
'unsupported_subjects',
|
||||
'unsupported_subject',
|
||||
'coverage_unproven',
|
||||
'strategy_failed',
|
||||
];
|
||||
|
||||
foreach ($legacyReasons as $legacyReason) {
|
||||
expect(CompareResultReason::tryFrom($legacyReason))->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies matching gaps into actionability and readiness dimensions', function (): void {
|
||||
$subject = new BaselineSubjectDescriptor(
|
||||
subjectDomain: 'baseline',
|
||||
subjectClass: SubjectClass::PolicyBacked,
|
||||
subjectTypeKey: 'deviceConfiguration',
|
||||
subjectType: 'policy',
|
||||
subjectExternalId: 'policy-1',
|
||||
canonicalSubjectKey: null,
|
||||
displayLabel: 'Policy 1',
|
||||
);
|
||||
|
||||
$outcome = app(BaselineCompareOutcomeClassifier::class)
|
||||
->fromMatchingOutcome(MatchingOutcome::ambiguous($subject, ['candidate_count' => 2]))
|
||||
->toArray();
|
||||
|
||||
expect($outcome['reason'])->toBe('unresolved_duplicate_candidates')
|
||||
->and($outcome['category'])->toBe('action_required')
|
||||
->and($outcome['actionability'])->toBe('binding_required')
|
||||
->and($outcome['readiness_impact'])->toBe('customer_blocker');
|
||||
});
|
||||
|
||||
it('classifies strategy drift and no-drift results without collapsing gaps into findings', function (): void {
|
||||
$classifier = app(BaselineCompareOutcomeClassifier::class);
|
||||
|
||||
$noDrift = $classifier->fromStrategyResult(compareSemanticsSubjectResult(CompareState::NoDrift))->toArray();
|
||||
$gap = $classifier->fromStrategyResult(compareSemanticsSubjectResult(
|
||||
CompareState::Incomplete,
|
||||
'missing_local_evidence',
|
||||
))->toArray();
|
||||
|
||||
expect($noDrift['reason'])->toBe('verified_no_drift')
|
||||
->and($noDrift['comparison_status'])->toBe('no_drift')
|
||||
->and($gap['reason'])->toBe('missing_local_evidence')
|
||||
->and($gap['comparison_status'])->toBe('not_compared')
|
||||
->and($gap['category'])->toBe('missing_evidence');
|
||||
});
|
||||
|
||||
it('does not classify low unusable or none-trust strategy no-drift as verified no-drift', function (string $trustLevel): void {
|
||||
$outcome = app(BaselineCompareOutcomeClassifier::class)
|
||||
->fromStrategyResult(compareSemanticsSubjectResult(CompareState::NoDrift, trustLevel: $trustLevel))
|
||||
->toArray();
|
||||
|
||||
expect($outcome['reason'])->toBe('unresolved_low_trust_match')
|
||||
->and($outcome['comparison_status'])->toBe('not_compared')
|
||||
->and($outcome['category'])->toBe('action_required')
|
||||
->and($outcome['actionability'])->toBe('binding_required')
|
||||
->and($outcome['readiness_impact'])->toBe('customer_blocker')
|
||||
->and($outcome['actionability'])->not->toBe('none')
|
||||
->and($outcome['readiness_impact'])->not->toBe('no_impact');
|
||||
})->with([
|
||||
'low trust' => 'low',
|
||||
'unusable trust' => 'unusable',
|
||||
'none trust' => 'none',
|
||||
]);
|
||||
|
||||
it('does not classify low-trust strategy drift as verified drift', function (): void {
|
||||
$outcome = app(BaselineCompareOutcomeClassifier::class)
|
||||
->fromStrategyResult(compareSemanticsSubjectResult(CompareState::Drift, trustLevel: 'low'))
|
||||
->toArray();
|
||||
|
||||
expect($outcome['reason'])->toBe('unresolved_low_trust_match')
|
||||
->and($outcome['comparison_status'])->toBe('not_compared')
|
||||
->and($outcome['category'])->toBe('action_required')
|
||||
->and($outcome['actionability'])->toBe('binding_required')
|
||||
->and($outcome['readiness_impact'])->toBe('customer_blocker')
|
||||
->and($outcome['reason'])->not->toBe('verified_drift_detected');
|
||||
});
|
||||
|
||||
it('does not allow verified no-drift from reason without explicit trusted identity', function (?string $trustLevel): void {
|
||||
$outcome = app(BaselineCompareOutcomeClassifier::class)
|
||||
->fromReason('verified_no_drift', trustLevel: $trustLevel)
|
||||
->toArray();
|
||||
|
||||
expect($outcome['reason'])->toBe('unresolved_low_trust_match')
|
||||
->and($outcome['comparison_status'])->toBe('not_compared')
|
||||
->and($outcome['category'])->toBe('action_required')
|
||||
->and($outcome['actionability'])->toBe('binding_required')
|
||||
->and($outcome['readiness_impact'])->toBe('customer_blocker')
|
||||
->and($outcome['reason'])->not->toBe('verified_no_drift')
|
||||
->and($outcome['category'])->not->toBe('verified')
|
||||
->and($outcome['actionability'])->not->toBe('none')
|
||||
->and($outcome['readiness_impact'])->not->toBe('no_impact');
|
||||
})->with([
|
||||
'null trust' => null,
|
||||
'unknown trust' => 'unknown',
|
||||
'unrecognized trust' => 'garbage',
|
||||
'low trust' => 'low',
|
||||
'unusable trust' => 'unusable',
|
||||
'none trust' => 'none',
|
||||
]);
|
||||
|
||||
it('does not allow verified drift from reason with unknown trust', function (): void {
|
||||
$outcome = app(BaselineCompareOutcomeClassifier::class)
|
||||
->fromReason('verified_drift_detected', trustLevel: 'unknown')
|
||||
->toArray();
|
||||
|
||||
expect($outcome['reason'])->toBe('unresolved_low_trust_match')
|
||||
->and($outcome['comparison_status'])->toBe('not_compared')
|
||||
->and($outcome['category'])->toBe('action_required')
|
||||
->and($outcome['actionability'])->toBe('binding_required')
|
||||
->and($outcome['readiness_impact'])->toBe('customer_blocker')
|
||||
->and($outcome['reason'])->not->toBe('verified_drift_detected')
|
||||
->and($outcome['category'])->not->toBe('drift_detected')
|
||||
->and($outcome['actionability'])->not->toBe('none')
|
||||
->and($outcome['readiness_impact'])->not->toBe('no_impact');
|
||||
});
|
||||
|
||||
it('allows verified reasons from reason with explicitly trusted identity', function (string $trustLevel): void {
|
||||
$outcome = app(BaselineCompareOutcomeClassifier::class)
|
||||
->fromReason('verified_no_drift', trustLevel: $trustLevel)
|
||||
->toArray();
|
||||
|
||||
expect($outcome['reason'])->toBe('verified_no_drift')
|
||||
->and($outcome['comparison_status'])->toBe('no_drift')
|
||||
->and($outcome['category'])->toBe('verified')
|
||||
->and($outcome['actionability'])->toBe('none')
|
||||
->and($outcome['readiness_impact'])->toBe('no_impact');
|
||||
})->with([
|
||||
'high trust' => 'high',
|
||||
'medium trust' => 'medium',
|
||||
]);
|
||||
|
||||
it('does not allow resolved identity reasons from reason without explicit trusted identity', function (string $reasonCode, ?string $trustLevel): void {
|
||||
$outcome = app(BaselineCompareOutcomeClassifier::class)
|
||||
->fromReason($reasonCode, trustLevel: $trustLevel)
|
||||
->toArray();
|
||||
|
||||
expect($outcome['reason'])->toBe('unresolved_low_trust_match')
|
||||
->and($outcome['comparison_status'])->toBe('not_compared')
|
||||
->and($outcome['category'])->toBe('action_required')
|
||||
->and($outcome['actionability'])->toBe('binding_required')
|
||||
->and($outcome['readiness_impact'])->toBe('customer_blocker')
|
||||
->and($outcome['category'])->not->toBe('verified')
|
||||
->and($outcome['actionability'])->not->toBe('none')
|
||||
->and($outcome['readiness_impact'])->not->toBe('no_impact');
|
||||
})->with(function (): \Generator {
|
||||
foreach ([
|
||||
'resolved_active_binding',
|
||||
'resolved_canonical_identity',
|
||||
'resolved_provider_identity',
|
||||
] as $reasonCode) {
|
||||
foreach ([
|
||||
'null trust' => null,
|
||||
'unknown trust' => 'unknown',
|
||||
'unrecognized trust' => 'garbage',
|
||||
'low trust' => 'low',
|
||||
'unusable trust' => 'unusable',
|
||||
'none trust' => 'none',
|
||||
] as $label => $trustLevel) {
|
||||
yield "{$reasonCode} with {$label}" => [$reasonCode, $trustLevel];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('allows resolved identity reasons from reason with explicitly trusted identity', function (string $reasonCode, string $trustLevel): void {
|
||||
$outcome = app(BaselineCompareOutcomeClassifier::class)
|
||||
->fromReason($reasonCode, trustLevel: $trustLevel)
|
||||
->toArray();
|
||||
|
||||
expect($outcome['reason'])->toBe($reasonCode)
|
||||
->and($outcome['category'])->toBe('verified')
|
||||
->and($outcome['actionability'])->toBe('none')
|
||||
->and($outcome['readiness_impact'])->toBe('no_impact');
|
||||
})->with([
|
||||
'active binding high trust' => ['resolved_active_binding', 'high'],
|
||||
'canonical identity medium trust' => ['resolved_canonical_identity', 'medium'],
|
||||
'provider identity trustworthy trust' => ['resolved_provider_identity', 'trustworthy'],
|
||||
]);
|
||||
|
||||
it('uses the same trust boundary for trust-sensitive strategy diagnostics', function (string $reasonCode): void {
|
||||
$outcome = app(BaselineCompareOutcomeClassifier::class)
|
||||
->fromStrategyResult(compareSemanticsSubjectResult(
|
||||
CompareState::Incomplete,
|
||||
reasonCode: $reasonCode,
|
||||
trustLevel: 'unusable',
|
||||
))
|
||||
->toArray();
|
||||
|
||||
expect($outcome['reason'])->toBe('unresolved_low_trust_match')
|
||||
->and($outcome['category'])->toBe('action_required')
|
||||
->and($outcome['actionability'])->toBe('binding_required')
|
||||
->and($outcome['readiness_impact'])->toBe('customer_blocker')
|
||||
->and($outcome['category'])->not->toBe('verified')
|
||||
->and($outcome['actionability'])->not->toBe('none')
|
||||
->and($outcome['readiness_impact'])->not->toBe('no_impact');
|
||||
})->with([
|
||||
'verified no drift' => 'verified_no_drift',
|
||||
'verified drift detected' => 'verified_drift_detected',
|
||||
'resolved active binding' => 'resolved_active_binding',
|
||||
'resolved canonical identity' => 'resolved_canonical_identity',
|
||||
'resolved provider identity' => 'resolved_provider_identity',
|
||||
]);
|
||||
|
||||
it('does not allow low-trust resolved matching outcomes to serialize as verified', function (): void {
|
||||
$subject = new BaselineSubjectDescriptor(
|
||||
subjectDomain: 'baseline',
|
||||
subjectClass: SubjectClass::PolicyBacked,
|
||||
subjectTypeKey: 'deviceConfiguration',
|
||||
subjectType: 'policy',
|
||||
subjectExternalId: 'policy-1',
|
||||
canonicalSubjectKey: 'provider-resource:v1:baseline:policy_backed:deviceconfiguration:fake-provider:policy:provider_resource:policy-1',
|
||||
displayLabel: 'Policy 1',
|
||||
);
|
||||
$descriptor = ProviderResourceDescriptor::fromIdentity(
|
||||
identity: ResourceIdentity::providerResource('fake-provider', 'policy', 'policy-1'),
|
||||
subjectDomain: 'baseline',
|
||||
subjectClass: SubjectClass::PolicyBacked,
|
||||
subjectTypeKey: 'deviceConfiguration',
|
||||
displayLabel: 'Policy 1',
|
||||
);
|
||||
|
||||
$outcome = app(BaselineCompareOutcomeClassifier::class)
|
||||
->fromMatchingOutcome(MatchingOutcome::resolved(
|
||||
subject: $subject,
|
||||
matchedDescriptor: $descriptor,
|
||||
matchedSubjectKey: 'provider-resource:v1:baseline:policy_backed:deviceconfiguration:fake-provider:policy:provider_resource:policy-1',
|
||||
reasonCode: 'resolved_provider_identity',
|
||||
trust: 'low',
|
||||
))
|
||||
->toArray();
|
||||
|
||||
expect($outcome['reason'])->toBe('unresolved_low_trust_match')
|
||||
->and($outcome['category'])->toBe('action_required')
|
||||
->and($outcome['actionability'])->toBe('binding_required')
|
||||
->and($outcome['readiness_impact'])->toBe('customer_blocker')
|
||||
->and($outcome['category'])->not->toBe('verified')
|
||||
->and($outcome['actionability'])->not->toBe('none')
|
||||
->and($outcome['readiness_impact'])->not->toBe('no_impact');
|
||||
});
|
||||
|
||||
it('recursively sanitizes nested subject and proof payloads', function (): void {
|
||||
$resource = fopen('php://memory', 'r');
|
||||
|
||||
expect($resource)->not->toBeFalse();
|
||||
|
||||
$outcome = app(BaselineCompareOutcomeClassifier::class)->fromReason(
|
||||
reasonCode: 'identity_required',
|
||||
subject: [
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'nested' => [
|
||||
'safe' => 'value',
|
||||
'object' => (object) ['leak' => true],
|
||||
'list' => ['alpha', (object) ['unsafe' => true]],
|
||||
],
|
||||
'' => 'dropped',
|
||||
],
|
||||
proof: [
|
||||
'resource' => $resource,
|
||||
'deep' => [
|
||||
'scalar' => 123,
|
||||
'object' => new class
|
||||
{
|
||||
public function __toString(): string
|
||||
{
|
||||
return 'stringable-proof';
|
||||
}
|
||||
},
|
||||
],
|
||||
],
|
||||
)->toArray();
|
||||
|
||||
fclose($resource);
|
||||
|
||||
expect($outcome['subject'])->not->toHaveKey('')
|
||||
->and(data_get($outcome, 'subject.nested.safe'))->toBe('value')
|
||||
->and(data_get($outcome, 'subject.nested.object'))->toBe('stdClass')
|
||||
->and(data_get($outcome, 'subject.nested.list.0'))->toBe('alpha')
|
||||
->and(data_get($outcome, 'subject.nested.list.1'))->toBe('stdClass')
|
||||
->and(data_get($outcome, 'proof.resource'))->toStartWith('resource:')
|
||||
->and(data_get($outcome, 'proof.deep.scalar'))->toBe(123)
|
||||
->and(data_get($outcome, 'proof.deep.object'))->toBe('stringable-proof');
|
||||
});
|
||||
|
||||
it('summarizes run semantics from explicit subject outcomes', function (): void {
|
||||
$classifier = app(BaselineCompareOutcomeClassifier::class);
|
||||
$summary = app(BaselineCompareRunSummaryClassifier::class)->summarize(
|
||||
subjectOutcomes: [
|
||||
$classifier->fromStrategyResult(compareSemanticsSubjectResult(CompareState::NoDrift))->toArray(),
|
||||
$classifier->fromStrategyResult(compareSemanticsSubjectResult(CompareState::Incomplete, 'missing_local_evidence'))->toArray(),
|
||||
],
|
||||
driftFindingsCount: 0,
|
||||
warningsRecorded: true,
|
||||
);
|
||||
|
||||
expect($summary['run_outcome'])->toBe('partial')
|
||||
->and($summary['operation_outcome'])->toBe(OperationRunOutcome::PartiallySucceeded->value)
|
||||
->and(data_get($summary, 'counts.by_reason.verified_no_drift'))->toBe(1)
|
||||
->and(data_get($summary, 'counts.by_reason.missing_local_evidence'))->toBe(1)
|
||||
->and(data_get($summary, 'counts.by_readiness_impact.customer_blocker'))->toBe(1);
|
||||
});
|
||||
|
||||
it('classifies completed drift blocked and failed run summaries', function (): void {
|
||||
$classifier = app(BaselineCompareOutcomeClassifier::class);
|
||||
$summaryClassifier = app(BaselineCompareRunSummaryClassifier::class);
|
||||
|
||||
$completed = $summaryClassifier->summarize(
|
||||
subjectOutcomes: [$classifier->fromStrategyResult(compareSemanticsSubjectResult(CompareState::NoDrift))->toArray()],
|
||||
driftFindingsCount: 0,
|
||||
warningsRecorded: false,
|
||||
);
|
||||
$completedWithDrift = $summaryClassifier->summarize(
|
||||
subjectOutcomes: [$classifier->fromStrategyResult(compareSemanticsSubjectResult(CompareState::Drift))->toArray()],
|
||||
driftFindingsCount: 1,
|
||||
warningsRecorded: false,
|
||||
);
|
||||
$blocked = $summaryClassifier->summarize(
|
||||
subjectOutcomes: [$classifier->fromReason('missing_provider_resource')->toArray()],
|
||||
driftFindingsCount: 0,
|
||||
warningsRecorded: true,
|
||||
);
|
||||
$failed = $summaryClassifier->summarize(
|
||||
subjectOutcomes: [$classifier->fromReason('compare_failed')->toArray()],
|
||||
driftFindingsCount: 0,
|
||||
warningsRecorded: true,
|
||||
);
|
||||
|
||||
expect($completed['run_outcome'])->toBe('completed')
|
||||
->and($completed['operation_outcome'])->toBe(OperationRunOutcome::Succeeded->value)
|
||||
->and($completedWithDrift['run_outcome'])->toBe('completed_with_drift')
|
||||
->and($completedWithDrift['operation_outcome'])->toBe(OperationRunOutcome::Succeeded->value)
|
||||
->and($blocked['run_outcome'])->toBe('blocked')
|
||||
->and($blocked['operation_outcome'])->toBe(OperationRunOutcome::PartiallySucceeded->value)
|
||||
->and($failed['run_outcome'])->toBe('failed')
|
||||
->and($failed['operation_outcome'])->toBe(OperationRunOutcome::PartiallySucceeded->value);
|
||||
});
|
||||
|
||||
function compareSemanticsSubjectResult(
|
||||
CompareState $state,
|
||||
?string $reasonCode = null,
|
||||
?string $trustLevel = null,
|
||||
): CompareSubjectResult {
|
||||
return new CompareSubjectResult(
|
||||
subjectIdentity: new CompareSubjectIdentity(
|
||||
domainKey: 'baseline',
|
||||
subjectClass: SubjectClass::PolicyBacked->value,
|
||||
subjectTypeKey: 'deviceConfiguration',
|
||||
externalSubjectId: 'policy-1',
|
||||
subjectKey: 'deviceconfiguration|policy-1',
|
||||
),
|
||||
projection: new CompareSubjectProjection(
|
||||
platformSubjectClass: 'policy',
|
||||
domainKey: 'baseline',
|
||||
subjectTypeKey: 'deviceConfiguration',
|
||||
operatorLabel: 'Policy 1',
|
||||
),
|
||||
baselineAvailability: 'available',
|
||||
currentStateAvailability: $state === CompareState::Incomplete ? 'unknown' : 'available',
|
||||
compareState: $state,
|
||||
trustLevel: $trustLevel ?? ($state === CompareState::Incomplete ? 'unusable' : 'trustworthy'),
|
||||
evidenceQuality: $state === CompareState::Incomplete ? 'missing' : 'content',
|
||||
findingCandidate: $state === CompareState::Drift
|
||||
? new CompareFindingCandidate(
|
||||
changeType: 'different_version',
|
||||
severity: 'medium',
|
||||
fingerprintBasis: ['policy_type' => 'deviceConfiguration'],
|
||||
evidencePayload: ['change_type' => 'different_version'],
|
||||
)
|
||||
: null,
|
||||
diagnostics: array_filter([
|
||||
'strategy_key' => 'intune_policy',
|
||||
'reason_code' => $reasonCode,
|
||||
]),
|
||||
);
|
||||
}
|
||||
@ -60,11 +60,11 @@
|
||||
subject: $subject,
|
||||
matchedDescriptor: $matched,
|
||||
matchedSubjectKey: (string) $canonicalSubjectKey,
|
||||
reasonCode: 'canonical_subject_key',
|
||||
reasonCode: 'resolved_canonical_identity',
|
||||
trust: 'high',
|
||||
);
|
||||
|
||||
expect($outcome->isComparable())->toBeTrue()
|
||||
->and($outcome->requiresWarning())->toBeFalse()
|
||||
->and($outcome->reasonCode)->toBe('canonical_subject_key');
|
||||
->and($outcome->reasonCode)->toBe('resolved_canonical_identity');
|
||||
});
|
||||
|
||||
@ -43,11 +43,50 @@
|
||||
$outcome = $result['outcomes'][0];
|
||||
|
||||
expect($outcome->status)->toBe(MatchingOutcome::Resolved)
|
||||
->and($outcome->reasonCode)->toBe('active_provider_resource_binding')
|
||||
->and($outcome->reasonCode)->toBe('resolved_active_binding')
|
||||
->and($outcome->matchedDescriptor?->sourceReferences['inventory_item_id'] ?? null)->toBe(2)
|
||||
->and(data_get($result, 'diagnostics.active_bindings_considered'))->toBe(1);
|
||||
});
|
||||
|
||||
it('maps active binding resolution modes to provider-neutral gap reasons', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$expectations = [
|
||||
ProviderResourceResolutionMode::MissingExpected->value => [MatchingOutcome::MissingProviderResource, 'missing_provider_resource'],
|
||||
ProviderResourceResolutionMode::ExcludedNonGoverned->value => [MatchingOutcome::Excluded, 'excluded_non_governed'],
|
||||
ProviderResourceResolutionMode::AcceptedLimitation->value => [MatchingOutcome::Limited, 'accepted_limitation'],
|
||||
ProviderResourceResolutionMode::UnsupportedCoverage->value => [MatchingOutcome::Unsupported, 'unsupported_resource_class'],
|
||||
];
|
||||
|
||||
foreach ($expectations as $resolutionMode => [$expectedStatus, $expectedReason]) {
|
||||
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'bound-'.$resolutionMode);
|
||||
$canonicalSubjectKey = spec382SubjectKey($identity);
|
||||
|
||||
ProviderResourceBinding::factory()
|
||||
->providerResource($identity)
|
||||
->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'canonical_subject_key' => $canonicalSubjectKey,
|
||||
'resolution_mode' => $resolutionMode,
|
||||
]);
|
||||
|
||||
$result = app(SubjectMatchingPipeline::class)->matchAll(
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
managedEnvironmentId: (int) $tenant->getKey(),
|
||||
baselineSubjects: [spec382MatchingSubject('Bound '.$resolutionMode, $identity)],
|
||||
currentDescriptors: [],
|
||||
);
|
||||
|
||||
/** @var MatchingOutcome $outcome */
|
||||
$outcome = $result['outcomes'][0];
|
||||
|
||||
expect($outcome->status)->toBe($expectedStatus)
|
||||
->and($outcome->reasonCode)->toBe($expectedReason)
|
||||
->and($outcome->isComparable())->toBeFalse();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats same display labels with different provider ids as different resources', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$identity = ResourceIdentity::providerResource('fake-provider', 'policy', 'provider-id-2');
|
||||
@ -66,7 +105,7 @@
|
||||
$outcome = $result['outcomes'][0];
|
||||
|
||||
expect($outcome->status)->toBe(MatchingOutcome::Resolved)
|
||||
->and($outcome->reasonCode)->toBe('canonical_subject_key')
|
||||
->and($outcome->reasonCode)->toBe('resolved_canonical_identity')
|
||||
->and($outcome->matchedDescriptor?->sourceReferences['inventory_item_id'] ?? null)->toBe(2);
|
||||
});
|
||||
|
||||
@ -88,7 +127,7 @@
|
||||
$outcome = $result['outcomes'][0];
|
||||
|
||||
expect($outcome->status)->toBe(MatchingOutcome::Ambiguous)
|
||||
->and($outcome->reasonCode)->toBe('ambiguous_match')
|
||||
->and($outcome->reasonCode)->toBe('unresolved_duplicate_candidates')
|
||||
->and($outcome->proof['match_stage'])->toBe('canonical_subject_key')
|
||||
->and($outcome->proof['candidate_count'])->toBe(2);
|
||||
});
|
||||
@ -204,7 +243,7 @@
|
||||
$outcome = $result['outcomes'][0];
|
||||
|
||||
expect($outcome->status)->toBe(MatchingOutcome::Resolved)
|
||||
->and($outcome->reasonCode)->toBe('canonical_subject_key')
|
||||
->and($outcome->reasonCode)->toBe('resolved_canonical_identity')
|
||||
->and($outcome->matchedSubjectKey)->toStartWith('provider-resource:v1:baseline:foundation_backed:assignmenttarget:fake-provider:assignment-target:canonical_builtin:');
|
||||
});
|
||||
|
||||
@ -225,7 +264,7 @@
|
||||
$outcome = $result['outcomes'][0];
|
||||
|
||||
expect($outcome->status)->toBe(MatchingOutcome::Resolved)
|
||||
->and($outcome->reasonCode)->toBe('canonical_subject_key')
|
||||
->and($outcome->reasonCode)->toBe('resolved_canonical_identity')
|
||||
->and($outcome->matchedSubjectKey)->toStartWith('provider-resource:v1:baseline:foundation_backed:assignmenttarget:fake-provider:assignment-target:canonical_virtual_target:');
|
||||
});
|
||||
|
||||
@ -245,7 +284,7 @@
|
||||
$outcome = $result['outcomes'][0];
|
||||
|
||||
expect($outcome->status)->toBe(MatchingOutcome::Limited)
|
||||
->and($outcome->reasonCode)->toBe('foundation_not_policy_backed')
|
||||
->and($outcome->reasonCode)->toBe('foundation_inventory_only')
|
||||
->and($outcome->isComparable())->toBeFalse()
|
||||
->and($outcome->proof['match_stage'])->toBe('foundation_coverage');
|
||||
});
|
||||
|
||||
@ -43,12 +43,12 @@
|
||||
$throttledOutcome = $resolver->throttled($policyDescriptor);
|
||||
|
||||
expect($structuralOutcome->resolutionOutcome)->toBe(ResolutionOutcome::FoundationInventoryOnly)
|
||||
->and($structuralOutcome->reasonCode)->toBe('foundation_not_policy_backed')
|
||||
->and($structuralOutcome->reasonCode)->toBe('foundation_inventory_only')
|
||||
->and($structuralOutcome->operatorActionCategory)->toBe(OperatorActionCategory::ProductFollowUp)
|
||||
->and($structuralOutcome->structural)->toBeTrue();
|
||||
|
||||
expect($missingPolicyOutcome->resolutionOutcome)->toBe(ResolutionOutcome::PolicyRecordMissing)
|
||||
->and($missingPolicyOutcome->reasonCode)->toBe('policy_record_missing')
|
||||
expect($missingPolicyOutcome->resolutionOutcome)->toBe(ResolutionOutcome::MissingLocalEvidence)
|
||||
->and($missingPolicyOutcome->reasonCode)->toBe('missing_local_evidence')
|
||||
->and($missingPolicyOutcome->operatorActionCategory)->toBe(OperatorActionCategory::RunPolicySyncOrBackup)
|
||||
->and($missingPolicyOutcome->structural)->toBeFalse();
|
||||
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
# Requirements Checklist: Spec 383 - Baseline Compare Result Semantics and Gap Classification v1
|
||||
|
||||
**Purpose**: Validate that the preparation artifacts define a bounded, implementable, constitution-aligned runtime/result-semantics slice.
|
||||
**Created**: 2026-06-16
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
**Note**: This checklist covers preparation quality only. It does not mark implementation work complete.
|
||||
|
||||
## Applicability And Scope
|
||||
|
||||
- [x] CHK001 The selected candidate is user-provided and directly follows completed Specs 381 and 382.
|
||||
- [x] CHK002 Related completed specs are treated as historical/dependency context only.
|
||||
- [x] CHK003 The spec excludes resolution UI, final evidence/review readiness mapping, customer-facing Review Pack wording, report/PDF runtime work, and generic workflow engine scope.
|
||||
- [x] CHK004 The spec states no new persisted entity/table/artifact is approved.
|
||||
- [x] CHK005 The spec explicitly rejects old result-code compatibility readers, mappers, aliases, and historical OperationRun context readers.
|
||||
|
||||
## UI And Filament
|
||||
|
||||
- [x] CHK010 The spec includes a UI Surface Impact decision for existing status/evidence presentation changes.
|
||||
- [x] CHK011 The plan states no new route, navigation entry, Filament panel/provider, action, modal, drawer, wizard, Livewire component, Blade layout, or asset is planned.
|
||||
- [x] CHK012 The plan requires UI coverage artifacts only if implementation changes route, layout, navigation, action hierarchy, or archetype rather than existing labels/groups.
|
||||
- [x] CHK013 Browser screenshots/smoke are not required by default and become required only if implementation changes rendered layout/navigation/action behavior.
|
||||
- [x] CHK014 The Filament v5 output contract is present for later implementation reporting.
|
||||
|
||||
## Provider Boundary And Result Truth
|
||||
|
||||
- [x] CHK020 The provider/platform boundary is classified with platform-core result semantics and provider-owned proof metadata.
|
||||
- [x] CHK021 Top-level result terms are provider-neutral and avoid Microsoft/Intune/policy-only semantics.
|
||||
- [x] CHK022 Spec 382 `MatchingOutcome` mapping is explicit.
|
||||
- [x] CHK023 Low-trust, display-label-only, unresolved, duplicate, unsupported, excluded, accepted-limitation, and missing-evidence outcomes cannot produce clean no drift.
|
||||
- [x] CHK024 Missing provider resource and missing local evidence are distinct states with distinct actionability.
|
||||
- [x] CHK025 Foundation outcomes use limitation/unsupported semantics rather than policy-backed or missing-policy semantics.
|
||||
|
||||
## Proportionality And Bloat Control
|
||||
|
||||
- [x] CHK030 The new status/reason/category/actionability/readiness/trust family has a proportionality review.
|
||||
- [x] CHK031 The plan explains why a narrow classifier/mapper is needed now.
|
||||
- [x] CHK032 The plan rejects broad UI presenter, badge, workflow, report, provider, and evidence readiness frameworks.
|
||||
- [x] CHK033 The plan requires spec/plan updates before adding persistence, new UI workflows, customer-facing readiness mapping, or compatibility readers.
|
||||
|
||||
## RBAC, Isolation, Audit, And OperationRun
|
||||
|
||||
- [x] CHK040 Existing workspace and managed-environment scope enforcement remains required before exposing compare results.
|
||||
- [x] CHK041 Non-member access remains deny-as-not-found and member-without-capability remains forbidden through existing policies.
|
||||
- [x] CHK042 Structured proof metadata must be sanitized and exclude secrets/raw sensitive provider payloads.
|
||||
- [x] CHK043 Existing baseline compare OperationRun lifecycle is reused without new start/completion/link UX.
|
||||
- [x] CHK044 No direct `OperationRun.status` or `OperationRun.outcome` transitions are approved.
|
||||
- [x] CHK045 Any new summary count keys must update `OperationSummaryKeys::all()` and tests.
|
||||
|
||||
## Test Readiness
|
||||
|
||||
- [x] CHK050 Unit and feature lanes are explicitly named as the narrowest proof.
|
||||
- [x] CHK051 PostgreSQL and browser lanes are conditional and tied to concrete implementation triggers.
|
||||
- [x] CHK052 Tasks include tests for result mapping, clean-success rules, matching outcome mapping, missing-provider versus missing-evidence, foundation limitations, OperationRun payloads, run summary aggregation, existing surface labels/groups, and evidence/review regressions.
|
||||
- [x] CHK053 Tasks require validation commands, Pint, and `git diff --check`.
|
||||
- [x] CHK054 Tasks require old authoritative reason strings to be removed or explicitly bounded outside final compare result truth.
|
||||
|
||||
## Preparation Gate Outcome
|
||||
|
||||
- [x] CHK060 Candidate Selection Gate result: PASS.
|
||||
- [x] CHK061 Spec Readiness Gate preparation status: ready after preparation analysis.
|
||||
- [x] CHK062 Workflow outcome: keep as narrowed Core Enterprise runtime/result-semantics slice.
|
||||
@ -0,0 +1,50 @@
|
||||
# Implementation Close-Out: Spec 383 - Baseline Compare Result Semantics and Gap Classification v1
|
||||
|
||||
## Scope Delivered
|
||||
|
||||
- Added provider-neutral compare result semantics under `apps/platform/app/Support/Baselines/CompareSemantics/`.
|
||||
- Replaced authoritative baseline compare gap/result reasons with V1 values such as `missing_local_evidence`, `missing_provider_resource`, `unresolved_duplicate_candidates`, `foundation_inventory_only`, `compare_not_supported`, and `compare_failed`.
|
||||
- Added structured `baseline_compare.result_semantics` payloads with run outcome, operation outcome, counts by reason/category/actionability/readiness impact, and bounded subject outcomes.
|
||||
- Extended `baseline_compare.evidence_gaps` with structured category/actionability/readiness counts while keeping existing `summary_counts` keys compatible.
|
||||
- Updated existing Filament/Livewire compare surfaces to render provider-neutral labels from persisted context.
|
||||
- Updated compare, matching, coverage, matrix, monitoring, and presentation regression tests.
|
||||
|
||||
## Explicit Boundaries
|
||||
|
||||
- No new database tables, migrations, indexes, persisted entities, env vars, queues, scheduler entries, routes, navigation entries, Filament panel providers, actions, modals, drawers, wizards, assets, or storage paths were added.
|
||||
- No Spec 384 operator resolution UI, manual bind/exclude/accept-limitation workflow, or operator decision screen was implemented.
|
||||
- No Spec 385 final Evidence Snapshot readiness mapping, Review Pack publication blocker mapping, or customer-facing report/review wording was implemented.
|
||||
- Existing `missing_policy` finding `change_type` semantics remain unchanged because they are drift-finding evidence, not baseline compare result truth.
|
||||
- Remaining legacy strings are limited to capture flows, historical/capture enum cases, portfolio preview matching, or negative guard tests that prove old values are not authoritative in the new semantics model.
|
||||
|
||||
## Filament Contract
|
||||
|
||||
- Livewire v4 compliance: project package state is Livewire v4.x and no Livewire v3 APIs were introduced.
|
||||
- Provider registration: no Filament panel provider changes were made; Laravel provider registration remains in `apps/platform/bootstrap/providers.php`.
|
||||
- Global search: no globally searchable Filament resources were added or changed.
|
||||
- Destructive/high-impact actions: no actions were added or changed; therefore no new confirmation or authorization path was introduced.
|
||||
- Asset strategy: no Filament or frontend assets were registered; no `filament:assets` deployment step is newly required by this spec.
|
||||
|
||||
## Validation
|
||||
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Unit/Support/Baselines/CompareSemantics`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Unit/Support/Baselines/Matching tests/Unit/Baselines/CompareStrategyRegistryTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Unit/Support/Baselines/CompareSemantics/BaselineCompareOutcomeClassifierTest.php tests/Unit/Support/Baselines/Matching/MatchingOutcomeTest.php tests/Unit/Support/Baselines/Matching/SubjectMatchingPipelineTest.php tests/Unit/Services/Baselines/Matching/FoundationCoverageResolverTest.php tests/Unit/Support/Baselines/SubjectResolverTest.php tests/Unit/Baselines/CompareSubjectResultContractTest.php tests/Feature/Baselines/BaselineCompareGapClassificationTest.php tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php tests/Feature/Baselines/BaselineCompareProviderResourceBindingCanonicalIdentityTest.php tests/Feature/BaselineDriftEngine/CompareFidelityMismatchTest.php tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php tests/Feature/Filament/BaselineGapSurfacesDbOnlyRenderTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Baselines/BaselineCompareCoverageGuardTest.php tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Baselines/BaselineGapContractCleanupTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail pint ...changed files...`
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `git diff --check`
|
||||
|
||||
## Browser Decision
|
||||
|
||||
Browser smoke was not run. The implementation changed persisted compare semantics, labels, and Livewire/Filament context rendering only; it did not change layout structure, navigation, actions, JavaScript behavior, routes, or assets. Existing Livewire/Filament feature tests cover the affected rendered surfaces.
|
||||
|
||||
## Deployment Impact
|
||||
|
||||
- Staging/production require code deploy only.
|
||||
- No migrations or data backfill are required.
|
||||
- Existing historical OperationRun payloads are not rewritten.
|
||||
- Queue workers should be restarted as part of the normal Laravel deploy so queued compare jobs use the new semantics code.
|
||||
- No new environment variables, storage volumes, cron/scheduler entries, or Dokploy-specific configuration changes are required.
|
||||
263
specs/383-baseline-result-semantics/plan.md
Normal file
263
specs/383-baseline-result-semantics/plan.md
Normal file
@ -0,0 +1,263 @@
|
||||
# Implementation Plan: Spec 383 - Baseline Compare Result Semantics and Gap Classification v1
|
||||
|
||||
**Branch**: `383-baseline-result-semantics` | **Date**: 2026-06-16 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/383-baseline-result-semantics/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Replace overloaded baseline compare result/gap semantics with a provider-neutral outcome model over existing Spec 382 matching and compare strategy output. The plan adds a narrow classifier/mapper, rewrites legacy authoritative reason strings, stores structured subject outcome proof in existing OperationRun/compare payloads, updates existing status/detail grouping, and keeps resolution UI, Evidence/Review final readiness, customer-facing Review Pack wording, report/PDF runtime work, and compatibility readers out of scope.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12.52, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, PostgreSQL 16 through Sail/Dokploy
|
||||
**Storage**: Existing OperationRun context/result payloads and existing compare structures only. No new persisted entity/table/artifact is approved.
|
||||
**Testing**: Pest unit and feature tests; Filament/Livewire feature tests only for existing status rendering touched by the new grouping. Browser lane only if implementation changes layout/navigation/action behavior.
|
||||
**Validation Lanes**: fast-feedback, confidence; conditional pgsql/browser if implementation triggers those scopes.
|
||||
**Target Platform**: Laravel monolith in `apps/platform`.
|
||||
**Project Type**: Web admin application, runtime/result-semantics change with limited existing-surface status presentation impact.
|
||||
**Performance Goals**: Deterministic in-process classification over existing matching/compare results; no new remote work and no UI-render Graph/provider calls.
|
||||
**Constraints**: Provider-neutral top-level semantics, no legacy result compatibility, no new UI workflow, no final evidence/review readiness mapping, no OperationRun lifecycle transition outside `OperationRunService`.
|
||||
**Scale/Scope**: Existing baseline compare workflow and existing OperationRun/evidence-gap consumers.
|
||||
|
||||
## Existing Repository Surfaces Likely Affected
|
||||
|
||||
- `apps/platform/app/Support/Baselines/Matching/MatchingOutcome.php`
|
||||
- `apps/platform/app/Services/Baselines/Matching/SubjectMatchingPipeline.php`
|
||||
- `apps/platform/app/Services/Baselines/Matching/FoundationCoverageResolver.php`
|
||||
- `apps/platform/app/Support/Baselines/BaselineCompareReasonCode.php`
|
||||
- `apps/platform/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`
|
||||
- `apps/platform/app/Support/Baselines/SubjectResolver.php`
|
||||
- `apps/platform/app/Support/Baselines/ResolutionOutcome.php`
|
||||
- `apps/platform/app/Support/Baselines/ResolutionOutcomeRecord.php`
|
||||
- `apps/platform/app/Support/Baselines/Compare/CompareState.php`
|
||||
- `apps/platform/app/Support/Baselines/Compare/CompareSubjectResult.php`
|
||||
- `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php`
|
||||
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php` only if new summary count keys are required
|
||||
- `apps/platform/app/Services/Evidence/Sources/BaselineDriftPostureSource.php` only for regression-safe consumption of new run summary truth
|
||||
- Existing baseline compare and OperationRun detail presentation tests under `apps/platform/tests/Feature/Filament/`
|
||||
- Existing baseline compare, evidence, and review-pack regression tests under `apps/platform/tests/Feature/`
|
||||
|
||||
Likely new focused support namespace if implementation keeps the plan shape:
|
||||
|
||||
```text
|
||||
apps/platform/app/Support/Baselines/CompareSemantics/
|
||||
├── BaselineCompareOutcomeClassifier.php
|
||||
├── BaselineCompareRunSummaryClassifier.php
|
||||
├── CompareResultActionability.php
|
||||
├── CompareResultCategory.php
|
||||
├── CompareResultCoverageStatus.php
|
||||
├── CompareResultIdentityStatus.php
|
||||
├── CompareResultReadinessImpact.php
|
||||
├── CompareResultReason.php
|
||||
├── CompareResultTrustLevel.php
|
||||
└── CompareSubjectOutcome.php
|
||||
```
|
||||
|
||||
If implementation can satisfy the spec by extending existing classes with less structure, prefer the narrower shape and update this plan before adding broader abstractions.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: existing status/evidence/detail presentation changes only.
|
||||
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: existing baseline compare and OperationRun detail contexts that render evidence gaps/status groups. No new route, navigation entry, action, modal, drawer, wizard, form, or panel provider.
|
||||
- **No-impact class, if applicable**: N/A.
|
||||
- **Native vs custom classification summary**: existing native/shared Filament/Livewire surfaces; no local design system.
|
||||
- **Shared-family relevance**: status messaging, evidence-gap detail, badge/status labels.
|
||||
- **State layers in scope**: backend payload state and existing detail/list grouping.
|
||||
- **Audience modes in scope**: operator-MSP and support-platform. Customer/read-only output is out of scope until Spec 385.
|
||||
- **Decision/diagnostic/raw hierarchy plan**: default-visible group/category/actionability/readiness first; matching proof/provider identifiers remain diagnostics/support detail.
|
||||
- **Raw/support gating plan**: no new raw payload exposure. Keep existing diagnostics/support gating.
|
||||
- **One-primary-action / duplicate-truth control**: no new actions. Use one canonical reason/category/actionability set for all rendered labels.
|
||||
- **Handling modes by drift class or surface**: limitations, unsupported, missing evidence, missing provider, blockers, drift, no drift, excluded, and failed map to distinct groups.
|
||||
- **Repository-signal treatment**: if implementation changes only labels/groups on existing surfaces, document in feature close-out. If route/layout/action hierarchy changes, update UI coverage artifacts before merge.
|
||||
- **Special surface test profiles**: standard-native-filament relief for label/group changes; browser smoke only if layout/navigation/action behavior changes.
|
||||
- **Required tests or manual smoke**: feature tests for existing Filament/Livewire status rendering when touched; no browser smoke by default.
|
||||
- **Exception path and spread control**: none planned.
|
||||
- **Active feature PR close-out entry**: Baseline Compare Result Semantics / Gap Classification.
|
||||
- **UI/Productization coverage decision**: existing surface, no new route/page/archetype.
|
||||
- **Coverage artifacts to update**: none during preparation. Implementation must update `docs/ui-ux-enterprise-audit/` only if actual rendered structure or route/archetype changes.
|
||||
- **No-impact rationale**: N/A, because existing status presentation may change.
|
||||
- **Navigation / Filament provider-panel handling**: unchanged; Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- **Screenshot or page-report need**: no unless implementation changes layout/navigation or customer-facing output.
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes.
|
||||
- **Systems touched**: baseline matching, compare strategies, OperationRun proof context, evidence-gap rendering, support diagnostics where they consume baseline compare context.
|
||||
- **Shared abstractions reused**: Spec 382 `MatchingOutcome` and `SubjectMatchingPipeline`, existing compare strategy result objects, `OperationRunService`, `OperationSummaryKeys`, existing Filament/Livewire surfaces and badge/status helpers.
|
||||
- **New abstraction introduced? why?**: yes, a narrow result semantics classifier/mapper is expected. It replaces overloaded result truth and gives future Specs 384/385 a stable input.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: Existing matching and compare abstractions identify subjects and payload differences, but they still express final result truth through old policy-shaped strings. Existing UI helpers can render mapped truth once the domain semantics are explicit.
|
||||
- **Bounded deviation / spread control**: The classifier is baseline-compare-owned. It must not become a workflow engine, broad evidence readiness engine, customer report wording engine, or generic provider framework.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: no.
|
||||
- **Central contract reused**: existing baseline compare operation lifecycle and Monitoring detail route/link behavior.
|
||||
- **Delegated UX behaviors**: N/A.
|
||||
- **Surface-owned behavior kept local**: N/A.
|
||||
- **Queued DB-notification policy**: N/A.
|
||||
- **Terminal notification path**: existing lifecycle only.
|
||||
- **Exception path**: none.
|
||||
|
||||
Implementation changes baseline compare OperationRun context/proof and summary semantics. It must keep `OperationRun.status` and `OperationRun.outcome` transitions inside `OperationRunService`, and any new summary count keys must be added to `OperationSummaryKeys::all()` with tests.
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes.
|
||||
- **Provider-owned seams**: provider metadata/proof fields that feed Spec 382 matching and compare strategies.
|
||||
- **Platform-core seams**: result dimensions, result reasons, categories, actionability, readiness impact, trust level, OperationRun proof payload contract, and operator-facing result vocabulary.
|
||||
- **Neutral platform terms / contracts preserved**: provider resource, governed subject, identity, binding, canonicalization, comparison, coverage, limitation, drift, evidence, actionability, readiness impact, trust level.
|
||||
- **Retained provider-specific semantics and why**: provider key/type/id/discriminator remain proof metadata. They are not top-level result categories.
|
||||
- **Bounded extraction or follow-up path**: document-in-feature for any contained provider-specific proof metadata; follow-up-spec for resolution UI or evidence/review readiness integration.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
- Inventory-first: result semantics consume last-observed inventory, snapshots, policy versions, Spec 382 descriptors, and existing compare strategy output. Microsoft remains external truth.
|
||||
- Read/write separation: V1 adds no write action. Existing compare operation remains queued/observable.
|
||||
- Graph contract path: no new Graph calls. No Graph/provider runtime call during UI render or classification.
|
||||
- Deterministic capabilities: no new capability family planned.
|
||||
- RBAC-UX: existing workspace/managed-environment access checks remain required before baseline compare results are visible.
|
||||
- Workspace isolation: OperationRun and baseline/evidence reads remain workspace scoped.
|
||||
- Tenant isolation: managed-environment scoped compare result proof must not leak across environments.
|
||||
- Run observability: existing baseline compare `OperationRun` remains canonical execution truth.
|
||||
- OperationRun start UX: unchanged.
|
||||
- Ops-UX lifecycle: no direct status/outcome transitions may be added.
|
||||
- Ops-UX summary counts: new keys require `OperationSummaryKeys::all()` update and tests; otherwise reuse existing keys.
|
||||
- Data minimization: structured proof must be sanitized and exclude secrets/raw provider payloads.
|
||||
- Test governance: unit and feature lanes are narrowest; browser/pgsql conditional only.
|
||||
- Proportionality: new semantic family is justified because old reason strings are product truth and block future resolution/readiness work.
|
||||
- No premature abstraction: only baseline compare semantics, not a generic workflow/evidence/report framework.
|
||||
- Persisted truth: no new table/entity approved; structured payloads use existing OperationRun context/result paths.
|
||||
- Behavioral state: every new value must change actionability, readiness, aggregation, trust, or operator interpretation.
|
||||
- UI semantics: direct domain-to-existing-surface mapping; no new UI taxonomy framework.
|
||||
- Shared pattern first: existing OperationRun and Filament/Livewire rendering paths are reused.
|
||||
- Provider boundary: top-level compare semantics are provider-neutral.
|
||||
- V1 explicitness / few layers: replace old strings rather than stack compatibility aliases.
|
||||
- Spec discipline / bloat check: result semantics grouped in one coherent spec; resolution UI and evidence/review readiness remain follow-ups.
|
||||
- Filament-native UI: no new Filament surface/action/layout. Existing native/shared surfaces only.
|
||||
- UI/Productization coverage: existing status presentation changes are documented; no new route/page/archetype.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit for semantic values/classifiers; Feature for compare integration, OperationRun payloads, existing status presentation, evidence/review regressions.
|
||||
- **Affected validation lanes**: fast-feedback, confidence; pgsql/browser conditional.
|
||||
- **Why this lane mix is the narrowest sufficient proof**: The behavior is deterministic classification and existing DB-backed compare result context. UI browser proof is not needed unless layout/navigation changes.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/CompareSemantics`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareProviderResourceBindingCanonicalIdentityTest.php tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: reuse existing baseline compare and Spec 382 fixtures. No global provider/workspace defaults.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none planned.
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament relief unless UI structure changes.
|
||||
- **Closing validation and reviewer handoff**: reviewers verify no legacy reason compatibility, no provider-specific top-level semantics, no false no-drift, no Spec 384/385 scope, and no hidden browser/pgsql lane change.
|
||||
- **Budget / baseline / trend follow-up**: none expected.
|
||||
- **Review-stop questions**: taxonomy bloat, old string leftovers, summary count key ownership, fixture cost, and scope bleed into evidence/review/customer output.
|
||||
- **Escalation path**: document-in-feature for contained existing-surface label changes; follow-up-spec for structural UI or evidence/readiness integration.
|
||||
- **Active feature PR close-out entry**: Baseline Compare Result Semantics / Gap Classification.
|
||||
- **Why no dedicated follow-up spec is needed**: This spec is the dedicated follow-up to Spec 382. Specs 384 and 385 remain separate for UI decisions and readiness integration.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/383-baseline-result-semantics/
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
├── plan.md
|
||||
├── spec.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/app/
|
||||
├── Jobs/
|
||||
│ └── CompareBaselineToTenantJob.php
|
||||
├── Services/
|
||||
│ ├── Baselines/
|
||||
│ │ └── Matching/
|
||||
│ └── Evidence/
|
||||
│ └── Sources/
|
||||
└── Support/
|
||||
├── Baselines/
|
||||
│ ├── Compare/
|
||||
│ ├── CompareSemantics/ # expected new narrow support namespace
|
||||
│ ├── Matching/
|
||||
│ ├── BaselineCompareEvidenceGapDetails.php
|
||||
│ ├── BaselineCompareReasonCode.php
|
||||
│ ├── ResolutionOutcome.php
|
||||
│ └── SubjectResolver.php
|
||||
└── OpsUx/
|
||||
└── OperationSummaryKeys.php
|
||||
|
||||
apps/platform/tests/
|
||||
├── Unit/Support/Baselines/CompareSemantics/
|
||||
├── Unit/Support/Baselines/Matching/
|
||||
├── Feature/Baselines/
|
||||
├── Feature/Filament/
|
||||
├── Feature/Evidence/
|
||||
└── Feature/ReviewPack/
|
||||
```
|
||||
|
||||
**Structure Decision**: Use the existing Laravel monolith under `apps/platform`. Keep semantics code baseline-compare-owned. Do not create a new package, module root, route family, UI framework, or persistence layer.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|---|---|---|
|
||||
| New result reason/category/actionability/readiness family | Current reason strings mix identity, evidence, provider absence, limitations, unsupported scope, and failures | Renaming labels would preserve ambiguous product truth and leave future Specs 384/385 unsafe |
|
||||
| New classifier/mapper | Spec 382 matching and existing compare strategies need one canonical mapping into final result semantics | Scattering mappings in `CompareBaselineToTenantJob`, `SubjectResolver`, and UI helpers would create duplicate truth |
|
||||
| Structured OperationRun proof payload | Monitoring/support/evidence consumers need machine-readable result truth | Keeping flat `by_reason` strings forces every consumer to decode overloaded legacy labels |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Operators cannot tell which compare outcomes are trusted, blocked, missing evidence, missing provider resource, unsupported, limited, excluded, or failed.
|
||||
- **Existing structure is insufficient because**: Current runtime still uses old strings in `MatchingOutcome`, `SubjectResolver`, compare strategy diagnostics, OperationRun context, and tests.
|
||||
- **Narrowest correct implementation**: One baseline compare semantics layer plus mapped structured payloads over existing matching/compare outputs.
|
||||
- **Ownership cost created**: New value families and mapping tests; reviewer vigilance against compatibility aliases and UI/evidence/report scope creep.
|
||||
- **Alternative intentionally rejected**: Keep old strings and add display labels. That would not remove false green/false red risk and would leave downstream readiness work ambiguous.
|
||||
- **Release truth**: Current-release truth required after Spec 382.
|
||||
|
||||
## Domain And Data Model Implications
|
||||
|
||||
- `MatchingOutcome` remains upstream matching truth, but its reason codes must map to final compare semantics.
|
||||
- `CompareSubjectResult` remains compare strategy output, but strategy gap reasons must map to final compare semantics.
|
||||
- `BaselineCompareReasonCode` may be replaced, narrowed, or kept only as run-level summary codes if it no longer carries overloaded subject-level truth.
|
||||
- `ResolutionOutcome` and `SubjectResolver` must not remain authoritative for new compare result semantics if their old values are policy-shaped.
|
||||
- OperationRun baseline compare context may preserve the current rendering envelope only where existing surfaces still read it, but this is not legacy semantic compatibility: authoritative result truth must be structured under a new semantic payload path, and old reason aliases/readers remain prohibited.
|
||||
- Existing local/dev rows need no compatibility reader. If implementation needs to purge/reset old local/dev payloads, document the operational step in close-out.
|
||||
- No new table, migration, index, queue, scheduler, env var, or storage path is expected. If implementation needs any of these, update spec and plan before continuing.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
1. Confirm completed dependency guardrails for Specs 381 and 382, and confirm no changes to completed spec history.
|
||||
2. Add unit tests for result reasons, categories, actionability, readiness impact, trust, clean-success rules, and run summary classification.
|
||||
3. Add feature tests for baseline compare gap payloads, missing provider vs missing local evidence, foundation limitation mapping, active binding/matching outcome mapping, and old reason removal.
|
||||
4. Add or update the narrow result semantics value family and classifier.
|
||||
5. Map Spec 382 `MatchingOutcome` to final compare subject outcomes.
|
||||
6. Map compare strategy states and diagnostics to final compare subject outcomes.
|
||||
7. Update `CompareBaselineToTenantJob` to aggregate structured subject outcomes, gap subjects, category counts, actionability counts, readiness counts, and run summary decisions.
|
||||
8. Update existing evidence-gap/detail/status label helpers and Filament/Livewire feature tests if rendered groups change.
|
||||
9. Run evidence/review regression tests to prove no final readiness/customer output mapping is introduced.
|
||||
10. Run targeted tests, Pint, and diff check; record close-out with Filament/Livewire/deploy impact.
|
||||
|
||||
## Filament v5 Output Contract For Later Implementation Report
|
||||
|
||||
- Livewire v4.0+ compliance: unchanged unless implementation unexpectedly touches Livewire. Project currently uses Livewire 4.1.4.
|
||||
- Provider registration location: unchanged. Laravel 12 panel providers remain in `apps/platform/bootstrap/providers.php`.
|
||||
- Global search: no resource is added or changed; no global search behavior is planned.
|
||||
- Destructive/high-impact actions: no Filament action is added and no destructive action is introduced. Existing compare start behavior keeps existing authorization/OperationRun rules.
|
||||
- Asset strategy: no Filament assets are registered; no Spec 383-specific `filament:assets` deployment concern beyond normal release process.
|
||||
- Testing plan: unit/feature tests cover semantics, compare integration, OperationRun payloads, existing status rendering, and evidence/review regressions. No browser test unless UI layout/navigation/action behavior changes.
|
||||
|
||||
## Rollout And Deployment Considerations
|
||||
|
||||
- No environment variables, queue names, scheduler entries, storage volumes, reverse proxy changes, route changes, panel provider changes, or asset build changes are expected.
|
||||
- No schema migration is expected. Because TenantPilot is pre-production, old local/dev compare payloads may be invalidated/reset instead of read through a compatibility mapper.
|
||||
- Staging validation should run targeted compare/semantics/evidence/review tests and normal formatting checks before production promotion.
|
||||
- Rollback is code rollback plus clearing/regenerating local/dev compare OperationRun payloads if necessary; no persisted compatibility layer is planned.
|
||||
428
specs/383-baseline-result-semantics/spec.md
Normal file
428
specs/383-baseline-result-semantics/spec.md
Normal file
@ -0,0 +1,428 @@
|
||||
# Feature Specification: Spec 383 - Baseline Compare Result Semantics and Gap Classification v1
|
||||
|
||||
**Feature Branch**: `383-baseline-result-semantics`
|
||||
**Created**: 2026-06-16
|
||||
**Status**: Draft / Ready for implementation preparation review
|
||||
**Input**: User-provided draft candidate "Spec 383 - Baseline Compare Result Semantics & Gap Classification v1" from `/Users/ahmeddarrazi/.codex/attachments/11205670-04a2-4b8a-abde-a7e89adf9b79/pasted-text.txt`.
|
||||
|
||||
## Repo-Truth Adjustment
|
||||
|
||||
The user supplied a complete numbered draft for Spec 383. Repo truth confirms that `specs/381-provider-resource-identity-binding/` and `specs/382-baseline-matching-canonicalization/` are implemented and closed out. Spec 382 added `SubjectMatchingPipeline`, `MatchingOutcome`, `BaselineSubjectDescriptor`, and `FoundationCoverageResolver`, but current runtime truth still exposes overloaded or legacy-flavored result reasons such as `ambiguous_match`, `policy_record_missing`, `foundation_not_policy_backed`, `missing_current`, `unsupported_subject`, `unsupported_subjects`, and `strategy_failed`.
|
||||
|
||||
This prepared Spec 383 narrows the draft to the smallest implementation-ready slice:
|
||||
|
||||
- V1 replaces overloaded baseline compare result and gap semantics with provider-neutral subject outcome dimensions.
|
||||
- V1 maps Spec 382 `MatchingOutcome` and existing compare strategy output into explicit identity, comparison, coverage, actionability, readiness, trust, reason, and category semantics.
|
||||
- V1 updates existing baseline compare OperationRun proof and gap payloads so downstream surfaces can distinguish blockers, limitations, unsupported coverage, missing provider resource, missing local evidence, drift, no drift, and exclusions.
|
||||
- V1 updates existing baseline compare detail/status presentation only as needed to render the new grouped truth from existing surfaces.
|
||||
- V1 does not add resolution UI, operator binding/exclusion screens, final Evidence Snapshot readiness mapping, final Review Pack publication readiness mapping, customer-facing Review Pack wording, report/PDF runtime work, or a generic workflow engine.
|
||||
- V1 does not preserve legacy compare result compatibility. TenantPilot is pre-production, so old local/dev compare records and old tests may be reset or rewritten to the new truth model.
|
||||
|
||||
## Candidate Selection Gate
|
||||
|
||||
- **Selected candidate**: Spec 383 - Baseline Compare Result Semantics and Gap Classification v1.
|
||||
- **Source**: Direct user-provided candidate attachment, plus follow-up references in `specs/381-provider-resource-identity-binding/spec.md`, `specs/381-provider-resource-identity-binding/implementation-close-out.md`, and `specs/382-baseline-matching-canonicalization/spec.md`.
|
||||
- **Why selected**: Specs 381 and 382 are completed, and current code now has stable matching inputs but still maps those inputs into ambiguous legacy result semantics. Spec 383 is the next dependency before resolution UI and evidence/review readiness can safely consume compare output.
|
||||
- **Roadmap relationship**: Supports provider-neutral baseline identity, governance truth, OperationRun proof quality, and customer-safe evidence/review foundations without reopening completed UI/productization or report lanes.
|
||||
- **Close alternatives deferred**:
|
||||
- Spec 384 - Baseline Subject Resolution UI & Operator Decisions v1: depends on 383 result/actionability semantics.
|
||||
- Spec 385 - Evidence & Review Readiness Integration v1: depends on 383 readiness-impact and category semantics.
|
||||
- Management Report/PDF runtime validation: unrelated current working-tree/report lane and explicitly out of scope.
|
||||
- Broad baseline compare UI redesign: not required for V1; this spec only updates existing status/grouping presentation where the new result truth reaches current surfaces.
|
||||
- **Completed-spec guardrail result**:
|
||||
- `specs/381-provider-resource-identity-binding/` is completed and validated. It is dependency context only.
|
||||
- `specs/382-baseline-matching-canonicalization/` is completed and validated. It is upstream runtime context only.
|
||||
- `specs/163-baseline-subject-resolution/`, `specs/336-baseline-compare-product-process-flow-alignment/`, `specs/347-review-pack-output-contract-readiness-semantics/`, `specs/350-operator-resolution-guidance-framework-v1/`, and `specs/380-management-report-pdf-staging-runtime-validation/` are historical or adjacent context only and must not be rewritten by this spec.
|
||||
- No existing `specs/383-*` package or `383-*` local/remote branch was found before the Spec Kit create script ran.
|
||||
- **Smallest viable implementation slice**: Replace the current overloaded compare reason strings and run/gap payload aggregation with a narrow provider-neutral outcome classifier and mapped structured payloads over existing compare and matching code.
|
||||
- **Gate result**: PASS. The candidate is user-provided, not already specced or completed, directly follows completed Specs 381 and 382, and can be scoped as a bounded runtime/result-semantics slice.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Baseline compare can now resolve provider-resource identity through Spec 382, but the resulting gaps and run summaries still use overloaded, policy-shaped, or legacy reason meanings.
|
||||
- **Today's failure**: Operators and downstream evidence/review code cannot reliably tell whether a result is true drift, unresolved identity, missing provider resource, missing local evidence, unsupported coverage, accepted limitation, excluded non-governed subject, or a technical compare failure.
|
||||
- **User-visible improvement**: Operators see honest compare outcomes: blockers require action, limitations are visible but not false failures, unsupported scope is explicit, low-trust identity cannot become no drift, and provider/default/foundation cases do not appear as missing policy records.
|
||||
- **Smallest enterprise-capable version**: Add a provider-neutral result semantics family and classifier, map Spec 382 matching outcomes plus compare strategy states into it, replace old gap reason strings as authoritative truth, write structured OperationRun gap/proof payloads, and update focused tests and existing display grouping.
|
||||
- **Explicit non-goals**: No resolution UI, no manual bind/exclude/accept-limitation screens, no Evidence Snapshot final readiness mapping, no Review Pack publication final mapping, no customer-facing Review Pack copy, no Management Report/PDF work, no generic workflow engine, no legacy compare result mapper, no historical OperationRun context reader, and no new persisted entity unless the spec/plan are updated first.
|
||||
- **Permanent complexity imported**: A narrow result semantics model, reason/category/actionability/readiness/trust values, an outcome classifier/mapper, updated OperationRun payload shape, focused unit/feature tests, and existing-surface label/grouping updates. No new table, route, panel, or broad UI framework is approved.
|
||||
- **Why now**: Spec 382 deliberately left final result semantics to Spec 383. Leaving legacy reason truth in place would make Specs 384 and 385 build on ambiguous signals.
|
||||
- **Why not local**: Patching individual labels such as `policy_record_missing` or `foundation_not_policy_backed` would preserve ambiguity across `MatchingOutcome`, `SubjectResolver`, `CompareState`, strategy diagnostics, OperationRun context, and evidence/review consumers. The current workflow needs one canonical semantic mapping.
|
||||
- **Approval class**: Core Enterprise.
|
||||
- **Red flags triggered**: New status/reason taxonomy, classifier layer, OperationRun payload semantics, and existing status presentation changes. Defense: the scope replaces overloaded truth instead of adding a parallel UI taxonomy, stays inside baseline compare result semantics, adds no persistence, and defers UI decisions and evidence/review readiness to explicit follow-ups.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve as a narrowed Core Enterprise runtime/result-semantics slice.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Baseline compare currently has multiple result and gap labels that compress incompatible meanings into one string. For example:
|
||||
|
||||
- `ambiguous_match` can mean unresolved duplicate identity, low-trust identity, or compare-key collision.
|
||||
- `policy_record_missing` and `missing_current` can mean missing provider resource, missing local evidence, or stale collection state.
|
||||
- `foundation_not_policy_backed` can mean inventory-only coverage, identity-only coverage, canonical-only coverage, unsupported class, or accepted limitation.
|
||||
- `unsupported_subjects` and `strategy_failed` can represent expected scope limitations, implementation gaps, or technical failures.
|
||||
|
||||
After Spec 382, compare has better upstream matching truth, but the downstream semantics still hide whether the operator needs to refresh provider data, create a binding, accept a limitation, exclude a subject, investigate a technical failure, or trust a no-drift result.
|
||||
|
||||
TenantPilot needs provider-neutral result semantics that keep drift, identity, coverage, actionability, readiness, and trust separate.
|
||||
|
||||
## Business / Product Value
|
||||
|
||||
- Prevents false green outcomes by ensuring unresolved, low-trust, unsupported, missing, excluded, or accepted-limitation subjects cannot count as clean no drift.
|
||||
- Prevents false red outcomes by making limitations, unsupported resource classes, and inventory-only foundations explicit instead of treating them as missing policies.
|
||||
- Gives OperationRun proof payloads enough structure for reliable support diagnosis and future evidence/review readiness mapping.
|
||||
- Creates clean inputs for Spec 384 resolution UI and Spec 385 evidence/review readiness without shipping those workflows now.
|
||||
- Keeps platform-core compare language provider-neutral as TenantPilot moves beyond Microsoft-shaped labels.
|
||||
|
||||
## Primary Users / Operators
|
||||
|
||||
- MSP or tenant operator reviewing baseline compare outcomes.
|
||||
- Workspace manager assessing whether baseline drift posture is trustworthy.
|
||||
- Support/platform operator diagnosing why compare output is blocked, partial, limited, or failed.
|
||||
- Release reviewer validating that compare semantics stay provider-neutral, pre-production lean, and test-proven.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant-owned baseline compare runtime and existing compare status/detail presentation within established workspace and managed-environment boundaries.
|
||||
- **Primary Routes**: No new route or navigation entry. Existing baseline compare landing/detail/operation surfaces and evidence-gap display paths may render updated status groups/labels from the new backend semantics.
|
||||
- **Data Ownership**: Existing OperationRun context/result payloads remain tenant-owned operational proof. Existing baseline snapshots, snapshot items, inventory items, policy versions, findings, evidence snapshots, review packs, and provider resource bindings keep their current ownership. No new persisted entity is approved.
|
||||
- **RBAC**: Existing baseline compare authorization remains. Reads and rendered results must stay workspace and managed-environment scoped. Non-members are denied as not found. Entitled members missing compare/view capability receive forbidden where existing policies apply.
|
||||
|
||||
For canonical-view specs:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Not applicable. No canonical-view route is added.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing OperationRun, baseline, evidence, and review surfaces must continue resolving records through scoped workspace/managed-environment access before exposing result semantics.
|
||||
|
||||
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||
|
||||
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||
|
||||
- [ ] No UI surface impact
|
||||
- [x] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [ ] Navigation changed
|
||||
- [ ] Filament panel/provider surface changed
|
||||
- [ ] New modal/drawer/wizard/action added
|
||||
- [ ] New table/form/state added
|
||||
- [ ] Customer-facing surface changed
|
||||
- [ ] Dangerous action changed
|
||||
- [x] Status/evidence/review presentation changed
|
||||
- [ ] Workspace/environment context presentation changed
|
||||
|
||||
## UI/Productization Coverage
|
||||
|
||||
- **Route/page/surface**: Existing baseline compare result/evidence-gap presentation in the admin panel, including `BaselineCompareLanding`, `BaselineCompareMatrix`, `BaselineCompareEvidenceGapTable`, and OperationRun detail contexts that render baseline compare evidence gaps.
|
||||
- **Current or new page archetype**: Existing baseline compare/domain status surfaces; no new archetype.
|
||||
- **Design depth**: Domain Pattern Surface. This spec changes result truth and grouping, not layout strategy.
|
||||
- **Repo-truth level**: repo-verified.
|
||||
- **Existing pattern reused**: Existing baseline compare and OperationRun detail surfaces; existing page report context from `docs/ui-ux-enterprise-audit/page-reports/ui-015-baseline-compare.md`.
|
||||
- **New pattern required**: none. Use existing status badge/detail grouping patterns and central badge/status helpers where applicable.
|
||||
- **Screenshot required**: no for V1 unless implementation materially changes layout, navigation, action hierarchy, or customer-facing output.
|
||||
- **Page audit required**: no for V1 unless implementation changes reachable layout or introduces a new visible group beyond existing baseline compare/status sections.
|
||||
- **Customer-safe review required**: no. Customer-facing readiness and Review Pack copy belong to Spec 385.
|
||||
- **Dangerous-action review required**: no. This spec adds no destructive or high-impact action.
|
||||
- **Coverage files updated or explicitly not needed**:
|
||||
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/page-reports/...`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
|
||||
- [x] `N/A - existing surface, no new route/page/archetype`
|
||||
- **No-impact rationale when applicable**: Not applicable. Existing status presentation may change; route/layout coverage updates are not required unless implementation changes surface structure rather than labels/groups fed by existing components.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse
|
||||
|
||||
- **Cross-cutting feature?**: yes.
|
||||
- **Interaction class(es)**: status messaging, evidence-gap detail, OperationRun proof context, badge/status labels where existing surfaces render reason state.
|
||||
- **Systems touched**: `MatchingOutcome`, `SubjectMatchingPipeline`, `FoundationCoverageResolver`, `CompareState`, `CompareSubjectResult`, `IntuneCompareStrategy`, `CompareBaselineToTenantJob`, `BaselineCompareReasonCode`, `BaselineCompareEvidenceGapDetails`, OperationRun summary counts/context, baseline compare tests, evidence/review regression tests.
|
||||
- **Existing pattern(s) to extend**: Existing compare strategy output, OperationRun lifecycle/service ownership, existing evidence-gap table/detail rendering, existing badge/status helper paths where used.
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: Reuse `OperationRunService` for status/outcome transitions, `OperationSummaryKeys` for summary counts, existing badge/status helpers for rendered labels, and existing compare/evidence gap rendering surfaces.
|
||||
- **Why the existing shared path is sufficient or insufficient**: Existing paths are sufficient for lifecycle, rendering, and DB-only monitoring. They are insufficient for result truth because reason strings are overloaded and not mapped from Spec 382 matching outcomes into explicit dimensions.
|
||||
- **Allowed deviation and why**: A narrow baseline compare outcome classifier/mapper is allowed because it replaces overloaded domain truth. It must not become a generic workflow engine, customer output engine, or parallel badge taxonomy.
|
||||
- **Consistency impact**: Run context, gap subjects, rendered evidence-gap labels, support diagnostics, and tests must use the same reason/category/actionability/readiness semantics.
|
||||
- **Review focus**: no old reason strings as authoritative values, no low-trust no-drift, no policy-only terms at platform-core boundaries, no local OperationRun lifecycle transitions, no customer-facing readiness scope.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: no.
|
||||
- **Shared OperationRun UX contract/layer reused**: Existing baseline compare OperationRun start/completion/link UX remains unchanged.
|
||||
- **Delegated start/completion UX behaviors**: N/A - no queued toast, `Open operation`, browser event, queued DB notification, or run-link behavior changes.
|
||||
- **Local surface-owned behavior that remains**: N/A.
|
||||
- **Queued DB-notification policy**: N/A - no new queued DB notifications.
|
||||
- **Terminal notification path**: Existing OperationRun lifecycle only.
|
||||
- **Exception required?**: none.
|
||||
|
||||
Spec 383 changes OperationRun proof/context/result semantics for existing baseline compare runs. It must not transition `OperationRun.status` or `OperationRun.outcome` outside `OperationRunService`, and any new summary count keys must go through `OperationSummaryKeys::all()`.
|
||||
|
||||
## Provider Boundary / Platform Core Check
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes.
|
||||
- **Boundary classification**: platform-core for result dimensions, categories, readiness impact, actionability, and trust semantics; provider-owned only for provider-specific metadata that feeds matching or compare strategies.
|
||||
- **Seams affected**: baseline compare result semantics, matching outcome mapping, compare strategy diagnostics, OperationRun proof payloads, evidence-gap detail labels, provider-neutral operator vocabulary.
|
||||
- **Neutral platform terms preserved or introduced**: provider resource, governed subject, identity, binding, canonicalization, comparison, coverage, limitation, drift, evidence, actionability, readiness impact, trust level.
|
||||
- **Provider-specific semantics retained and why**: provider key, provider resource type, provider resource ID, and provider-owned metadata remain low-level identity/proof fields. Microsoft/Intune terms must not be top-level result semantics.
|
||||
- **Why this does not deepen provider coupling accidentally**: V1 replaces policy-only reason codes with provider-neutral semantics and keeps provider details inside subject/proof metadata.
|
||||
- **Follow-up path**: Spec 384 may add operator decision UI. Spec 385 may map these semantics into evidence/review readiness and customer-safe output.
|
||||
|
||||
## UI / Surface Guardrail Impact
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / N/A Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Baseline compare evidence-gap/status presentation | yes | Existing native/shared surfaces | status messaging, evidence-gap detail, badge/status labels | page/detail data state | no | Existing surfaces only; no new route, navigation, action, modal, or layout pattern |
|
||||
| OperationRun baseline compare proof context | yes, where rendered by existing run detail/support contexts | Existing OperationRun surfaces | OperationRun proof and diagnostics | detail data state | no | Start/link UX unchanged |
|
||||
|
||||
## Decision-First Surface Role
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Baseline compare evidence-gap/status presentation | Secondary Context Surface | Decide whether compare output is trusted, blocked, limited, or needs follow-up | result group, blocker/limitation/missing/evidence category, next action class | subject proof, matching proof, provider metadata, raw diagnostics | Secondary because it supports a compare/review decision but does not create a new workflow surface | Follows existing baseline compare and operation detail workflows | Reduces interpretation work by splitting real blockers from limitations and missing evidence |
|
||||
|
||||
## Audience-Aware Disclosure
|
||||
|
||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Existing baseline compare status/detail surfaces | operator-MSP, support-platform | grouped status, readiness impact, actionability, subject label | matching status, reason category, source proof summary | structured matching proof and provider identifiers where existing support contexts allow | resolve blocker through later Spec 384 or refresh evidence where V1 says refresh is required | raw payloads and low-level proof remain diagnostics/support detail | one canonical reason/category/actionability set feeds all display paths |
|
||||
|
||||
## UI/UX Surface Classification
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Existing baseline compare result/gap surfaces | List / Detail / Diagnostics | Status/evidence detail | Inspect blocker, limitation, missing evidence, or drift | Existing page/run detail inspect path | unchanged | unchanged | N/A | existing baseline compare route | existing operation/baseline detail route | Workspace and managed environment from existing surfaces | Baseline compare result | category, actionability, readiness impact, trust | none |
|
||||
|
||||
## Operator Surface Contract
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Existing baseline compare result/gap surfaces | Tenant operator / support operator | Decide whether compare output is trusted or blocked and what follow-up class applies | Secondary status/evidence detail | Can I trust this compare result, and what action is required? | result group, blocker/limitation/missing/drift/no-drift status, actionability, readiness impact | matching proof, raw provider IDs, diagnostics payloads | identity, comparison, coverage, actionability, readiness, trust | read-only status/proof only | Existing inspect/open actions | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: yes, a canonical derived result-semantics truth for baseline compare subject outcomes. It replaces overloaded reason strings as authoritative runtime/result truth.
|
||||
- **New persisted entity/table/artifact?**: no new persisted entity/table/artifact. Structured payloads are stored in existing OperationRun/context or existing compare result paths only.
|
||||
- **New abstraction?**: yes, a narrow baseline compare outcome classifier/mapper and value families for dimensions/reasons/categories/actionability/readiness/trust.
|
||||
- **New enum/state/reason family?**: yes. Each value must change operator interpretation, run aggregation, future resolution routing, evidence/review readiness preparation, or testable behavior.
|
||||
- **New cross-domain UI framework/taxonomy?**: no. Rendered UI uses existing surfaces/helpers and maps directly from domain truth.
|
||||
- **Current operator problem**: Operators cannot distinguish real drift, missing evidence, missing provider resource, unsupported coverage, accepted limitation, excluded scope, unresolved identity, and compare failure from current overloaded strings.
|
||||
- **Existing structure is insufficient because**: Spec 382 upstream matching outputs are still collapsed into legacy gap reasons and broad run reason codes. Individual label patches would preserve ambiguity across job context, strategy results, support diagnostics, evidence/review consumers, and tests.
|
||||
- **Narrowest correct implementation**: One baseline compare semantics layer that maps existing matching and strategy outputs into structured payloads, updates existing reason values, and updates focused tests.
|
||||
- **Ownership cost**: New values and mapping tests must be maintained. Reviewers must prevent the model from growing into a broad workflow, UI, report, or evidence readiness framework.
|
||||
- **Alternative intentionally rejected**: Keep old reasons and add display labels. This would make old overloaded states remain product truth and would not protect future Specs 384 and 385.
|
||||
- **Release truth**: Current-release truth. Spec 382 is already implemented and its matching outcomes require final compare semantics now.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
TenantPilot is pre-production. V1 must not add legacy result-code aliases, historical compare payload readers, old OperationRun context mappers, dual old/new reason readers, or compatibility tests that preserve old reason meanings. Existing local/dev compare records may be invalidated, reset, or destructively migrated if needed. Old tests that encode legacy reason semantics must be rewritten to the new truth model.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit for result value mapping and classifiers; Feature for baseline compare integration, OperationRun gap payloads, run summary calculation, existing UI/status rendering where affected, and evidence/review regressions.
|
||||
- **Validation lane(s)**: fast-feedback and confidence. PostgreSQL lane only if implementation introduces migrations, JSONB index/query behavior, locks, or constraints. Browser lane only if rendered layout/navigation/action behavior changes beyond existing labels/groups.
|
||||
- **Why this classification and these lanes are sufficient**: The feature changes deterministic domain semantics and existing DB-backed compare results. No new route, workflow, or JavaScript interaction is planned.
|
||||
- **New or expanded test families**: focused unit tests for result semantics and feature tests in `tests/Feature/Baselines`, plus existing evidence/review regression tests. No broad heavy-governance or browser family by default.
|
||||
- **Fixture / helper cost impact**: reuse existing baseline compare fixtures and Spec 382 provider-resource identity fixtures. Do not widen global workspace/provider defaults.
|
||||
- **Heavy-family visibility / justification**: none by default.
|
||||
- **Special surface test profile**: standard-native-filament relief unless implementation changes actual rendered layout/action hierarchy.
|
||||
- **Standard-native relief or required special coverage**: ordinary feature and existing Filament/status rendering coverage only; browser smoke becomes required only if UI structure changes.
|
||||
- **Reviewer handoff**: verify no old reasons remain authoritative, all result values have behavioral consequences, no low-trust no-drift, no provider-specific top-level semantics, no legacy mapper, no direct OperationRun lifecycle transitions, and no Spec 384/385 scope.
|
||||
- **Budget / baseline / trend impact**: expected small unit/feature runtime increase. Escalate as `follow-up-spec` if implementation needs broad evidence/review or UI productization coverage.
|
||||
- **Escalation needed**: document-in-feature for contained surface label/grouping changes; follow-up-spec for resolution UI, evidence/review readiness, customer output, or broad UI redesign.
|
||||
- **Active feature PR close-out entry**: Baseline Compare Result Semantics / Gap Classification.
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/CompareSemantics`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/Matching tests/Unit/Baselines/CompareStrategyRegistryTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareProviderResourceBindingCanonicalIdentityTest.php tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `git diff --check`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Classify compare subject outcomes explicitly (Priority: P1)
|
||||
|
||||
As a baseline governance operator, I need each subject outcome to state whether identity, comparison, coverage, actionability, readiness, and trust are healthy, blocked, missing, limited, unsupported, excluded, or failed, so I can understand why compare output is trustworthy or incomplete.
|
||||
|
||||
**Why this priority**: This is the core source-of-truth change and unlocks all other stories.
|
||||
|
||||
**Independent Test**: Unit tests cover every result reason and prove it maps to exactly one category, actionability, readiness impact, and trust rule.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a trusted comparable subject with equal payload, **When** result semantics are calculated, **Then** the subject is `no_drift`, `fully_verified`, `no_impact`, high trust, and no action required.
|
||||
2. **Given** a trusted comparable subject with changed payload, **When** result semantics are calculated, **Then** the subject is `drift_detected` with trusted comparison state and no identity blocker.
|
||||
3. **Given** unresolved identity, duplicate candidates, missing local evidence, missing provider resource, unsupported class, accepted limitation, or excluded non-governed scope, **When** result semantics are calculated, **Then** each produces a distinct reason/category/actionability/readiness combination.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Map Spec 382 matching outcomes without false green or false red output (Priority: P1)
|
||||
|
||||
As a release reviewer, I need `MatchingOutcome` states from Spec 382 to map into final compare semantics without legacy labels, so resolved identity can proceed to comparison and unresolved or limited identity cannot become healthy no drift.
|
||||
|
||||
**Why this priority**: Spec 383 depends on Spec 382 and must not leave matching results as a second taxonomy.
|
||||
|
||||
**Independent Test**: Unit and feature tests map active binding, canonical identity, missing local evidence, missing provider resource, unsupported, limitation, excluded, ambiguous, and identity-required outcomes into final result semantics.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an active binding that resolves to a current provider descriptor, **When** compare runs, **Then** identity is resolved by binding and comparison proceeds.
|
||||
2. **Given** a low-trust, display-label-only, unresolved, duplicate, unsupported, excluded, or accepted-limitation matching outcome, **When** compare runs, **Then** it cannot produce clean `no_drift`.
|
||||
3. **Given** an inventory-only or identity-only foundation subject, **When** compare runs, **Then** the result is a limitation rather than a missing policy or no-drift result.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Store structured OperationRun proof and run summaries (Priority: P2)
|
||||
|
||||
As a support/platform operator, I need baseline compare OperationRun context to expose structured subject outcome proof and summary counts, so Monitoring and support diagnostics can explain what happened without decoding legacy strings.
|
||||
|
||||
**Why this priority**: OperationRun proof is the audit and troubleshooting surface for queued compare operations.
|
||||
|
||||
**Independent Test**: Feature tests assert OperationRun baseline compare context includes structured subject outcome payloads, reason/category counts, and summary outcome decisions derived from new semantics.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** blockers such as `identity_required`, duplicate candidates, missing required provider resource, or compare failure, **When** the run completes, **Then** the run summary marks the compare blocked or partial according to the explicit required-scope rule and records blocker counts.
|
||||
2. **Given** only trusted no-drift and trusted drift results with no blockers, **When** the run completes, **Then** the operation is completed with trustworthy counts and drift findings are represented as findings, not operation failure.
|
||||
3. **Given** limitations, unsupported resource classes, accepted limitations, or excluded non-governed subjects, **When** the run completes, **Then** the run records warnings/partial or limitation counts without claiming verified no drift for those subjects.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Render existing compare detail groups from the new truth (Priority: P2)
|
||||
|
||||
As an operator reviewing compare output, I need existing compare detail surfaces to group results into verified, drift, action required, missing evidence, missing provider resource, unsupported, limitations, excluded, and failed, so I can scan the result without interpreting internal codes.
|
||||
|
||||
**Why this priority**: The runtime truth should be visible enough on existing surfaces without waiting for the full resolution UI.
|
||||
|
||||
**Independent Test**: Existing Filament/Livewire feature tests assert baseline compare gap/detail surfaces render new group labels and do not expose old reason strings as primary operator truth.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** mixed compare results, **When** an existing compare detail or OperationRun detail surface renders evidence gaps, **Then** it groups subjects by the new result categories.
|
||||
2. **Given** old reasons are removed as authoritative values, **When** existing status/explanation helpers render copy, **Then** they use provider-neutral labels and next-action classes.
|
||||
3. **Given** customer-facing review output is generated, **When** Spec 383 is implemented, **Then** customer-ready publication wording remains unchanged until Spec 385.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Active binding points to a provider resource that is not present in latest current descriptors.
|
||||
- Current provider descriptor collection is stale or absent, so absence is not proven.
|
||||
- Duplicate tenant-owned candidates exist without an active binding.
|
||||
- A subject has only display-label identity or old local/dev subject-key data.
|
||||
- A foundation resource is inventory-only, identity-only, canonical-only, unsupported, accepted as a limitation, or excluded as non-governed.
|
||||
- Compare strategy throws a technical exception.
|
||||
- Compare strategy returns an incomplete/gap result after identity was resolved.
|
||||
- Drift exists with no blockers or limitations.
|
||||
- Excluded subjects appear in totals but must not count as verified or no drift.
|
||||
- Existing evidence/review consumers read baseline drift posture before Spec 385 exists.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-383-001**: TenantPilot MUST define provider-neutral baseline compare subject outcome semantics with identity status, comparison status, coverage status, actionability, readiness impact, trust level, reason, and category dimensions.
|
||||
- **FR-383-002**: TenantPilot MUST replace old overloaded compare/gap reasons as authoritative product semantics, including `ambiguous_match`, `policy_record_missing`, `foundation_not_policy_backed`, `missing_policy`, `missing_current`, `unsupported_subjects`, `unsupported_subject`, `coverage_unproven`, and `strategy_failed`.
|
||||
- **FR-383-003**: TenantPilot MUST distinguish resolved identity, binding-resolved identity, canonicalization-resolved identity, unresolved identity, missing identity, and unsupported identity.
|
||||
- **FR-383-004**: TenantPilot MUST distinguish no drift, drift detected, not compared, compare not supported, and compare failed.
|
||||
- **FR-383-005**: TenantPilot MUST distinguish fully verified, verified with limitations, inventory-only, identity-only, canonical-only, unsupported, missing local evidence, missing provider resource, excluded, and accepted limitation coverage.
|
||||
- **FR-383-006**: TenantPilot MUST classify each subject outcome by actionability, including none, operator action required, provider data refresh required, binding required, scope decision required, implementation gap, accepted, and excluded.
|
||||
- **FR-383-007**: TenantPilot MUST classify each subject outcome by readiness impact, including no impact, internal limitation, customer limitation, customer blocker, and internal blocker.
|
||||
- **FR-383-008**: TenantPilot MUST classify each subject outcome by trust level, including `high`, `medium`, `low`, `untrusted`, `not_applicable`, and `failed`.
|
||||
- **FR-383-009**: TenantPilot MUST classify each subject outcome by result category, including `verified`, `drift_detected`, `action_required`, `missing_evidence`, `missing_provider_resource`, `unsupported`, `limitation`, `excluded`, and `failed`.
|
||||
- **FR-383-010**: TenantPilot MUST define the V1 provider-neutral result reasons as: `verified_no_drift`, `verified_drift_detected`, `resolved_active_binding`, `resolved_canonical_identity`, `resolved_provider_identity`, `identity_required`, `unresolved_duplicate_candidates`, `unresolved_low_trust_match`, `unresolved_ambiguous_identity`, `missing_local_evidence`, `missing_provider_resource`, `unsupported_resource_class`, `foundation_inventory_only`, `foundation_identity_only`, `foundation_canonical_only`, `accepted_limitation`, `excluded_non_governed`, `compare_not_supported`, and `compare_failed`.
|
||||
- **FR-383-011**: Low-trust, label-only, unresolved, duplicate, unsupported, excluded, accepted-limitation, or missing-evidence outcomes MUST NOT produce clean no drift.
|
||||
- **FR-383-012**: Missing provider resource MUST be distinct from missing local evidence and may only be used when current provider evidence proves absence or an active binding explicitly marks the expected resource missing.
|
||||
- **FR-383-013**: Foundation resources MUST use provider-neutral limitation or unsupported semantics rather than policy-backed or missing-policy semantics.
|
||||
- **FR-383-014**: Accepted limitation MUST NOT count as verified no drift.
|
||||
- **FR-383-015**: Excluded non-governed subjects MUST be represented separately from no drift, unsupported, and accepted limitation.
|
||||
- **FR-383-016**: Spec 382 `MatchingOutcome` values MUST map into the new compare outcome semantics before run/gap aggregation.
|
||||
- **FR-383-017**: Compare strategies MUST only produce trusted drift/no-drift when identity is trusted and the subject is comparable.
|
||||
- **FR-383-018**: OperationRun baseline compare context/gap payloads MUST include structured subject outcome semantics and counts by reason/category/actionability/readiness.
|
||||
- **FR-383-019**: Run-level completed/partial/blocked/failed decisions MUST derive from explicit subject outcome semantics, not legacy reason strings.
|
||||
- **FR-383-020**: Existing compare detail/status presentation MUST render provider-neutral result groups from the new semantics without adding a new page or resolution workflow.
|
||||
- **FR-383-021**: Tests asserting old overloaded reason behavior MUST be rewritten or removed unless the old string is retained only as non-authoritative migration/test fixture input explicitly documented by this spec.
|
||||
- **FR-383-022**: No legacy compatibility mapper, dual reader, historical OperationRun context reader, or old reason alias system may be introduced.
|
||||
- **FR-383-023**: Spec 383 MUST NOT implement resolution UI, Evidence/Review readiness final mapping, customer-facing Review Pack wording, report/PDF runtime work, or generic workflow orchestration.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-383-001**: Outcome classification MUST be deterministic for the same matching, compare, evidence, and scope inputs.
|
||||
- **NFR-383-002**: Top-level result semantics MUST be provider-neutral and must not depend on Microsoft/Intune-specific labels.
|
||||
- **NFR-383-003**: Structured proof payloads MUST not store secrets, credentials, raw sensitive provider payloads, raw Graph error bodies, or unredacted operator notes.
|
||||
- **NFR-383-004**: OperationRun `status` and `outcome` transitions MUST remain service-owned through `OperationRunService`.
|
||||
- **NFR-383-005**: Summary count keys MUST remain compatible with `OperationSummaryKeys::all()` or update that canonical list with tests.
|
||||
- **NFR-383-006**: The implementation MUST avoid broad UI presenter, badge, workflow, or evidence readiness frameworks; direct mapping from canonical result truth to existing surfaces is preferred.
|
||||
- **NFR-383-007**: Test fixtures must keep provider, workspace, membership, and evidence setup explicit and must not widen global defaults.
|
||||
- **NFR-383-008**: No browser test is required unless implementation changes rendered layout/navigation/action behavior beyond existing labels/groups.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
Spec 383 may update existing labels/grouping rendered by existing Filament/Livewire surfaces. It does not add new actions.
|
||||
|
||||
| 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 result/gap display | existing baseline compare and OperationRun detail paths | unchanged | unchanged | unchanged | unchanged | unchanged | unchanged | N/A | N/A | Only status/group/label rendering may change; no new mutation/action |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Baseline compare subject outcome**: Derived result truth for one governed subject, carrying identity status, comparison status, coverage status, actionability, readiness impact, trust level, reason, category, and sanitized proof.
|
||||
- **Result reason**: Provider-neutral reason value that explains why the subject is verified, drifted, blocked, limited, unsupported, missing, excluded, or failed.
|
||||
- **Result category**: High-level grouping used for run aggregation and existing detail/status presentation.
|
||||
- **Required governed subject**: A governed subject whose baseline profile, subject scope, or provider-resource binding marks it as required for compare coverage. In V1, subjects included only for inventory context, accepted limitation, or explicit non-governed exclusion are not required governed subjects.
|
||||
- **OperationRun compare proof payload**: Structured existing OperationRun context payload containing subject outcome summaries, counts, and sanitized proof.
|
||||
- **Run summary classifier**: Derived aggregation that maps subject outcome categories into completed, partial/warning, blocked, or failed operation result semantics.
|
||||
|
||||
No new persisted entity/table/artifact is approved.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-383-001**: Automated tests prove every supported result reason maps to exactly one category, actionability, readiness impact, and trust rule.
|
||||
- **SC-383-002**: Automated tests prove low-trust, label-only, unresolved, duplicate, unsupported, accepted-limitation, excluded, and missing-evidence outcomes never produce clean no drift.
|
||||
- **SC-383-003**: Automated tests prove missing provider resource and missing local evidence produce distinct structured payloads and distinct operator actionability.
|
||||
- **SC-383-004**: Automated tests prove foundation inventory-only, identity-only, canonical-only, unsupported, and accepted-limitation cases do not use policy-backed or missing-policy semantics.
|
||||
- **SC-383-005**: OperationRun baseline compare context includes structured subject outcome payloads and counts by category/reason/actionability/readiness in covered integration tests.
|
||||
- **SC-383-006**: Existing evidence/review regression tests pass without adding customer-facing readiness or Review Pack wording changes.
|
||||
- **SC-383-007**: No old overloaded reason string remains as an authoritative enum/constant/test expectation for baseline compare result semantics.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **AC-383-001**: Provider-neutral result semantics exist and are used by baseline compare.
|
||||
- **AC-383-002**: Spec 382 matching outcomes map into final compare result semantics.
|
||||
- **AC-383-003**: Trusted identity and comparable subjects are required before drift/no-drift is produced.
|
||||
- **AC-383-004**: Missing provider resource and missing local evidence are separate states with separate actionability.
|
||||
- **AC-383-005**: Foundation and unsupported coverage are represented as limitations/unsupported, not missing policies.
|
||||
- **AC-383-006**: OperationRun gap/proof payloads contain structured semantics.
|
||||
- **AC-383-007**: Run summary classification derives from new categories, not legacy strings.
|
||||
- **AC-383-008**: Existing compare/detail status surfaces render the new group labels where they expose compare result truth.
|
||||
- **AC-383-009**: Old result-code compatibility is not introduced.
|
||||
- **AC-383-010**: No application implementation outside the selected runtime/result-semantics slice is included.
|
||||
|
||||
## V1 Decisions And Assumptions
|
||||
|
||||
- `identity_required`, unresolved duplicate candidates, missing required provider resource, required coverage unproven, and compare failure are blockers for required governed subjects.
|
||||
- Required governed subjects are determined from the existing baseline profile subject scope and provider-resource binding context. When the current data cannot prove a subject is optional, V1 treats the subject as required to avoid false green compare output.
|
||||
- `missing_local_evidence` requests provider data refresh unless current provider evidence proves absence.
|
||||
- `unsupported_resource_class`, `foundation_inventory_only`, `foundation_identity_only`, and `foundation_canonical_only` are limitations by default unless profile/scope rules mark the subject required enough to block readiness.
|
||||
- `accepted_limitation` is accepted and limited, not verified no drift.
|
||||
- Trusted drift is a completed compare result with findings when no blockers/failures exist; it is not an operation failure.
|
||||
- Excluded non-governed subjects may appear in totals and excluded counts but must not count as verified/no-drift.
|
||||
- V1 stores dimensions inside existing structured OperationRun/compare payloads and derived objects, not new columns or tables.
|
||||
- Existing customer-facing review output remains unchanged until Spec 385.
|
||||
|
||||
## Risks
|
||||
|
||||
- **False green**: Mitigated by clean-success rules and tests proving low-trust, limitation, unsupported, excluded, and missing-evidence cases cannot be no drift.
|
||||
- **False red**: Mitigated by separate limitation, unsupported, missing, blocker, and failed categories.
|
||||
- **Taxonomy bloat**: Mitigated by requiring each value to have a behavioral, aggregation, readiness, or operator-action consequence.
|
||||
- **Provider-specific leakage**: Mitigated by provider-neutral top-level terms and tests/grep checks for old policy-only top-level reasons.
|
||||
- **Scope creep into Specs 384/385**: Mitigated by explicit non-goals and regression tests that customer-facing readiness output is unchanged.
|
||||
- **UI noise**: Mitigated by reusing existing surfaces and grouping, not creating a new page or resolution workflow.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None blocking. Implementation must stop and update this spec and plan before continuing if it requires new persistence, a new UI workflow, a customer-facing readiness mapping, or a compatibility reader for old result payloads.
|
||||
|
||||
## Follow-up Spec Candidates
|
||||
|
||||
- Spec 384 - Baseline Subject Resolution UI & Operator Decisions v1.
|
||||
- Spec 385 - Evidence & Review Readiness Integration v1.
|
||||
- Optional later UI productization if existing compare result/detail surfaces need a broader redesign after 383 data semantics are implemented.
|
||||
179
specs/383-baseline-result-semantics/tasks.md
Normal file
179
specs/383-baseline-result-semantics/tasks.md
Normal file
@ -0,0 +1,179 @@
|
||||
# Tasks: Spec 383 - Baseline Compare Result Semantics and Gap Classification v1
|
||||
|
||||
**Input**: Design documents from `/specs/383-baseline-result-semantics/`
|
||||
**Prerequisites**: `spec.md`, `plan.md`, completed Specs 381 and 382 close-outs
|
||||
|
||||
**Tests**: Runtime behavior changes require Pest unit and feature tests before or alongside implementation. Browser tests are not required unless implementation changes rendered layout, navigation, actions, or JavaScript behavior.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] TGC001 Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||
- [x] TGC002 New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
|
||||
- [x] TGC003 Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
|
||||
- [x] TGC004 Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||
- [x] TGC005 The declared surface test profile or `standard-native-filament` relief is explicit.
|
||||
- [x] TGC006 Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
||||
|
||||
## Phase 1: Preparation And Guardrails
|
||||
|
||||
**Purpose**: Protect completed history, confirm repo truth, and keep the implementation bounded.
|
||||
|
||||
- [x] T001 Confirm `specs/381-provider-resource-identity-binding/implementation-close-out.md` and `specs/382-baseline-matching-canonicalization/implementation-close-out.md` exist and treat both as dependency context only.
|
||||
- [x] T002 Confirm no code or artifact changes are made to completed specs `specs/381-provider-resource-identity-binding/`, `specs/382-baseline-matching-canonicalization/`, `specs/163-baseline-subject-resolution/`, `specs/336-baseline-compare-product-process-flow-alignment/`, `specs/347-review-pack-output-contract-readiness-semantics/`, `specs/350-operator-resolution-guidance-framework-v1/`, or `specs/380-management-report-pdf-staging-runtime-validation/`.
|
||||
- [x] T003 Re-read `apps/platform/app/Support/Baselines/Matching/MatchingOutcome.php`, `apps/platform/app/Services/Baselines/Matching/SubjectMatchingPipeline.php`, and `apps/platform/app/Services/Baselines/Matching/FoundationCoverageResolver.php` before implementation.
|
||||
- [x] T004 Re-read `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Support/Baselines/Compare/CompareSubjectResult.php`, `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php`, and `apps/platform/app/Support/Baselines/Compare/CompareState.php` before implementation.
|
||||
- [x] T005 Re-read `apps/platform/app/Support/Baselines/BaselineCompareReasonCode.php`, `apps/platform/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`, `apps/platform/app/Support/Baselines/SubjectResolver.php`, and `apps/platform/app/Support/Baselines/ResolutionOutcome.php` before implementation.
|
||||
- [x] T006 Confirm no new route, navigation entry, destructive action, Filament panel provider, Livewire component, queue name, scheduler entry, env var, storage path, or persisted entity is needed; if any is needed, stop and update `specs/383-baseline-result-semantics/spec.md` and `plan.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Tests First - Core Semantics
|
||||
|
||||
**Purpose**: Lock the new source of truth before changing runtime code.
|
||||
|
||||
- [x] T007 [P] [US1] Add coverage in `apps/platform/tests/Unit/Support/Baselines/CompareSemantics/BaselineCompareOutcomeClassifierTest.php` for every V1 reason, category, actionability, readiness impact, and trust-level mapping.
|
||||
- [x] T008 [P] [US1] Add coverage in `apps/platform/tests/Unit/Support/Baselines/CompareSemantics/BaselineCompareOutcomeClassifierTest.php` for clean success rules, drift, no drift, blocker, limitation, unsupported, missing, excluded, and failed outcomes.
|
||||
- [x] T009 [P] [US1] Add `apps/platform/tests/Unit/Support/Baselines/CompareSemantics/BaselineCompareOutcomeClassifierTest.php` covering trusted no-drift, trusted drift, identity required, duplicate candidates, missing provider resource, missing local evidence, unsupported resource, inventory-only foundation, identity-only foundation, accepted limitation, excluded non-governed, low-trust not no-drift, and compare failure.
|
||||
- [x] T010 [P] [US3] Add coverage in `apps/platform/tests/Unit/Support/Baselines/CompareSemantics/BaselineCompareOutcomeClassifierTest.php` for completed, completed-with-drift, partial/limited, blocked, and failed run aggregation.
|
||||
- [x] T011 [P] [US1] Add coverage in `apps/platform/tests/Unit/Support/Baselines/CompareSemantics/BaselineCompareOutcomeClassifierTest.php` asserting old overloaded reason values are not authoritative enum/constant values in the new semantics model.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Tests First - Matching And Compare Integration
|
||||
|
||||
**Purpose**: Prove Spec 382 matching outcomes map to final compare semantics without false green or false red output.
|
||||
|
||||
- [x] T012 [P] [US2] Update `apps/platform/tests/Unit/Support/Baselines/Matching/MatchingOutcomeTest.php` so matching reasons expected by Spec 383 are provider-neutral and no longer assert `ambiguous_match`, `unsupported_subject`, or `foundation_not_policy_backed` as final result truth.
|
||||
- [x] T013 [P] [US2] Update `apps/platform/tests/Unit/Support/Baselines/Matching/SubjectMatchingPipelineTest.php` to assert active binding, canonical identity, duplicate candidates, missing local evidence, missing provider resource, unsupported, limited, excluded, and identity-required outcomes map through the classifier.
|
||||
- [x] T014 [P] [US2] Update `apps/platform/tests/Unit/Services/Baselines/Matching/FoundationCoverageResolverTest.php` so inventory-only, identity-only, canonical-only, unsupported, and accepted limitation coverage expect the new provider-neutral reason names.
|
||||
- [x] T015 [P] [US2] Update `apps/platform/tests/Feature/Baselines/BaselineCompareProviderResourceBindingCanonicalIdentityTest.php` to assert binding-resolved identity produces trusted comparison eligibility but not no-drift by itself.
|
||||
- [x] T016 [P] [US2] Update `apps/platform/tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php` to expect `unresolved_duplicate_candidates` or `unresolved_ambiguous_identity` instead of `ambiguous_match`.
|
||||
- [x] T017 [P] [US1] Update `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php` to assert missing local evidence, missing provider resource, and foundation limitation states are distinct.
|
||||
- [x] T018 [P] [US3] Update `apps/platform/tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php` so compare strategy exceptions map to compare-failed semantics without relying on `strategy_failed` as an authoritative subject reason.
|
||||
- [x] T019 [P] [US3] Update `apps/platform/tests/Feature/Baselines/BaselineCompareResumeTokenTest.php` so resumed full-content gaps use new missing-local-evidence semantics instead of `policy_record_missing`.
|
||||
- [x] T020 [P] [US1] Update `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php` to prove stale or absent current provider descriptors do not by themselves emit `missing_provider_resource`, and that missing-provider semantics require current-provider absence proof or an active binding that marks the expected resource missing.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Tests First - Existing Presentation And Downstream Regressions
|
||||
|
||||
**Purpose**: Keep existing surfaces and downstream consumers honest without implementing Spec 384 or 385.
|
||||
|
||||
- [x] T021 [P] [US4] Update `apps/platform/tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php` to assert existing evidence-gap rows render the new result groups and do not expose old reason strings as primary operator truth.
|
||||
- [x] T022 [P] [US4] Update `apps/platform/tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php` to assert provider-neutral blocker/limitation/missing/failure explanations.
|
||||
- [x] T023 [P] [US4] Update `apps/platform/tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php` to prove no-drift explanation appears only when the new clean-success rules allow it.
|
||||
- [x] T024 [P] [US4] Update `apps/platform/tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php` to assert result group totals stay consistent with OperationRun context counts.
|
||||
- [x] T025 [P] [US3] Update `apps/platform/tests/Feature/Evidence/BaselineDriftPostureSourceTest.php` to prove evidence posture does not treat blockers/limitations as verified no drift before Spec 385.
|
||||
- [x] T026 [P] [US3] Update `apps/platform/tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php` and `apps/platform/tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php` to prove customer-facing readiness/output wording is unchanged by Spec 383.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Define The Narrow Result Semantics Model
|
||||
|
||||
**Purpose**: Add provider-neutral value families with direct behavioral consequences.
|
||||
|
||||
- [x] T027 [US1] Create `apps/platform/app/Support/Baselines/CompareSemantics/CompareResultIdentityStatus.php` with resolved, binding-resolved, canonicalization-resolved, unresolved, missing, and unsupported identity values.
|
||||
- [x] T028 [US1] Create `apps/platform/app/Support/Baselines/CompareSemantics/CompareResultComparisonStatus.php` with not-compared, no-drift, drift-detected, compare-failed, and compare-not-supported values.
|
||||
- [x] T029 [US1] Create `apps/platform/app/Support/Baselines/CompareSemantics/CompareResultCoverageStatus.php` with fully-verified, verified-with-limitations, inventory-only, identity-only, canonical-only, unsupported, missing-local-evidence, missing-provider-resource, excluded, and accepted-limitation values.
|
||||
- [x] T030 [US1] Create `apps/platform/app/Support/Baselines/CompareSemantics/CompareResultActionability.php`, `CompareResultReadinessImpact.php`, `CompareResultTrustLevel.php`, and `CompareResultCategory.php` with the V1 values from `specs/383-baseline-result-semantics/spec.md`.
|
||||
- [x] T031 [US1] Create `apps/platform/app/Support/Baselines/CompareSemantics/CompareResultReason.php` with provider-neutral reasons and mapping methods for category, actionability, readiness impact, and default trust.
|
||||
- [x] T032 [US1] Create `apps/platform/app/Support/Baselines/CompareSemantics/CompareSubjectOutcome.php` as a derived result object with sanitized `toArray()` output for OperationRun/context use.
|
||||
- [x] T033 [US1] Create `apps/platform/app/Support/Baselines/CompareSemantics/BaselineCompareOutcomeClassifier.php` to map matching outcomes plus compare strategy outputs into `CompareSubjectOutcome`.
|
||||
- [x] T034 [US3] Create `apps/platform/app/Support/Baselines/CompareSemantics/BaselineCompareRunSummaryClassifier.php` to aggregate subject outcomes into run-level completed, partial, blocked, or failed decisions and count buckets.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Replace Legacy Matching And Gap Reasons
|
||||
|
||||
**Purpose**: Stop old matching/gap strings from remaining authoritative.
|
||||
|
||||
- [x] T035 [US2] Update `apps/platform/app/Support/Baselines/Matching/MatchingOutcome.php` so factory methods use new provider-neutral reason names and keep old strings out of authoritative output.
|
||||
- [x] T036 [US2] Update `apps/platform/app/Services/Baselines/Matching/FoundationCoverageResolver.php` so unsupported and foundation coverage returns `unsupported_resource_class`, `foundation_inventory_only`, `foundation_identity_only`, or `foundation_canonical_only` as appropriate.
|
||||
- [x] T037 [US2] Update `apps/platform/app/Services/Baselines/Matching/SubjectMatchingPipeline.php` to map duplicate candidates to `unresolved_duplicate_candidates`, low-trust/identity gaps to `identity_required` or `unresolved_low_trust_match`, active binding resolution to `resolved_active_binding`, and canonical/provider identity to provider-neutral resolved reasons.
|
||||
- [x] T038 [US1] Update `apps/platform/app/Support/Baselines/SubjectResolver.php`, `apps/platform/app/Support/Baselines/ResolutionOutcome.php`, and `apps/platform/app/Support/Baselines/ResolutionOutcomeRecord.php` so legacy policy-shaped reasons are no longer final compare result truth; retain only non-authoritative helper behavior if still needed by capture flows and document any boundary in code/tests.
|
||||
- [x] T039 [US1] Update `apps/platform/app/Support/Baselines/BaselineCompareReasonCode.php` so run-level reasons are either provider-neutral summary reasons or delegated to the new run summary classifier.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Compare Strategy And OperationRun Integration
|
||||
|
||||
**Purpose**: Make runtime compare output and proof payloads use the new truth.
|
||||
|
||||
- [x] T040 [US2] Update `apps/platform/app/Support/Baselines/Compare/CompareState.php` or its mapping layer so unsupported, incomplete, ambiguous, failed, drift, and no-drift states map to `CompareSubjectOutcome` without old reason strings.
|
||||
- [x] T041 [US2] Update `apps/platform/app/Support/Baselines/Compare/CompareSubjectResult.php` to expose structured semantic payloads or enough diagnostics for `BaselineCompareOutcomeClassifier` without duplicating result truth.
|
||||
- [x] T042 [US2] Update `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` so missing current evidence, unsupported subjects, ambiguous conditions, and compare failures emit provider-neutral diagnostics and keep drift/no-drift limited to trusted comparable subjects.
|
||||
- [x] T043 [US2] Update `apps/platform/tests/Feature/Baselines/Support/FakeCompareStrategy.php` to emit provider-neutral diagnostics used by Spec 383 tests.
|
||||
- [x] T044 [US3] Update `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` to build structured `CompareSubjectOutcome` records from matching outcomes and strategy results before gap aggregation.
|
||||
- [x] T045 [US3] Update `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` so `baseline_compare.evidence_gaps` includes structured counts by reason, category, actionability, readiness impact, and subject outcome payloads.
|
||||
- [x] T046 [US3] Update `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` so run outcome and `summary_counts` derive from `BaselineCompareRunSummaryClassifier` and stay compatible with `OperationSummaryKeys::all()`.
|
||||
- [x] T047 [US3] Update `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php` and `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php` only if Spec 383 needs new count keys not representable by the existing canonical list.
|
||||
- [x] T048 [US3] Add or update a focused test or guard assertion proving baseline compare aggregation does not mutate `OperationRun.status` or `OperationRun.outcome` outside `OperationRunService` while summary semantics change.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Existing Surface Labels And Downstream Consumers
|
||||
|
||||
**Purpose**: Render the new truth on existing surfaces without new UI workflows.
|
||||
|
||||
- [x] T049 [US4] Update `apps/platform/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php` to render provider-neutral group labels for verified, drift detected, action required, missing evidence, missing provider resource, unsupported, limitations, excluded, and failed.
|
||||
- [x] T050 [US4] Update `apps/platform/app/Support/Baselines/BaselineCompareExplanationRegistry.php` and `apps/platform/app/Support/ReasonTranslation/ReasonPresenter.php`; search for OperationRun baseline-compare presentation helpers directly touched by the implementation and update any matches so primary operator text no longer uses old reason strings.
|
||||
- [x] T051 [US4] Confirm `apps/platform/app/Livewire/BaselineCompareEvidenceGapTable.php` uses existing data paths and does not add a new action, route, modal, drawer, or layout pattern.
|
||||
- [x] T052 [US3] Update `apps/platform/app/Services/Evidence/Sources/BaselineDriftPostureSource.php` only if needed to avoid treating blocked/limited compare runs as complete before Spec 385.
|
||||
- [x] T053 [US4] If implementation changes route/layout/action structure instead of labels/groups only, update the active spec/plan plus `docs/ui-ux-enterprise-audit/route-inventory.md`, `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`, and `docs/ui-ux-enterprise-audit/page-reports/ui-015-baseline-compare.md` before continuing.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Legacy Removal And Scope Guard
|
||||
|
||||
**Purpose**: Remove old authoritative truth and prevent accidental compatibility scope.
|
||||
|
||||
- [x] T054 [US1] Search `apps/platform/app` and `apps/platform/tests` for `ambiguous_match`, `policy_record_missing`, `foundation_not_policy_backed`, `missing_policy`, `missing_current`, `unsupported_subject`, `unsupported_subjects`, `coverage_unproven`, and `strategy_failed`; remove or convert compare-result usages to the new semantics.
|
||||
- [x] T055 [US1] Keep any old string that remains only if it is outside baseline compare result truth or is explicitly transitional fixture input; document the boundary in the nearest test or close-out note.
|
||||
- [x] T056 [US1] Confirm no legacy result-code mapper, old OperationRun context reader, dual old/new result reader, or compatibility alias is introduced.
|
||||
- [x] T057 [US4] Confirm no Spec 384 resolution UI, manual bind/exclude/accept-limitation workflow, or operator decision screen is implemented.
|
||||
- [x] T058 [US3] Confirm no Spec 385 Evidence Snapshot readiness final mapping, Review Pack publication blocker mapping, or customer-facing wording is implemented.
|
||||
- [x] T059 [US4] Confirm no Management Report/PDF runtime or report wording work is included.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Validation And Close-Out
|
||||
|
||||
**Purpose**: Prove the implementation and document the exact operational impact.
|
||||
|
||||
- [x] T060 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/CompareSemantics`.
|
||||
- [x] T061 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines/Matching tests/Unit/Baselines/CompareStrategyRegistryTest.php`.
|
||||
- [x] T062 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php tests/Feature/Baselines/BaselineCompareAmbiguousMatchGapTest.php tests/Feature/Baselines/BaselineCompareProviderResourceBindingCanonicalIdentityTest.php tests/Feature/Baselines/BaselineCompareExecutionGuardTest.php tests/Feature/Baselines/BaselineCompareResumeTokenTest.php`.
|
||||
- [x] T063 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php tests/Feature/Filament/BaselineCompareEvidenceGapTableTest.php`.
|
||||
- [x] T064 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/BaselineDriftPostureSourceTest.php tests/Feature/ReviewPack/Spec347ReviewPackReadinessSemanticsTest.php tests/Feature/ReviewPack/Spec349ReviewPackResolutionGuidanceTest.php`.
|
||||
- [x] T065 Run a PostgreSQL lane only if implementation adds migrations, JSONB indexes/query behavior, locks, or constraints.
|
||||
- [x] T066 Run a browser smoke test only if implementation changes rendered layout, navigation, actions, or JavaScript behavior beyond labels/groups.
|
||||
- [x] T067 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
- [x] T068 Run `git diff --check`.
|
||||
- [x] T069 Record `specs/383-baseline-result-semantics/implementation-close-out.md` with Livewire v4 compliance, provider registration location, global search status, destructive/high-impact action status, asset strategy, tests run, browser decision, and deployment impact.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Phase 1 must finish before runtime work.
|
||||
- Phases 2-4 should be written before or alongside implementation changes.
|
||||
- Phase 5 unblocks Phases 6 and 7.
|
||||
- Phase 6 must complete before OperationRun/gap aggregation can be trusted.
|
||||
- Phase 7 unblocks Phase 8.
|
||||
- Phase 9 and Phase 10 validate the completed implementation.
|
||||
|
||||
## Parallel Opportunities
|
||||
|
||||
- T007-T011 can be drafted in parallel.
|
||||
- T012-T020 can be drafted in parallel if each test file remains scoped.
|
||||
- T021-T026 can be drafted in parallel with the core semantics tests.
|
||||
- T027-T034 can be implemented in parallel after names are agreed, but mapping methods should converge before integration.
|
||||
- T035-T039 and T040-T048 should be coordinated because they touch shared reason mappings.
|
||||
- T060-T064 can be run independently after implementation, but close-out should cite the complete targeted set.
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
- Do not add new persisted entities/tables/artifacts without updating spec and plan first.
|
||||
- Do not add new routes, navigation entries, Filament actions, modals, drawers, wizards, panel providers, or assets.
|
||||
- Do not add operator resolution UI.
|
||||
- Do not change final Evidence Snapshot readiness, Review Pack readiness, or customer-facing report/review wording.
|
||||
- Do not add historical payload mappers, OperationRun context compatibility readers, or old reason aliases.
|
||||
- Do not create a generic workflow engine, report engine, provider framework, badge framework, or evidence readiness framework.
|
||||
Loading…
Reference in New Issue
Block a user