## Summary - add the structured subject-resolution foundation for baseline compare and baseline capture, including capability guards, subject descriptors, resolution outcomes, and operator action categories - persist structured evidence-gap subject records and update compare/capture surfaces, landing projections, and cleanup tooling to use the new contract - add Spec 163 artifacts and focused Pest coverage for classification, determinism, cleanup, and DB-only rendering ## Validation - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` ## Notes - verified locally that a fresh post-restart baseline compare run now writes structured `baseline_compare.evidence_gaps.subjects` records instead of the legacy broad payload shape - excluded the separate `docs/product/spec-candidates.md` worktree change from this branch commit and PR Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #193
202 lines
8.3 KiB
PHP
202 lines
8.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Baselines;
|
|
|
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
|
|
|
final class SubjectResolver
|
|
{
|
|
public function capability(string $policyType): SupportCapabilityRecord
|
|
{
|
|
$contract = InventoryPolicyTypeMeta::baselineSupportContract($policyType);
|
|
|
|
return new SupportCapabilityRecord(
|
|
policyType: $policyType,
|
|
subjectClass: SubjectClass::from($contract['subject_class']),
|
|
compareCapability: $contract['compare_capability'],
|
|
captureCapability: $contract['capture_capability'],
|
|
resolutionPath: ResolutionPath::from($contract['resolution_path']),
|
|
configSupported: (bool) $contract['config_supported'],
|
|
runtimeValid: (bool) $contract['runtime_valid'],
|
|
sourceModelExpected: $contract['source_model_expected'],
|
|
);
|
|
}
|
|
|
|
public function describeForCompare(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
|
{
|
|
return $this->describe(operation: 'compare', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
|
|
}
|
|
|
|
public function describeForCapture(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
|
{
|
|
return $this->describe(operation: 'capture', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
|
|
}
|
|
|
|
public function resolved(SubjectDescriptor $descriptor, ?string $sourceModelFound = null): ResolutionOutcomeRecord
|
|
{
|
|
$outcome = $descriptor->expectsPolicy()
|
|
? ResolutionOutcome::ResolvedPolicy
|
|
: ResolutionOutcome::ResolvedInventory;
|
|
|
|
return new ResolutionOutcomeRecord(
|
|
resolutionOutcome: $outcome,
|
|
reasonCode: $outcome->value,
|
|
operatorActionCategory: OperatorActionCategory::None,
|
|
structural: false,
|
|
retryable: false,
|
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
|
sourceModelFound: $sourceModelFound ?? $descriptor->sourceModelExpected,
|
|
);
|
|
}
|
|
|
|
public function missingExpectedRecord(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
|
{
|
|
$expectsPolicy = $descriptor->expectsPolicy();
|
|
|
|
return new ResolutionOutcomeRecord(
|
|
resolutionOutcome: $expectsPolicy ? ResolutionOutcome::PolicyRecordMissing : ResolutionOutcome::InventoryRecordMissing,
|
|
reasonCode: $expectsPolicy ? 'policy_record_missing' : 'inventory_record_missing',
|
|
operatorActionCategory: $expectsPolicy ? OperatorActionCategory::RunPolicySyncOrBackup : OperatorActionCategory::RunInventorySync,
|
|
structural: false,
|
|
retryable: false,
|
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
|
);
|
|
}
|
|
|
|
public function structuralInventoryOnly(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
|
{
|
|
return new ResolutionOutcomeRecord(
|
|
resolutionOutcome: ResolutionOutcome::FoundationInventoryOnly,
|
|
reasonCode: 'foundation_not_policy_backed',
|
|
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
|
structural: true,
|
|
retryable: false,
|
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
|
sourceModelFound: 'inventory',
|
|
);
|
|
}
|
|
|
|
public function invalidSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
|
{
|
|
return new ResolutionOutcomeRecord(
|
|
resolutionOutcome: ResolutionOutcome::InvalidSubject,
|
|
reasonCode: 'invalid_subject',
|
|
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
|
structural: false,
|
|
retryable: false,
|
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
|
);
|
|
}
|
|
|
|
public function duplicateSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
|
{
|
|
return new ResolutionOutcomeRecord(
|
|
resolutionOutcome: ResolutionOutcome::DuplicateSubject,
|
|
reasonCode: 'duplicate_subject',
|
|
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
|
structural: false,
|
|
retryable: false,
|
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
|
);
|
|
}
|
|
|
|
public function ambiguousMatch(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
|
{
|
|
return new ResolutionOutcomeRecord(
|
|
resolutionOutcome: ResolutionOutcome::AmbiguousMatch,
|
|
reasonCode: 'ambiguous_match',
|
|
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
|
|
structural: false,
|
|
retryable: false,
|
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
|
);
|
|
}
|
|
|
|
public function invalidSupportConfiguration(SupportCapabilityRecord $capability): ResolutionOutcomeRecord
|
|
{
|
|
return new ResolutionOutcomeRecord(
|
|
resolutionOutcome: ResolutionOutcome::InvalidSupportConfig,
|
|
reasonCode: 'invalid_support_config',
|
|
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
|
|
structural: true,
|
|
retryable: false,
|
|
sourceModelExpected: $capability->sourceModelExpected,
|
|
);
|
|
}
|
|
|
|
public function throttled(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
|
{
|
|
return new ResolutionOutcomeRecord(
|
|
resolutionOutcome: ResolutionOutcome::Throttled,
|
|
reasonCode: 'throttled',
|
|
operatorActionCategory: OperatorActionCategory::Retry,
|
|
structural: false,
|
|
retryable: true,
|
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
|
);
|
|
}
|
|
|
|
public function captureFailed(SubjectDescriptor $descriptor, bool $retryable = false): ResolutionOutcomeRecord
|
|
{
|
|
return new ResolutionOutcomeRecord(
|
|
resolutionOutcome: $retryable ? ResolutionOutcome::RetryableCaptureFailure : ResolutionOutcome::CaptureFailed,
|
|
reasonCode: $retryable ? 'retryable_capture_failure' : 'capture_failed',
|
|
operatorActionCategory: OperatorActionCategory::Retry,
|
|
structural: false,
|
|
retryable: $retryable,
|
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
|
);
|
|
}
|
|
|
|
public function budgetExhausted(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
|
|
{
|
|
return new ResolutionOutcomeRecord(
|
|
resolutionOutcome: ResolutionOutcome::BudgetExhausted,
|
|
reasonCode: 'budget_exhausted',
|
|
operatorActionCategory: OperatorActionCategory::Retry,
|
|
structural: false,
|
|
retryable: true,
|
|
sourceModelExpected: $descriptor->sourceModelExpected,
|
|
);
|
|
}
|
|
|
|
private function describe(string $operation, string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
|
|
{
|
|
$capability = $this->capability($policyType);
|
|
$resolvedSubjectKey = $this->normalizeSubjectKey($policyType, $subjectExternalId, $subjectKey);
|
|
|
|
return new SubjectDescriptor(
|
|
policyType: $policyType,
|
|
subjectExternalId: $subjectExternalId !== null && trim($subjectExternalId) !== '' ? trim($subjectExternalId) : null,
|
|
subjectKey: $resolvedSubjectKey,
|
|
subjectClass: $capability->subjectClass,
|
|
resolutionPath: $capability->resolutionPath,
|
|
supportMode: $capability->supportModeFor($operation),
|
|
sourceModelExpected: $capability->sourceModelExpected,
|
|
);
|
|
}
|
|
|
|
private function normalizeSubjectKey(string $policyType, ?string $subjectExternalId, ?string $subjectKey): string
|
|
{
|
|
$trimmedSubjectKey = is_string($subjectKey) ? trim($subjectKey) : '';
|
|
|
|
if ($trimmedSubjectKey !== '') {
|
|
return $trimmedSubjectKey;
|
|
}
|
|
|
|
$generated = BaselineSubjectKey::forPolicy($policyType, subjectExternalId: $subjectExternalId);
|
|
|
|
if (is_string($generated) && $generated !== '') {
|
|
return $generated;
|
|
}
|
|
|
|
$fallbackExternalId = is_string($subjectExternalId) && trim($subjectExternalId) !== ''
|
|
? trim($subjectExternalId)
|
|
: 'unknown';
|
|
|
|
return trim($policyType).'|'.$fallbackExternalId;
|
|
}
|
|
}
|