$policyTypes * @param list $foundationTypes * @param list, filters: array}> $entries * @param list $legacyKeysPresent */ public function __construct( public readonly array $policyTypes = [], public readonly array $foundationTypes = [], public readonly array $entries = [], public readonly int $version = 2, public readonly string $sourceShape = 'canonical_v2', public readonly bool $normalizedOnRead = false, public readonly array $legacyKeysPresent = [], public readonly bool $saveForwardRequired = false, ) {} /** * Create from the scope_jsonb column value. * * @param array|null $scopeJsonb */ public static function fromJsonb(?array $scopeJsonb, bool $allowEmptyLegacyAsNoOverride = false): self { if ($scopeJsonb === null) { return self::fromLegacyPayload( policyTypes: [], foundationTypes: [], legacyKeysPresent: [], normalizedOnRead: true, saveForwardRequired: true, ); } if (isset($scopeJsonb['canonical_scope']) && is_array($scopeJsonb['canonical_scope'])) { return self::fromJsonb($scopeJsonb['canonical_scope'], $allowEmptyLegacyAsNoOverride); } $hasLegacyKeys = array_key_exists('policy_types', $scopeJsonb) || array_key_exists('foundation_types', $scopeJsonb); $hasCanonicalKeys = array_key_exists('version', $scopeJsonb) || array_key_exists('entries', $scopeJsonb); if ($hasLegacyKeys && $hasCanonicalKeys) { throw new InvalidArgumentException('Baseline scope payload must not mix legacy buckets with canonical V2 keys.'); } if ($hasCanonicalKeys) { return self::fromCanonicalPayload($scopeJsonb); } if (! $hasLegacyKeys) { throw new InvalidArgumentException('Baseline scope payload must contain either legacy buckets or canonical V2 keys.'); } $legacyKeysPresent = array_values(array_filter([ array_key_exists('policy_types', $scopeJsonb) ? 'policy_types' : null, array_key_exists('foundation_types', $scopeJsonb) ? 'foundation_types' : null, ])); $policyTypes = self::stringList($scopeJsonb['policy_types'] ?? []); $foundationTypes = self::stringList($scopeJsonb['foundation_types'] ?? []); if ($allowEmptyLegacyAsNoOverride && $policyTypes === [] && $foundationTypes === []) { return new self( policyTypes: [], foundationTypes: [], entries: [], sourceShape: 'legacy', normalizedOnRead: false, legacyKeysPresent: $legacyKeysPresent, saveForwardRequired: false, ); } return self::fromLegacyPayload( policyTypes: $policyTypes, foundationTypes: $foundationTypes, legacyKeysPresent: $legacyKeysPresent, normalizedOnRead: true, saveForwardRequired: true, ); } /** * Normalize the effective scope by intersecting profile scope with an optional override. * * Override can only narrow the profile scope (subset enforcement). * Empty override means "no override". */ public static function effective(self $profileScope, ?self $overrideScope): self { $profileScope = $profileScope->expandDefaults(); if ($overrideScope === null || $overrideScope->isEmpty()) { return $profileScope; } $overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes); $overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes); $entries = []; foreach ($profileScope->entries as $entry) { $subjectTypeKeys = $entry['subject_type_keys']; if ($entry['domain_key'] === GovernanceDomainKey::Intune->value && $entry['subject_class'] === GovernanceSubjectClass::Policy->value && $overridePolicyTypes !== []) { $subjectTypeKeys = array_values(array_intersect($subjectTypeKeys, $overridePolicyTypes)); } if ($entry['domain_key'] === GovernanceDomainKey::PlatformFoundation->value && $entry['subject_class'] === GovernanceSubjectClass::ConfigurationResource->value && $overrideFoundationTypes !== []) { $subjectTypeKeys = array_values(array_intersect($subjectTypeKeys, $overrideFoundationTypes)); } $subjectTypeKeys = self::uniqueSorted($subjectTypeKeys); if ($subjectTypeKeys === []) { continue; } $entries[] = [ 'domain_key' => $entry['domain_key'], 'subject_class' => $entry['subject_class'], 'subject_type_keys' => $subjectTypeKeys, 'filters' => $entry['filters'], ]; } return self::fromCanonicalPayload([ 'version' => 2, 'entries' => $entries, ]); } /** * An empty scope means "no override" (for override_scope semantics). */ public function isEmpty(): bool { return $this->entries === [] && $this->policyTypes === [] && $this->foundationTypes === []; } /** * Apply Spec 116 defaults and filter to supported types. */ public function expandDefaults(): self { if ($this->entries !== []) { return $this; } return self::fromLegacyPayload( policyTypes: $this->policyTypes, foundationTypes: $this->foundationTypes, legacyKeysPresent: $this->legacyKeysPresent, normalizedOnRead: $this->normalizedOnRead, saveForwardRequired: $this->saveForwardRequired, ); } /** * @return list */ public function allTypes(): array { $expanded = $this->expandDefaults(); return self::uniqueSorted(array_merge( $expanded->policyTypes, $expanded->foundationTypes, )); } /** * @return list */ public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array { $guard ??= app(BaselineSupportCapabilityGuard::class); $guardResult = $guard->guardTypes($this->allTypes(), $operation); return $guardResult['allowed_types']; } /** * @return array */ public function toJsonb(): array { $supportedPolicyTypes = self::supportedPolicyTypes(); $policyTypes = $this->policyTypes; if ($policyTypes === $supportedPolicyTypes) { $policyTypes = []; } return [ 'policy_types' => $policyTypes, 'foundation_types' => $this->foundationTypes, ]; } /** * @return array{version: 2, entries: list, filters: array}>} */ public function toStoredJsonb(): array { return [ 'version' => 2, 'entries' => $this->entries, ]; } /** * @return array{source_shape: string, normalized_on_read: bool, legacy_keys_present: list, save_forward_required: bool} */ public function normalizationLineage(): array { return [ 'source_shape' => $this->sourceShape, 'normalized_on_read' => $this->normalizedOnRead, 'legacy_keys_present' => $this->legacyKeysPresent, 'save_forward_required' => $this->saveForwardRequired, ]; } /** * @return list, capture_supported_count: int, compare_supported_count: int, inactive_count: int}> */ public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = null): array { $registry ??= app(GovernanceSubjectTaxonomyRegistry::class); $groups = []; foreach ($this->entries as $entry) { $selectedSubjectTypes = []; $captureSupportedCount = 0; $compareSupportedCount = 0; $inactiveCount = 0; foreach ($entry['subject_type_keys'] as $subjectTypeKey) { $subjectType = $registry->find($entry['domain_key'], $subjectTypeKey); $selectedSubjectTypes[] = $subjectType?->label ?? $subjectTypeKey; if ($subjectType?->captureSupported) { $captureSupportedCount++; } if ($subjectType?->compareSupported) { $compareSupportedCount++; } if ($subjectType !== null && ! $subjectType->active) { $inactiveCount++; } } sort($selectedSubjectTypes, SORT_STRING); $groups[] = [ 'domain_key' => $entry['domain_key'], 'subject_class' => $entry['subject_class'], 'group_label' => $registry->groupLabel($entry['domain_key'], $entry['subject_class']), 'selected_subject_types' => $selectedSubjectTypes, 'capture_supported_count' => $captureSupportedCount, 'compare_supported_count' => $compareSupportedCount, 'inactive_count' => $inactiveCount, ]; } return $groups; } /** * Effective scope payload for OperationRun.context. * * @return array */ public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array { $expanded = $this->expandDefaults(); $allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes)); $context = [ 'canonical_scope' => $expanded->toStoredJsonb(), 'legacy_projection' => $expanded->toJsonb(), 'policy_types' => $expanded->policyTypes, 'foundation_types' => $expanded->foundationTypes, 'all_types' => $allTypes, 'selected_type_keys' => $allTypes, 'foundations_included' => $expanded->foundationTypes !== [], ]; if (! is_string($operation) || $operation === '') { return $context; } $guard ??= app(BaselineSupportCapabilityGuard::class); $guardResult = $guard->guardTypes($allTypes, $operation); return array_merge($context, [ 'truthful_types' => $guardResult['allowed_types'], 'limited_types' => $guardResult['limited_types'], 'unsupported_types' => $guardResult['unsupported_types'], 'invalid_support_types' => $guardResult['invalid_support_types'], 'capabilities' => $guardResult['capabilities'], 'allowed_type_keys' => $guardResult['allowed_types'], 'limited_type_keys' => $guardResult['limited_types'], 'unsupported_type_keys' => $guardResult['unsupported_types'], 'capabilities_by_type' => $guardResult['capabilities'], ]); } /** * @return array{ok: bool, unsupported_types: list, invalid_support_types: list} */ public function operationEligibility(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array { $guard ??= app(BaselineSupportCapabilityGuard::class); $guardResult = $guard->guardTypes($this->allTypes(), $operation); return [ 'ok' => $guardResult['unsupported_types'] === [] && $guardResult['invalid_support_types'] === [], 'unsupported_types' => $guardResult['unsupported_types'], 'invalid_support_types' => $guardResult['invalid_support_types'], ]; } /** * @return list */ private static function supportedPolicyTypes(): array { return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('policy_types'); } /** * @return list */ private static function supportedFoundationTypes(): array { return app(GovernanceSubjectTaxonomyRegistry::class)->activeLegacyBucketKeys('foundation_types'); } /** * @param array $types * @return list */ private static function normalizePolicyTypes(array $types): array { $supported = self::supportedPolicyTypes(); return self::uniqueSorted(array_values(array_intersect($types, $supported))); } /** * @param array $types * @return list */ private static function normalizeFoundationTypes(array $types): array { $supported = self::supportedFoundationTypes(); return self::uniqueSorted(array_values(array_intersect($types, $supported))); } /** * @param array $types * @return list */ private static function uniqueSorted(array $types): array { $types = array_values(array_unique(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== ''))); sort($types, SORT_STRING); return $types; } /** * @param list $policyTypes * @param list $foundationTypes * @param list $legacyKeysPresent */ private static function fromLegacyPayload( array $policyTypes, array $foundationTypes, array $legacyKeysPresent, bool $normalizedOnRead, bool $saveForwardRequired, ): self { $policyTypes = $policyTypes === [] ? self::supportedPolicyTypes() : self::normalizePolicyTypes($policyTypes); $foundationTypes = self::normalizeFoundationTypes($foundationTypes); $entries = []; if ($policyTypes !== []) { $entries[] = [ 'domain_key' => GovernanceDomainKey::Intune->value, 'subject_class' => GovernanceSubjectClass::Policy->value, 'subject_type_keys' => $policyTypes, 'filters' => [], ]; } if ($foundationTypes !== []) { $entries[] = [ 'domain_key' => GovernanceDomainKey::PlatformFoundation->value, 'subject_class' => GovernanceSubjectClass::ConfigurationResource->value, 'subject_type_keys' => $foundationTypes, 'filters' => [], ]; } return new self( policyTypes: $policyTypes, foundationTypes: $foundationTypes, entries: $entries, sourceShape: 'legacy', normalizedOnRead: $normalizedOnRead, legacyKeysPresent: $legacyKeysPresent, saveForwardRequired: $saveForwardRequired, ); } /** * @param array $scopeJsonb */ private static function fromCanonicalPayload(array $scopeJsonb): self { if (($scopeJsonb['version'] ?? null) !== 2) { throw new InvalidArgumentException('Baseline scope version must equal 2.'); } $entries = $scopeJsonb['entries'] ?? null; if (! is_array($entries) || $entries === []) { throw new InvalidArgumentException('Baseline scope V2 entries must be a non-empty array.'); } $normalizedEntries = self::normalizeEntries($entries); [$policyTypes, $foundationTypes] = self::legacyProjectionFromEntries($normalizedEntries); return new self( policyTypes: $policyTypes, foundationTypes: $foundationTypes, entries: $normalizedEntries, sourceShape: 'canonical_v2', normalizedOnRead: false, legacyKeysPresent: [], saveForwardRequired: false, ); } /** * @param list $entries * @return list, filters: array}> */ private static function normalizeEntries(array $entries): array { $registry = app(GovernanceSubjectTaxonomyRegistry::class); $normalizedEntries = []; $subjectFilters = []; foreach ($entries as $entry) { if (! is_array($entry)) { throw new InvalidArgumentException('Each canonical baseline scope entry must be an array.'); } $domainKey = trim((string) ($entry['domain_key'] ?? '')); $subjectClass = trim((string) ($entry['subject_class'] ?? '')); if (! $registry->isKnownDomain($domainKey)) { throw new InvalidArgumentException('Unknown governance domain ['.$domainKey.'].'); } if (! $registry->allowsSubjectClass($domainKey, $subjectClass)) { throw new InvalidArgumentException('Subject class ['.$subjectClass.'] is not valid for domain ['.$domainKey.'].'); } $subjectTypeKeys = self::stringList($entry['subject_type_keys'] ?? null); if ($subjectTypeKeys === []) { throw new InvalidArgumentException('Canonical baseline scope entries must include at least one subject type key.'); } $filters = $entry['filters'] ?? []; if (! is_array($filters)) { throw new InvalidArgumentException('Baseline scope entry filters must be an object-shaped array.'); } $filters = self::normalizeFilters($filters); if ($filters !== [] && ! $registry->supportsFilters($domainKey, $subjectClass)) { throw new InvalidArgumentException('Filters are not supported for the current governance domain and subject class.'); } $subjectTypeKeys = array_map( static fn (string $subjectTypeKey): string => trim($subjectTypeKey), self::uniqueSorted($subjectTypeKeys), ); foreach ($subjectTypeKeys as $subjectTypeKey) { $subjectType = $registry->find($domainKey, $subjectTypeKey); if ($subjectType === null) { throw new InvalidArgumentException('Unknown subject type ['.$subjectTypeKey.'] for domain ['.$domainKey.'].'); } if ($subjectType->subjectClass->value !== $subjectClass) { throw new InvalidArgumentException('Subject type ['.$subjectTypeKey.'] does not belong to subject class ['.$subjectClass.'].'); } if (! $subjectType->active) { throw new InvalidArgumentException('Inactive subject type ['.$subjectTypeKey.'] cannot be selected.'); } } $filtersHash = self::filtersHash($filters); foreach ($subjectTypeKeys as $subjectTypeKey) { $subjectKey = implode('|', [$domainKey, $subjectClass, $subjectTypeKey]); $existingFiltersHash = $subjectFilters[$subjectKey] ?? null; if ($existingFiltersHash !== null && $existingFiltersHash !== $filtersHash) { throw new InvalidArgumentException('Ambiguous baseline scope filters were provided for ['.$subjectTypeKey.'].'); } $subjectFilters[$subjectKey] = $filtersHash; } $entryKey = implode('|', [$domainKey, $subjectClass, $filtersHash]); if (! array_key_exists($entryKey, $normalizedEntries)) { $normalizedEntries[$entryKey] = [ 'domain_key' => $domainKey, 'subject_class' => $subjectClass, 'subject_type_keys' => [], 'filters' => $filters, ]; } $normalizedEntries[$entryKey]['subject_type_keys'] = self::uniqueSorted(array_merge( $normalizedEntries[$entryKey]['subject_type_keys'], $subjectTypeKeys, )); } $normalizedEntries = array_values($normalizedEntries); usort($normalizedEntries, static function (array $left, array $right): int { $leftKey = implode('|', [ $left['domain_key'], $left['subject_class'], self::filtersHash($left['filters']), implode(',', $left['subject_type_keys']), ]); $rightKey = implode('|', [ $right['domain_key'], $right['subject_class'], self::filtersHash($right['filters']), implode(',', $right['subject_type_keys']), ]); return $leftKey <=> $rightKey; }); return $normalizedEntries; } /** * @param list, filters: array}> $entries * @return array{0: list, 1: list} */ private static function legacyProjectionFromEntries(array $entries): array { $policyTypes = []; $foundationTypes = []; foreach ($entries as $entry) { if ($entry['domain_key'] === GovernanceDomainKey::Intune->value && $entry['subject_class'] === GovernanceSubjectClass::Policy->value) { $policyTypes = array_merge($policyTypes, $entry['subject_type_keys']); } if ($entry['domain_key'] === GovernanceDomainKey::PlatformFoundation->value && $entry['subject_class'] === GovernanceSubjectClass::ConfigurationResource->value) { $foundationTypes = array_merge($foundationTypes, $entry['subject_type_keys']); } } return [ self::uniqueSorted($policyTypes), self::uniqueSorted($foundationTypes), ]; } /** * @param mixed $values * @return list */ private static function stringList(mixed $values): array { if (! is_array($values)) { return []; } return array_values(array_filter($values, 'is_string')); } /** * @param array $filters * @return array */ private static function normalizeFilters(array $filters): array { ksort($filters); foreach ($filters as $key => $value) { if (is_array($value)) { $filters[$key] = self::normalizeFilterArray($value); } } return $filters; } /** * @param array $values * @return array */ private static function normalizeFilterArray(array $values): array { foreach ($values as $key => $value) { if (is_array($value)) { $values[$key] = self::normalizeFilterArray($value); } } if (array_is_list($values)) { sort($values); return array_values($values); } ksort($values); return $values; } /** * @param array $filters */ private static function filtersHash(array $filters): string { return json_encode($filters, JSON_THROW_ON_ERROR); } }