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

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

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

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #193
2026-03-25 12:40:45 +00:00

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;
}
}