TenantAtlas/app/Support/Baselines/BaselineScope.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

247 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Support\Inventory\InventoryPolicyTypeMeta;
/**
* Value object for baseline scope resolution.
*
* A scope defines which policy types are included in a baseline profile.
*
* Spec 116 semantics:
* - Empty policy_types means "all supported policy types" (excluding foundations).
* - Empty foundation_types means "none".
*/
final class BaselineScope
{
/**
* @param array<string> $policyTypes
* @param array<string> $foundationTypes
*/
public function __construct(
public readonly array $policyTypes = [],
public readonly array $foundationTypes = [],
) {}
/**
* Create from the scope_jsonb column value.
*
* @param array<string, mixed>|null $scopeJsonb
*/
public static function fromJsonb(?array $scopeJsonb): self
{
if ($scopeJsonb === null) {
return new self;
}
$policyTypes = $scopeJsonb['policy_types'] ?? [];
$foundationTypes = $scopeJsonb['foundation_types'] ?? [];
$policyTypes = is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [];
$foundationTypes = is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [];
return new self(
policyTypes: $policyTypes === [] ? [] : self::normalizePolicyTypes($policyTypes),
foundationTypes: self::normalizeFoundationTypes($foundationTypes),
);
}
/**
* 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);
$effectivePolicyTypes = $overridePolicyTypes !== []
? array_values(array_intersect($profileScope->policyTypes, $overridePolicyTypes))
: $profileScope->policyTypes;
$effectiveFoundationTypes = $overrideFoundationTypes !== []
? array_values(array_intersect($profileScope->foundationTypes, $overrideFoundationTypes))
: $profileScope->foundationTypes;
return new self(
policyTypes: self::uniqueSorted($effectivePolicyTypes),
foundationTypes: self::uniqueSorted($effectiveFoundationTypes),
);
}
/**
* An empty scope means "no override" (for override_scope semantics).
*/
public function isEmpty(): bool
{
return $this->policyTypes === [] && $this->foundationTypes === [];
}
/**
* Apply Spec 116 defaults and filter to supported types.
*/
public function expandDefaults(): self
{
$policyTypes = $this->policyTypes === []
? self::supportedPolicyTypes()
: self::normalizePolicyTypes($this->policyTypes);
$foundationTypes = self::normalizeFoundationTypes($this->foundationTypes);
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
);
}
/**
* @return list<string>
*/
public function allTypes(): array
{
$expanded = $this->expandDefaults();
return self::uniqueSorted(array_merge(
$expanded->policyTypes,
$expanded->foundationTypes,
));
}
/**
* @return list<string>
*/
public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
{
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
return $guardResult['allowed_types'];
}
/**
* @return array<string, mixed>
*/
public function toJsonb(): array
{
return [
'policy_types' => $this->policyTypes,
'foundation_types' => $this->foundationTypes,
];
}
/**
* Effective scope payload for OperationRun.context.
*
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
*/
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
{
$expanded = $this->expandDefaults();
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
$context = [
'policy_types' => $expanded->policyTypes,
'foundation_types' => $expanded->foundationTypes,
'all_types' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [],
];
if (! is_string($operation) || $operation === '') {
return $context;
}
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($allTypes, $operation);
return array_merge($context, [
'truthful_types' => $guardResult['allowed_types'],
'limited_types' => $guardResult['limited_types'],
'unsupported_types' => $guardResult['unsupported_types'],
'invalid_support_types' => $guardResult['invalid_support_types'],
'capabilities' => $guardResult['capabilities'],
]);
}
/**
* @return list<string>
*/
private static function supportedPolicyTypes(): array
{
$supported = config('tenantpilot.supported_policy_types', []);
if (! is_array($supported)) {
return [];
}
$types = collect($supported)
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
}
/**
* @return list<string>
*/
private static function supportedFoundationTypes(): array
{
$types = collect(InventoryPolicyTypeMeta::baselineSupportedFoundations())
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizePolicyTypes(array $types): array
{
$supported = self::supportedPolicyTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizeFoundationTypes(array $types): array
{
$supported = self::supportedFoundationTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<int, string> $types
* @return list<string>
*/
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;
}
}