feat: implement canonical identity engine #484

Merged
ahmido merged 1 commits from 417-canonical-identity-engine into platform-dev 2026-06-26 06:50:29 +00:00
28 changed files with 2828 additions and 31 deletions

View File

@ -5,6 +5,7 @@
namespace App\Models; namespace App\Models;
use App\Support\TenantConfiguration\ClaimState; use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\EvidenceState; use App\Support\TenantConfiguration\EvidenceState;
use App\Support\TenantConfiguration\IdentityState; use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\SourceClass; use App\Support\TenantConfiguration\SourceClass;
@ -27,9 +28,14 @@ protected function casts(): array
return [ return [
'source_class' => SourceClass::class, 'source_class' => SourceClass::class,
'source_metadata' => 'array', 'source_metadata' => 'array',
'canonical_key_kind' => CanonicalKeyKind::class,
'source_identity' => 'array',
'secondary_identity_keys' => 'array',
'identity_diagnostics' => 'array',
'latest_evidence_state' => EvidenceState::class, 'latest_evidence_state' => EvidenceState::class,
'latest_identity_state' => IdentityState::class, 'latest_identity_state' => IdentityState::class,
'latest_claim_state' => ClaimState::class, 'latest_claim_state' => ClaimState::class,
'identity_evaluated_at' => 'datetime',
'latest_captured_at' => 'datetime', 'latest_captured_at' => 'datetime',
]; ];
} }

View File

@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
use App\Models\TenantConfigurationResourceType;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\SourceClass;
final class CanonicalIdentityResolver
{
public function __construct(
private readonly CoverageIdentityStrategyRegistry $strategies,
private readonly CoverageSecondaryKeyBuilder $secondaryKeys,
private readonly IdentityConflictDiagnosticsBuilder $diagnostics,
) {}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $sourceMetadata
*/
public function resolve(TenantConfigurationResourceType $resourceType, array $payload, array $sourceMetadata = []): CanonicalIdentityResult
{
$strategy = $this->strategies->strategyFor($resourceType);
$canonicalType = (string) $strategy['canonical_type'];
$strategyIdentifier = (string) $strategy['strategy_identifier'];
$secondaryKeys = $this->secondaryKeys->build($strategy, $payload, $sourceMetadata);
if (($strategy['supported'] ?? false) !== true) {
return $this->result(
strategyIdentifier: $strategyIdentifier,
identityState: IdentityState::UnsupportedIdentity,
keyKind: CanonicalKeyKind::Unsupported,
canonicalType: $canonicalType,
identityValues: ['canonical_type' => $canonicalType],
secondaryKeys: $secondaryKeys,
diagnostics: $this->diagnostics->build(
reasonCode: 'unsupported_identity_strategy',
identityState: IdentityState::UnsupportedIdentity,
keyKind: CanonicalKeyKind::Unsupported,
metadata: ['strategy_identifier' => $strategyIdentifier],
),
);
}
$preferred = $this->firstScalarField($strategy['preferred_identity_fields'] ?? [], $payload, $sourceMetadata);
if ($preferred !== null) {
$keyKind = $this->stableKeyKind($resourceType, experimental: (bool) ($strategy['allows_experimental_identity'] ?? false));
$identityState = $keyKind === CanonicalKeyKind::ExperimentalSourceKey
? IdentityState::Derived
: IdentityState::Stable;
return $this->result(
strategyIdentifier: $strategyIdentifier,
identityState: $identityState,
keyKind: $keyKind,
canonicalType: $canonicalType,
identityValues: [
'field' => $preferred['field'],
'value' => $preferred['value'],
],
secondaryKeys: $secondaryKeys,
diagnostics: $this->diagnostics->build(
reasonCode: $identityState === IdentityState::Stable ? 'stable_identity_resolved' : 'experimental_identity_resolved',
identityState: $identityState,
keyKind: $keyKind,
metadata: ['strategy_identifier' => $strategyIdentifier, 'field' => $preferred['field']],
),
derivedClaimsAllowed: false,
);
}
$fallback = $this->firstScalarField($strategy['fallback_identity_fields'] ?? [], $payload, $sourceMetadata);
if ($fallback !== null) {
$keyKind = (bool) ($strategy['allows_experimental_identity'] ?? false)
? CanonicalKeyKind::ExperimentalSourceKey
: CanonicalKeyKind::ProviderExternalId;
$identityState = $keyKind === CanonicalKeyKind::ExperimentalSourceKey
? IdentityState::Derived
: IdentityState::Stable;
return $this->result(
strategyIdentifier: $strategyIdentifier,
identityState: $identityState,
keyKind: $keyKind,
canonicalType: $canonicalType,
identityValues: [
'field' => $fallback['field'],
'value' => $fallback['value'],
],
secondaryKeys: $secondaryKeys,
diagnostics: $this->diagnostics->build(
reasonCode: $identityState === IdentityState::Stable ? 'fallback_identity_resolved' : 'experimental_fallback_identity_resolved',
identityState: $identityState,
keyKind: $keyKind,
metadata: ['strategy_identifier' => $strategyIdentifier, 'field' => $fallback['field']],
),
derivedClaimsAllowed: false,
);
}
$sourceComposite = $this->compositeValues($strategy['source_composite_fields'] ?? [], $payload, $sourceMetadata);
if ($sourceComposite['values'] !== [] && (bool) ($strategy['allows_derived_identity'] ?? false)) {
return $this->result(
strategyIdentifier: $strategyIdentifier,
identityState: IdentityState::Derived,
keyKind: CanonicalKeyKind::SourceComposite,
canonicalType: $canonicalType,
identityValues: $sourceComposite['values'],
secondaryKeys: $secondaryKeys,
diagnostics: $this->diagnostics->build(
reasonCode: 'source_composite_identity_resolved',
identityState: IdentityState::Derived,
keyKind: CanonicalKeyKind::SourceComposite,
metadata: ['strategy_identifier' => $strategyIdentifier, 'fields' => array_keys($sourceComposite['values'])],
),
derivedClaimsAllowed: (bool) ($strategy['derived_claims_allowed'] ?? false),
);
}
$derivedComposite = $this->compositeValues($strategy['derived_composite_fields'] ?? [], $payload, $sourceMetadata);
if ($derivedComposite['values'] !== [] && (bool) ($strategy['allows_derived_identity'] ?? false)) {
return $this->result(
strategyIdentifier: $strategyIdentifier,
identityState: IdentityState::Derived,
keyKind: CanonicalKeyKind::DerivedComposite,
canonicalType: $canonicalType,
identityValues: $derivedComposite['values'],
secondaryKeys: $secondaryKeys,
diagnostics: $this->diagnostics->build(
reasonCode: 'derived_composite_identity_resolved',
identityState: IdentityState::Derived,
keyKind: CanonicalKeyKind::DerivedComposite,
metadata: ['strategy_identifier' => $strategyIdentifier, 'fields' => array_keys($derivedComposite['values'])],
),
derivedClaimsAllowed: (bool) ($strategy['derived_claims_allowed'] ?? false),
);
}
$missingFields = array_values(array_unique([
...$this->list($strategy['preferred_identity_fields'] ?? []),
...$sourceComposite['missing'],
...$derivedComposite['missing'],
]));
return $this->result(
strategyIdentifier: $strategyIdentifier,
identityState: IdentityState::MissingExternalId,
keyKind: CanonicalKeyKind::Unsupported,
canonicalType: $canonicalType,
identityValues: [
'missing_external_id' => $canonicalType,
'secondary_fingerprint' => hash('sha256', json_encode($secondaryKeys, JSON_THROW_ON_ERROR)),
],
secondaryKeys: $secondaryKeys,
diagnostics: $this->diagnostics->build(
reasonCode: 'missing_external_id',
identityState: IdentityState::MissingExternalId,
keyKind: CanonicalKeyKind::Unsupported,
missingFields: $missingFields,
metadata: ['strategy_identifier' => $strategyIdentifier],
),
);
}
/**
* @param array<string, mixed> $identityValues
* @param array<string, mixed> $secondaryKeys
* @param array<string, mixed> $diagnostics
*/
private function result(
string $strategyIdentifier,
IdentityState $identityState,
CanonicalKeyKind $keyKind,
string $canonicalType,
array $identityValues,
array $secondaryKeys,
array $diagnostics,
bool $derivedClaimsAllowed = false,
): CanonicalIdentityResult {
$candidateKeyHash = hash('sha256', json_encode([
'canonical_type' => $canonicalType,
'key_kind' => $keyKind->value,
'identity' => $identityValues,
], JSON_THROW_ON_ERROR));
$fingerprint = hash('sha256', json_encode([
'canonical_type' => $canonicalType,
'key_kind' => $keyKind->value,
'identity' => $identityValues,
'secondary' => $secondaryKeys,
], JSON_THROW_ON_ERROR));
$canonicalResourceKey = sprintf('%s:%s:%s', $canonicalType, $keyKind->value, $candidateKeyHash);
return new CanonicalIdentityResult(
strategyIdentifier: $strategyIdentifier,
identityState: $identityState,
keyKind: $keyKind,
canonicalResourceKey: $canonicalResourceKey,
sourceResourceId: $this->sourceResourceId($identityState, $identityValues, $fingerprint),
sourceIdentity: [
'strategy_identifier' => $strategyIdentifier,
'key_kind' => $keyKind->value,
'candidate_key_hash' => $candidateKeyHash,
'fingerprint' => $fingerprint,
'values' => $identityValues,
],
secondaryKeys: $secondaryKeys,
diagnostics: $diagnostics,
derivedClaimsAllowed: $derivedClaimsAllowed,
);
}
private function sourceResourceId(IdentityState $identityState, array $identityValues, string $fingerprint): string
{
$value = $identityValues['value'] ?? null;
if (is_scalar($value) && trim((string) $value) !== '') {
return mb_substr(trim((string) $value), 0, 240);
}
return match ($identityState) {
IdentityState::MissingExternalId => 'missing:'.$fingerprint,
IdentityState::UnsupportedIdentity => 'unsupported:'.$fingerprint,
default => 'derived:'.$fingerprint,
};
}
private function stableKeyKind(TenantConfigurationResourceType $resourceType, bool $experimental): CanonicalKeyKind
{
if ($experimental) {
return CanonicalKeyKind::ExperimentalSourceKey;
}
$sourceClass = $resourceType->source_class;
if ($sourceClass instanceof SourceClass && $sourceClass === SourceClass::Tcm) {
return CanonicalKeyKind::TcmResourceIdentifier;
}
if ($sourceClass instanceof SourceClass && $sourceClass->isGraphFallback()) {
return CanonicalKeyKind::GraphObjectId;
}
return CanonicalKeyKind::ProviderExternalId;
}
/**
* @param mixed $fields
* @param array<string, mixed> $payload
* @param array<string, mixed> $sourceMetadata
* @return array{field: string, value: string}|null
*/
private function firstScalarField(mixed $fields, array $payload, array $sourceMetadata): ?array
{
foreach ($this->list($fields) as $field) {
$value = $this->fieldValue($field, $payload, $sourceMetadata);
if (! is_scalar($value)) {
continue;
}
$value = trim((string) $value);
if ($value === '') {
continue;
}
return ['field' => $field, 'value' => $value];
}
return null;
}
/**
* @param mixed $fields
* @param array<string, mixed> $payload
* @param array<string, mixed> $sourceMetadata
* @return array{values: array<string, mixed>, missing: list<string>}
*/
private function compositeValues(mixed $fields, array $payload, array $sourceMetadata): array
{
$values = [];
$missing = [];
foreach ($this->list($fields) as $field) {
$value = $this->fieldValue($field, $payload, $sourceMetadata);
if ($value === null || $value === '' || (is_array($value) && $value === [])) {
$missing[] = $field;
continue;
}
$values[$field] = $value;
}
if ($missing !== []) {
return ['values' => [], 'missing' => $missing];
}
return ['values' => $values, 'missing' => []];
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $sourceMetadata
*/
private function fieldValue(string $field, array $payload, array $sourceMetadata): mixed
{
if (str_starts_with($field, 'source_metadata.')) {
return data_get($sourceMetadata, substr($field, 16));
}
if (str_starts_with($field, 'payload.')) {
return data_get($payload, substr($field, 8));
}
return data_get($payload, $field);
}
/**
* @return list<string>
*/
private function list(mixed $fields): array
{
if (! is_array($fields)) {
return [];
}
return array_values(array_filter(
array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $fields),
static fn (string $field): bool => $field !== '',
));
}
}

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\IdentityState;
final class CanonicalIdentityResult
{
/**
* @param array<string, mixed> $sourceIdentity
* @param array<string, mixed> $secondaryKeys
* @param array<string, mixed> $diagnostics
*/
public function __construct(
public readonly string $strategyIdentifier,
public readonly IdentityState $identityState,
public readonly CanonicalKeyKind $keyKind,
public readonly string $canonicalResourceKey,
public readonly string $sourceResourceId,
public readonly array $sourceIdentity,
public readonly array $secondaryKeys,
public readonly array $diagnostics,
public readonly bool $derivedClaimsAllowed = false,
) {}
public function fingerprint(): string
{
$fingerprint = $this->sourceIdentity['fingerprint'] ?? null;
return is_string($fingerprint) && $fingerprint !== ''
? $fingerprint
: hash('sha256', json_encode($this->sourceIdentity, JSON_THROW_ON_ERROR));
}
public function candidateKeyHash(): string
{
$candidateKeyHash = $this->sourceIdentity['candidate_key_hash'] ?? null;
return is_string($candidateKeyHash) && $candidateKeyHash !== ''
? $candidateKeyHash
: $this->fingerprint();
}
public function withCanonicalResourceKey(string $canonicalResourceKey): self
{
return new self(
strategyIdentifier: $this->strategyIdentifier,
identityState: $this->identityState,
keyKind: $this->keyKind,
canonicalResourceKey: $canonicalResourceKey,
sourceResourceId: $this->sourceResourceId,
sourceIdentity: $this->sourceIdentity,
secondaryKeys: $this->secondaryKeys,
diagnostics: $this->diagnostics,
derivedClaimsAllowed: $this->derivedClaimsAllowed,
);
}
/**
* @param array<string, mixed> $diagnostics
*/
public function asConflict(string $canonicalResourceKey, array $diagnostics): self
{
return new self(
strategyIdentifier: $this->strategyIdentifier,
identityState: IdentityState::IdentityConflict,
keyKind: $this->keyKind,
canonicalResourceKey: $canonicalResourceKey,
sourceResourceId: $this->sourceResourceId,
sourceIdentity: $this->sourceIdentity,
secondaryKeys: $this->secondaryKeys,
diagnostics: array_replace_recursive($this->diagnostics, $diagnostics),
derivedClaimsAllowed: false,
);
}
}

View File

@ -6,6 +6,7 @@
use App\Support\TenantConfiguration\ClaimState; use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel; use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\RestoreTier; use App\Support\TenantConfiguration\RestoreTier;
use App\Support\TenantConfiguration\SourceClass; use App\Support\TenantConfiguration\SourceClass;
@ -22,19 +23,34 @@ public function evaluate(
?int $percentage = null, ?int $percentage = null,
SourceClass|string|null $sourceClass = null, SourceClass|string|null $sourceClass = null,
RestoreTier|string|null $restoreTier = null, RestoreTier|string|null $restoreTier = null,
IdentityState|string|null $identityState = null,
bool $restoreClaim = false, bool $restoreClaim = false,
bool $allowsBetaClaims = false, bool $allowsBetaClaims = false,
bool $allowsCertifiedClaims = false, bool $allowsCertifiedClaims = false,
bool $allowsDerivedIdentityClaims = false,
): ClaimState { ): ClaimState {
$requested = $this->coverageLevel($requestedLevel); $requested = $this->coverageLevel($requestedLevel);
$actual = $this->coverageLevel($actualLevel); $actual = $this->coverageLevel($actualLevel);
$source = $this->sourceClass($sourceClass); $source = $this->sourceClass($sourceClass);
$restore = $this->restoreTier($restoreTier); $restore = $this->restoreTier($restoreTier);
$identity = $this->identityState($identityState);
if (($scopeKey === null || $unscoped) && $percentage === 100) { if (($scopeKey === null || $unscoped) && $percentage === 100) {
return ClaimState::ClaimBlocked; return ClaimState::ClaimBlocked;
} }
if (in_array($identity, [
IdentityState::IdentityConflict,
IdentityState::MissingExternalId,
IdentityState::UnsupportedIdentity,
], true)) {
return ClaimState::ClaimBlocked;
}
if ($identity === IdentityState::Derived && ! $allowsDerivedIdentityClaims) {
return $customerFacing ? ClaimState::ClaimBlocked : ClaimState::ClaimLimited;
}
if ($source?->isBetaExperimental() === true) { if ($source?->isBetaExperimental() === true) {
if (! $allowsBetaClaims) { if (! $allowsBetaClaims) {
return ClaimState::ClaimBlocked; return ClaimState::ClaimBlocked;
@ -82,4 +98,13 @@ private function restoreTier(RestoreTier|string|null $restoreTier): ?RestoreTier
return RestoreTier::from($restoreTier); return RestoreTier::from($restoreTier);
} }
private function identityState(IdentityState|string|null $identityState): ?IdentityState
{
if ($identityState === null || $identityState instanceof IdentityState) {
return $identityState;
}
return IdentityState::from($identityState);
}
} }

View File

@ -12,6 +12,7 @@
use App\Support\TenantConfiguration\CaptureOutcome; use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\CoverageLevel; use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\EvidenceState; use App\Support\TenantConfiguration\EvidenceState;
use BackedEnum;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
@ -68,7 +69,7 @@ public function append(
'raw_payload' => $rawPayload, 'raw_payload' => $rawPayload,
'normalized_payload' => $normalizedPayload, 'normalized_payload' => $normalizedPayload,
'payload_hash' => $payloadHash, 'payload_hash' => $payloadHash,
'permission_context' => $permissionContext, 'permission_context' => $permissionContext === [] ? (object) [] : $permissionContext,
'evidence_state' => EvidenceState::ContentBacked->value, 'evidence_state' => EvidenceState::ContentBacked->value,
'coverage_level' => CoverageLevel::ContentBacked->value, 'coverage_level' => CoverageLevel::ContentBacked->value,
'capture_outcome' => CaptureOutcome::Captured->value, 'capture_outcome' => CaptureOutcome::Captured->value,
@ -78,8 +79,8 @@ public function append(
$resource->forceFill([ $resource->forceFill([
'latest_evidence_id' => (int) $evidence->getKey(), 'latest_evidence_id' => (int) $evidence->getKey(),
'latest_evidence_state' => EvidenceState::ContentBacked->value, 'latest_evidence_state' => EvidenceState::ContentBacked->value,
'latest_identity_state' => $resourceType->default_identity_state, 'latest_identity_state' => $this->stringValue($resource->latest_identity_state),
'latest_claim_state' => $resourceType->default_claim_state, 'latest_claim_state' => $this->stringValue($resource->latest_claim_state),
'latest_payload_hash' => $payloadHash, 'latest_payload_hash' => $payloadHash,
'latest_captured_at' => $capturedAt, 'latest_captured_at' => $capturedAt,
])->save(); ])->save();
@ -110,4 +111,13 @@ private function assertScoped(
throw new InvalidArgumentException('Operation run scope mismatch while appending tenant configuration evidence.'); throw new InvalidArgumentException('Operation run scope mismatch while appending tenant configuration evidence.');
} }
} }
private function stringValue(mixed $value): string
{
if ($value instanceof BackedEnum) {
return (string) $value->value;
}
return (string) $value;
}
} }

View File

@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
use App\Models\TenantConfigurationResourceType;
use App\Support\TenantConfiguration\SourceClass;
final class CoverageIdentityStrategyRegistry
{
/**
* @var array<string, array<string, mixed>>
*/
private const STRATEGIES = [
'deviceAndAppManagementAssignmentFilter' => [
'strategy_identifier' => 'tcm.assignment_filter.v1',
'preferred_identity_fields' => ['id', 'sourceId', 'assignmentFilterId'],
'fallback_identity_fields' => ['templateReference.templateId', 'sourceKey'],
'source_composite_fields' => ['platform', 'assignmentFilterManagementType', 'rule'],
'derived_composite_fields' => ['platform', 'payloadType', 'source_metadata.source_contract_key'],
'display_fields' => ['displayName', 'name'],
'secondary_fields' => ['platform', 'assignmentFilterManagementType', 'source_metadata.source_contract_key', 'source_metadata.source_version'],
'requires_provider_connection_scope' => true,
'allows_derived_identity' => true,
'allows_experimental_identity' => false,
'derived_claims_allowed' => false,
],
'deviceEnrollmentLimitRestriction' => [
'strategy_identifier' => 'tcm.device_enrollment_limit_restriction.v1',
'preferred_identity_fields' => ['id', 'sourceId', 'restrictionId'],
'fallback_identity_fields' => ['settingId', 'sourceKey'],
'source_composite_fields' => ['platform', 'limit', 'priority'],
'derived_composite_fields' => ['platform', 'limit', 'source_metadata.source_contract_key'],
'display_fields' => ['displayName', 'name'],
'secondary_fields' => ['platform', 'limit', 'priority', 'source_metadata.source_contract_key'],
'requires_provider_connection_scope' => true,
'allows_derived_identity' => true,
'allows_experimental_identity' => false,
'derived_claims_allowed' => false,
],
'deviceEnrollmentPlatformRestriction' => [
'strategy_identifier' => 'tcm.device_enrollment_platform_restriction.v1',
'preferred_identity_fields' => ['id', 'sourceId', 'restrictionId'],
'fallback_identity_fields' => ['platformRestrictionId', 'sourceKey'],
'source_composite_fields' => ['platform', 'platformType', 'restrictionType'],
'derived_composite_fields' => ['platform', 'platformType', 'source_metadata.source_contract_key'],
'display_fields' => ['displayName', 'name'],
'secondary_fields' => ['platform', 'platformType', 'restrictionType', 'source_metadata.source_contract_key'],
'requires_provider_connection_scope' => true,
'allows_derived_identity' => true,
'allows_experimental_identity' => false,
'derived_claims_allowed' => false,
],
'deviceEnrollmentStatusPageWindows10' => [
'strategy_identifier' => 'tcm.device_enrollment_status_page_windows10.v1',
'preferred_identity_fields' => ['id', 'sourceId', 'statusPageId'],
'fallback_identity_fields' => ['enrollmentStatusPageId', 'sourceKey'],
'source_composite_fields' => ['platform', 'installProgressTimeoutInMinutes', 'priority'],
'derived_composite_fields' => ['platform', 'showInstallationProgress', 'source_metadata.source_contract_key'],
'display_fields' => ['displayName', 'name'],
'secondary_fields' => ['platform', 'showInstallationProgress', 'installProgressTimeoutInMinutes', 'source_metadata.source_contract_key'],
'requires_provider_connection_scope' => true,
'allows_derived_identity' => true,
'allows_experimental_identity' => false,
'derived_claims_allowed' => false,
],
'appProtectionPolicyAndroid' => [
'strategy_identifier' => 'tcm.app_protection_policy_android.v1',
'preferred_identity_fields' => ['id', 'sourceId', 'policyId'],
'fallback_identity_fields' => ['appProtectionPolicyId', 'sourceKey'],
'source_composite_fields' => ['platform', 'targetedAppManagementLevels', 'roleScopeTagIds'],
'derived_composite_fields' => ['platform', 'targetedAppManagementLevels', 'source_metadata.source_contract_key'],
'display_fields' => ['displayName', 'name'],
'secondary_fields' => ['platform', 'targetedAppManagementLevels', 'roleScopeTagIds', 'source_metadata.source_contract_key'],
'requires_provider_connection_scope' => true,
'allows_derived_identity' => true,
'allows_experimental_identity' => false,
'derived_claims_allowed' => false,
],
'appProtectionPolicyiOS' => [
'strategy_identifier' => 'tcm.app_protection_policy_ios.v1',
'preferred_identity_fields' => ['id', 'sourceId', 'policyId'],
'fallback_identity_fields' => ['appProtectionPolicyId', 'sourceKey'],
'source_composite_fields' => ['platform', 'targetedAppManagementLevels', 'roleScopeTagIds'],
'derived_composite_fields' => ['platform', 'targetedAppManagementLevels', 'source_metadata.source_contract_key'],
'display_fields' => ['displayName', 'name'],
'secondary_fields' => ['platform', 'targetedAppManagementLevels', 'roleScopeTagIds', 'source_metadata.source_contract_key'],
'requires_provider_connection_scope' => true,
'allows_derived_identity' => true,
'allows_experimental_identity' => false,
'derived_claims_allowed' => false,
],
'notificationMessageTemplate' => [
'strategy_identifier' => 'graph.notification_message_template.v1',
'preferred_identity_fields' => ['id', 'templateId', 'sourceId'],
'fallback_identity_fields' => ['notificationMessageTemplateId', 'sourceKey'],
'source_composite_fields' => ['brandingOptions', 'source_metadata.source_contract_key'],
'derived_composite_fields' => ['source_metadata.source_contract_key', 'source_metadata.source_version'],
'display_fields' => ['displayName', 'name'],
'secondary_fields' => ['brandingOptions', 'source_metadata.source_contract_key', 'source_metadata.source_version'],
'requires_provider_connection_scope' => true,
'allows_derived_identity' => true,
'allows_experimental_identity' => false,
'derived_claims_allowed' => false,
],
'roleScopeTag' => [
'strategy_identifier' => 'graph_beta.role_scope_tag.v1',
'preferred_identity_fields' => ['id', 'roleScopeTagId', 'sourceId'],
'fallback_identity_fields' => ['sourceKey'],
'source_composite_fields' => ['source_metadata.source_contract_key', 'source_metadata.source_version'],
'derived_composite_fields' => ['source_metadata.source_contract_key', 'source_metadata.source_version'],
'display_fields' => ['displayName', 'name'],
'secondary_fields' => ['description', 'source_metadata.source_contract_key', 'source_metadata.source_version'],
'requires_provider_connection_scope' => true,
'allows_derived_identity' => true,
'allows_experimental_identity' => true,
'derived_claims_allowed' => false,
],
];
/**
* @return array<string, array<string, mixed>>
*/
public function strategies(): array
{
return self::STRATEGIES;
}
/**
* @return array<string, mixed>
*/
public function strategyFor(TenantConfigurationResourceType|string $resourceType): array
{
$canonicalType = $resourceType instanceof TenantConfigurationResourceType
? (string) $resourceType->canonical_type
: $resourceType;
$sourceClass = $resourceType instanceof TenantConfigurationResourceType
? $this->sourceClassValue($resourceType->source_class)
: null;
$strategy = self::STRATEGIES[$canonicalType] ?? null;
if ($strategy === null) {
return [
'strategy_identifier' => 'unsupported.'.$canonicalType,
'canonical_type' => $canonicalType,
'source_class' => $sourceClass,
'preferred_identity_fields' => [],
'fallback_identity_fields' => [],
'source_composite_fields' => [],
'derived_composite_fields' => [],
'display_fields' => ['displayName', 'name'],
'secondary_fields' => [],
'requires_provider_connection_scope' => true,
'allows_derived_identity' => false,
'allows_experimental_identity' => false,
'derived_claims_allowed' => false,
'supported' => false,
];
}
return [
...$strategy,
'canonical_type' => $canonicalType,
'source_class' => $sourceClass,
'supported' => true,
];
}
private function sourceClassValue(mixed $sourceClass): ?string
{
if ($sourceClass instanceof SourceClass) {
return $sourceClass->value;
}
if (is_scalar($sourceClass)) {
$sourceClass = trim((string) $sourceClass);
return $sourceClass !== '' ? $sourceClass : null;
}
return null;
}
}

View File

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceType;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\IdentityState;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
final class CoverageResourceIdentityEvaluator
{
public function __construct(
private readonly IdentityConflictDiagnosticsBuilder $diagnostics,
) {}
public function evaluate(
CanonicalIdentityResult $result,
ManagedEnvironment $tenant,
ProviderConnection $providerConnection,
TenantConfigurationResourceType $resourceType,
): CanonicalIdentityResult {
if (! $this->requiresUnsafeCollisionEvaluation($result)) {
return $result;
}
$candidates = $this->sameCandidateRows($result, $tenant, $providerConnection, $resourceType);
if ($candidates->isEmpty()) {
return $result;
}
$matching = $candidates->first(
fn (TenantConfigurationResource $resource): bool => data_get($resource->source_identity, 'fingerprint') === $result->fingerprint(),
);
if ($result->identityState === IdentityState::Derived
&& $matching instanceof TenantConfigurationResource
&& $candidates->count() === 1
) {
return $result->withCanonicalResourceKey((string) $matching->canonical_resource_key);
}
$candidateCount = $candidates->count() + $this->incomingCandidateCount($result, $matching);
$existingIds = $candidates
->pluck('id')
->map(static fn (mixed $id): int => (int) $id)
->values()
->all();
$conflictDiagnostics = $this->diagnostics->build(
reasonCode: $this->collisionReasonCode($result),
identityState: IdentityState::IdentityConflict,
keyKind: $result->keyKind,
metadata: [
'candidate_key_hash' => $result->candidateKeyHash(),
'candidate_count' => $candidateCount,
'conflicting_resource_ids' => $existingIds,
],
);
$candidates->each(function (TenantConfigurationResource $resource) use ($conflictDiagnostics): void {
$resource->forceFill([
'latest_identity_state' => IdentityState::IdentityConflict->value,
'latest_claim_state' => ClaimState::ClaimBlocked->value,
'identity_diagnostics' => array_replace_recursive(
is_array($resource->identity_diagnostics) ? $resource->identity_diagnostics : [],
$conflictDiagnostics,
),
'identity_evaluated_at' => now(),
])->save();
});
$canonicalKey = $result->identityState === IdentityState::Derived && $matching instanceof TenantConfigurationResource
? (string) $matching->canonical_resource_key
: $this->newConflictResourceKey($result);
return $result->asConflict($canonicalKey, $conflictDiagnostics);
}
private function requiresUnsafeCollisionEvaluation(CanonicalIdentityResult $result): bool
{
return in_array($result->identityState, [
IdentityState::Derived,
IdentityState::MissingExternalId,
IdentityState::UnsupportedIdentity,
], true);
}
private function incomingCandidateCount(
CanonicalIdentityResult $result,
?TenantConfigurationResource $matching,
): int {
if ($result->identityState !== IdentityState::Derived) {
return 1;
}
return $matching instanceof TenantConfigurationResource ? 0 : 1;
}
private function collisionReasonCode(CanonicalIdentityResult $result): string
{
return match ($result->identityState) {
IdentityState::MissingExternalId => 'same_scope_missing_identity_collision',
IdentityState::UnsupportedIdentity => 'same_scope_unsupported_identity_collision',
default => 'same_scope_derived_identity_collision',
};
}
private function newConflictResourceKey(CanonicalIdentityResult $result): string
{
return sprintf(
'%s:conflict:%s:%s',
$result->canonicalResourceKey,
substr($result->fingerprint(), 0, 16),
Str::ulid()->toBase32(),
);
}
/**
* @return Collection<int, TenantConfigurationResource>
*/
private function sameCandidateRows(
CanonicalIdentityResult $result,
ManagedEnvironment $tenant,
ProviderConnection $providerConnection,
TenantConfigurationResourceType $resourceType,
): Collection {
return TenantConfigurationResource::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('managed_environment_id', (int) $tenant->getKey())
->where('provider_connection_id', (int) $providerConnection->getKey())
->where('resource_type_id', (int) $resourceType->getKey())
->where(function ($query) use ($result): void {
$query
->where('canonical_resource_key', $result->canonicalResourceKey)
->orWhere('canonical_resource_key', 'like', $result->canonicalResourceKey.':conflict:%');
})
->get();
}
}

View File

@ -10,12 +10,17 @@
use App\Models\TenantConfigurationResourceType; use App\Models\TenantConfigurationResourceType;
use App\Support\TenantConfiguration\ClaimState; use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\EvidenceState; use App\Support\TenantConfiguration\EvidenceState;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\SourceClass; use App\Support\TenantConfiguration\SourceClass;
use InvalidArgumentException; use InvalidArgumentException;
final class CoverageResourceUpserter final class CoverageResourceUpserter
{ {
public function __construct(
private readonly CanonicalIdentityResolver $identityResolver,
private readonly CoverageResourceIdentityEvaluator $identityEvaluator,
private readonly ClaimGuard $claimGuard,
) {}
/** /**
* @param array<string, mixed> $payload * @param array<string, mixed> $payload
* @param array<string, mixed> $sourceMetadata * @param array<string, mixed> $sourceMetadata
@ -29,34 +34,45 @@ public function upsert(
): TenantConfigurationResource { ): TenantConfigurationResource {
$this->assertScoped($tenant, $providerConnection); $this->assertScoped($tenant, $providerConnection);
$sourceResourceId = $this->extractSourceResourceId($payload); $identity = $this->identityEvaluator->evaluate(
result: $this->identityResolver->resolve($resourceType, $payload, $sourceMetadata),
tenant: $tenant,
providerConnection: $providerConnection,
resourceType: $resourceType,
);
$canonicalType = (string) $resourceType->canonical_type; $canonicalType = (string) $resourceType->canonical_type;
$canonicalResourceKey = sprintf('%s:%s', $canonicalType, $sourceResourceId);
$sourceClass = $resourceType->source_class instanceof SourceClass $sourceClass = $resourceType->source_class instanceof SourceClass
? $resourceType->source_class->value ? $resourceType->source_class->value
: (string) $resourceType->source_class; : (string) $resourceType->source_class;
$claimState = $this->claimStateFor($resourceType, $identity);
$resource = TenantConfigurationResource::query()->firstOrNew([ $resource = TenantConfigurationResource::query()->firstOrNew([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(), 'managed_environment_id' => (int) $tenant->getKey(),
'provider_connection_id' => (int) $providerConnection->getKey(), 'provider_connection_id' => (int) $providerConnection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(), 'resource_type_id' => (int) $resourceType->getKey(),
'canonical_resource_key' => $canonicalResourceKey, 'canonical_resource_key' => $identity->canonicalResourceKey,
]); ]);
$resource->fill([ $resource->fill([
'source_class' => $sourceClass, 'source_class' => $sourceClass,
'canonical_type' => $canonicalType, 'canonical_type' => $canonicalType,
'source_resource_id' => $sourceResourceId, 'canonical_key_kind' => $identity->keyKind->value,
'source_resource_id' => $identity->sourceResourceId,
'source_display_name' => $this->extractDisplayName($payload), 'source_display_name' => $this->extractDisplayName($payload),
'source_metadata' => $sourceMetadata, 'source_metadata' => $this->jsonObject($sourceMetadata),
'identity_strategy' => $identity->strategyIdentifier,
'source_identity' => $identity->sourceIdentity,
'secondary_identity_keys' => $this->jsonObject($identity->secondaryKeys),
'identity_diagnostics' => $this->jsonObject($identity->diagnostics),
'identity_evaluated_at' => now(),
'latest_identity_state' => $identity->identityState->value,
'latest_claim_state' => $claimState->value,
]); ]);
if (! $resource->exists) { if (! $resource->exists) {
$resource->forceFill([ $resource->forceFill([
'latest_evidence_state' => EvidenceState::NotCaptured->value, 'latest_evidence_state' => EvidenceState::NotCaptured->value,
'latest_identity_state' => IdentityState::Stable->value,
'latest_claim_state' => ClaimState::InternalOnly->value,
]); ]);
} }
@ -65,6 +81,51 @@ public function upsert(
return $resource; return $resource;
} }
private function claimStateFor(
TenantConfigurationResourceType $resourceType,
CanonicalIdentityResult $identity,
): ClaimState {
$guarded = $this->claimGuard->evaluate(
scopeKey: 'intune_tcm_core',
requestedLevel: $resourceType->default_coverage_level,
actualLevel: $resourceType->default_coverage_level,
scopeComplete: true,
sourceClass: $resourceType->source_class,
restoreTier: $resourceType->restore_tier,
identityState: $identity->identityState,
allowsBetaClaims: (bool) $resourceType->allows_beta_claims,
allowsCertifiedClaims: (bool) $resourceType->allows_certified_claims,
allowsDerivedIdentityClaims: $identity->derivedClaimsAllowed,
);
$default = $resourceType->default_claim_state;
$default = $default instanceof ClaimState ? $default : ClaimState::from((string) $default);
return $this->mostRestrictiveClaimState($guarded, $default);
}
private function mostRestrictiveClaimState(ClaimState $guarded, ClaimState $default): ClaimState
{
return $this->claimStateRank($guarded) >= $this->claimStateRank($default)
? $guarded
: $default;
}
private function claimStateRank(ClaimState $claimState): int
{
return match ($claimState) {
ClaimState::ClaimAllowed => 0,
ClaimState::ClaimLimited => 10,
ClaimState::InternalOnly => 20,
ClaimState::ClaimBlocked => 30,
};
}
private function jsonObject(array $value): array|object
{
return $value === [] ? (object) [] : $value;
}
private function assertScoped(ManagedEnvironment $tenant, ProviderConnection $providerConnection): void private function assertScoped(ManagedEnvironment $tenant, ProviderConnection $providerConnection): void
{ {
if ((int) $providerConnection->managed_environment_id !== (int) $tenant->getKey()) { if ((int) $providerConnection->managed_environment_id !== (int) $tenant->getKey()) {
@ -76,26 +137,6 @@ private function assertScoped(ManagedEnvironment $tenant, ProviderConnection $pr
} }
} }
/**
* @param array<string, mixed> $payload
*/
private function extractSourceResourceId(array $payload): string
{
$id = $payload['id'] ?? $payload['sourceId'] ?? null;
if (! is_scalar($id)) {
throw new InvalidArgumentException('Captured resource payload must include a stable source id.');
}
$id = trim((string) $id);
if ($id === '') {
throw new InvalidArgumentException('Captured resource payload must include a non-empty source id.');
}
return $id;
}
/** /**
* @param array<string, mixed> $payload * @param array<string, mixed> $payload
*/ */

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class CoverageSecondaryKeyBuilder
{
public function __construct(
private readonly CoveragePayloadRedactor $redactor,
) {}
/**
* @param array<string, mixed> $strategy
* @param array<string, mixed> $payload
* @param array<string, mixed> $sourceMetadata
* @return array<string, mixed>
*/
public function build(array $strategy, array $payload, array $sourceMetadata = []): array
{
$fields = collect([
...$this->list($strategy['display_fields'] ?? []),
...$this->list($strategy['secondary_fields'] ?? []),
])->unique()->values()->all();
$keys = [];
foreach ($fields as $field) {
$value = $this->value($field, $payload, $sourceMetadata);
if ($value === null || $value === '') {
continue;
}
$redacted = $this->redactor->redact([$field => $value]);
$keys[$field] = $this->bounded(is_array($redacted) ? ($redacted[$field] ?? null) : $redacted);
}
return $keys;
}
/**
* @return list<string>
*/
private function list(mixed $fields): array
{
if (! is_array($fields)) {
return [];
}
return array_values(array_filter(
array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $fields),
static fn (string $field): bool => $field !== '',
));
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $sourceMetadata
*/
private function value(string $field, array $payload, array $sourceMetadata): mixed
{
if (str_starts_with($field, 'source_metadata.')) {
return data_get($sourceMetadata, substr($field, 16));
}
if (str_starts_with($field, 'payload.')) {
return data_get($payload, substr($field, 8));
}
return data_get($payload, $field);
}
private function bounded(mixed $value): mixed
{
if (is_string($value)) {
return mb_substr($value, 0, 240);
}
if (is_array($value)) {
return collect($value)
->take(12)
->map(fn (mixed $nested): mixed => $this->bounded($nested))
->all();
}
if (is_scalar($value) || $value === null) {
return $value;
}
return (string) $value;
}
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\IdentityState;
final class IdentityConflictDiagnosticsBuilder
{
public function __construct(
private readonly CoveragePayloadRedactor $redactor,
) {}
/**
* @param list<string> $missingFields
* @param array<string, mixed> $metadata
* @return array<string, mixed>
*/
public function build(
string $reasonCode,
IdentityState $identityState,
CanonicalKeyKind $keyKind,
array $missingFields = [],
array $metadata = [],
): array {
$diagnostics = [
'reason_code' => $reasonCode,
'identity_state' => $identityState->value,
'key_kind' => $keyKind->value,
];
if ($missingFields !== []) {
$diagnostics['missing_fields'] = array_values(array_unique($missingFields));
}
foreach ($metadata as $key => $value) {
$key = (string) $key;
$redacted = $this->redactor->redact([$key => $value]);
$diagnostics[$key] = $this->bounded(is_array($redacted) ? ($redacted[$key] ?? null) : $redacted);
}
return $diagnostics;
}
private function bounded(mixed $value): mixed
{
if (is_string($value)) {
return mb_substr($value, 0, 240);
}
if (is_array($value)) {
return collect($value)
->take(16)
->map(fn (mixed $nested): mixed => $this->bounded($nested))
->all();
}
if (is_scalar($value) || $value === null) {
return $value;
}
return (string) $value;
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\TenantConfiguration;
enum CanonicalKeyKind: string
{
case ProviderExternalId = 'provider_external_id';
case GraphObjectId = 'graph_object_id';
case TcmResourceIdentifier = 'tcm_resource_identifier';
case SourceComposite = 'source_composite';
case DerivedComposite = 'derived_composite';
case ExperimentalSourceKey = 'experimental_source_key';
case Unsupported = 'unsupported';
/**
* @return list<string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -10,6 +10,7 @@
use App\Models\TenantConfigurationResourceType; use App\Models\TenantConfigurationResourceType;
use App\Models\Workspace; use App\Models\Workspace;
use App\Support\TenantConfiguration\ClaimState; use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\EvidenceState; use App\Support\TenantConfiguration\EvidenceState;
use App\Support\TenantConfiguration\IdentityState; use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\SourceClass; use App\Support\TenantConfiguration\SourceClass;
@ -53,9 +54,15 @@ public function definition(): array
(string) ($attributes['canonical_type'] ?? 'resource'), (string) ($attributes['canonical_type'] ?? 'resource'),
fake()->uuid(), fake()->uuid(),
), ),
'canonical_key_kind' => CanonicalKeyKind::ProviderExternalId->value,
'source_resource_id' => fake()->uuid(), 'source_resource_id' => fake()->uuid(),
'source_display_name' => fake()->words(3, true), 'source_display_name' => fake()->words(3, true),
'source_metadata' => ['factory' => 'tenant_configuration_resource'], 'source_metadata' => ['factory' => 'tenant_configuration_resource'],
'identity_strategy' => 'factory.identity.v1',
'source_identity' => ['factory' => 'tenant_configuration_resource'],
'secondary_identity_keys' => (object) [],
'identity_diagnostics' => (object) [],
'identity_evaluated_at' => null,
'latest_evidence_state' => EvidenceState::NotCaptured->value, 'latest_evidence_state' => EvidenceState::NotCaptured->value,
'latest_identity_state' => IdentityState::Stable->value, 'latest_identity_state' => IdentityState::Stable->value,
'latest_claim_state' => ClaimState::InternalOnly->value, 'latest_claim_state' => ClaimState::InternalOnly->value,
@ -64,6 +71,48 @@ public function definition(): array
]; ];
} }
public function derivedIdentity(): self
{
return $this->state(fn (array $attributes): array => [
'canonical_key_kind' => CanonicalKeyKind::DerivedComposite->value,
'latest_identity_state' => IdentityState::Derived->value,
'latest_claim_state' => ClaimState::ClaimLimited->value,
'source_identity' => [
'factory' => 'tenant_configuration_resource',
'key_kind' => CanonicalKeyKind::DerivedComposite->value,
],
]);
}
public function identityConflict(): self
{
return $this->state(fn (array $attributes): array => [
'latest_identity_state' => IdentityState::IdentityConflict->value,
'latest_claim_state' => ClaimState::ClaimBlocked->value,
'identity_diagnostics' => ['reason_code' => 'same_scope_derived_identity_collision'],
]);
}
public function missingExternalId(): self
{
return $this->state(fn (array $attributes): array => [
'canonical_key_kind' => CanonicalKeyKind::Unsupported->value,
'latest_identity_state' => IdentityState::MissingExternalId->value,
'latest_claim_state' => ClaimState::ClaimBlocked->value,
'identity_diagnostics' => ['reason_code' => 'missing_external_id'],
]);
}
public function unsupportedIdentity(): self
{
return $this->state(fn (array $attributes): array => [
'canonical_key_kind' => CanonicalKeyKind::Unsupported->value,
'latest_identity_state' => IdentityState::UnsupportedIdentity->value,
'latest_claim_state' => ClaimState::ClaimBlocked->value,
'identity_diagnostics' => ['reason_code' => 'unsupported_identity_strategy'],
]);
}
private function workspaceIdForTenant(int $tenantId): int private function workspaceIdForTenant(int $tenantId): int
{ {
$tenant = ManagedEnvironment::query()->find($tenantId); $tenant = ManagedEnvironment::query()->find($tenantId);

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const KEY_KINDS = [
'provider_external_id',
'graph_object_id',
'tcm_resource_identifier',
'source_composite',
'derived_composite',
'experimental_source_key',
'unsupported',
];
public function up(): void
{
Schema::table('tenant_configuration_resources', function (Blueprint $table): void {
$table->string('canonical_key_kind')->default('provider_external_id')->after('canonical_resource_key');
$table->string('identity_strategy')->nullable()->after('source_metadata');
$table->jsonb('source_identity')->default('{}')->after('identity_strategy');
$table->jsonb('secondary_identity_keys')->default('{}')->after('source_identity');
$table->jsonb('identity_diagnostics')->default('{}')->after('secondary_identity_keys');
$table->timestampTz('identity_evaluated_at')->nullable()->after('identity_diagnostics');
$table->index(
['workspace_id', 'managed_environment_id', 'provider_connection_id', 'resource_type_id', 'latest_identity_state'],
'tenant_config_resources_scope_identity_state_idx',
);
$table->index(['resource_type_id', 'canonical_key_kind'], 'tenant_config_resources_type_key_kind_idx');
});
$this->addPostgresConstraints();
}
public function down(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('ALTER TABLE tenant_configuration_resources DROP CONSTRAINT IF EXISTS tenant_config_resources_key_kind_check');
DB::statement('ALTER TABLE tenant_configuration_resources DROP CONSTRAINT IF EXISTS tenant_config_resources_source_identity_object_check');
DB::statement('ALTER TABLE tenant_configuration_resources DROP CONSTRAINT IF EXISTS tenant_config_resources_secondary_keys_object_check');
DB::statement('ALTER TABLE tenant_configuration_resources DROP CONSTRAINT IF EXISTS tenant_config_resources_identity_diagnostics_object_check');
}
Schema::table('tenant_configuration_resources', function (Blueprint $table): void {
$table->dropIndex('tenant_config_resources_scope_identity_state_idx');
$table->dropIndex('tenant_config_resources_type_key_kind_idx');
$table->dropColumn([
'canonical_key_kind',
'identity_strategy',
'source_identity',
'secondary_identity_keys',
'identity_diagnostics',
'identity_evaluated_at',
]);
});
}
private function addPostgresConstraints(): void
{
if (DB::getDriverName() !== 'pgsql') {
return;
}
DB::statement($this->checkIn('tenant_configuration_resources', 'canonical_key_kind', self::KEY_KINDS, 'tenant_config_resources_key_kind_check'));
DB::statement("ALTER TABLE tenant_configuration_resources ADD CONSTRAINT tenant_config_resources_source_identity_object_check CHECK (jsonb_typeof(source_identity) = 'object')");
DB::statement("ALTER TABLE tenant_configuration_resources ADD CONSTRAINT tenant_config_resources_secondary_keys_object_check CHECK (jsonb_typeof(secondary_identity_keys) = 'object')");
DB::statement("ALTER TABLE tenant_configuration_resources ADD CONSTRAINT tenant_config_resources_identity_diagnostics_object_check CHECK (jsonb_typeof(identity_diagnostics) = 'object')");
}
/**
* @param list<string> $values
*/
private function checkIn(string $table, string $column, array $values, string $constraintName): string
{
$quotedValues = implode(', ', array_map(
static fn (string $value): string => "'".str_replace("'", "''", $value)."'",
$values,
));
return sprintf(
'ALTER TABLE %s ADD CONSTRAINT %s CHECK (%s IN (%s))',
$table,
$constraintName,
$column,
$quotedValues,
);
}
};

View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\CoverageEvidenceWriter;
use App\Services\TenantConfiguration\CoverageResourceUpserter;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\GenericPayloadNormalizer;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\IdentityState;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
it('Spec417 persists canonical identity metadata on captured resources without duplicate key truth', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
$connection = spec417PersistenceConnection($tenant);
$resourceType = spec417PersistenceResourceType('deviceAndAppManagementAssignmentFilter');
$decision = app(CoverageSourceContractResolver::class)->resolve($resourceType);
$resource = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: [
'id' => 'assignment-filter-1',
'displayName' => 'Corporate devices',
'platform' => 'windows10AndLater',
],
sourceMetadata: $decision->sourceMetadata,
);
expect($resource->canonical_key_kind)->toBe(CanonicalKeyKind::TcmResourceIdentifier)
->and($resource->latest_identity_state)->toBe(IdentityState::Stable)
->and($resource->source_identity['strategy_identifier'])->toBe('tcm.assignment_filter.v1')
->and($resource->source_identity['candidate_key_hash'])->toBeString()->not->toBe('')
->and($resource->secondary_identity_keys['displayName'])->toBe('Corporate devices')
->and($resource->identity_diagnostics['reason_code'])->toBe('stable_identity_resolved')
->and($resource->identity_evaluated_at)->not->toBeNull();
expect(Schema::hasColumn('tenant_configuration_resources', 'canonical_resource_key'))->toBeTrue()
->and(Schema::hasColumn('tenant_configuration_resources', 'canonical_key'))->toBeFalse();
$run = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
'type' => OperationRunType::TenantConfigurationCapture->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$normalizer = new GenericPayloadNormalizer;
$payload = ['displayName' => 'Display only'];
$missingIdentityResource = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: $payload,
sourceMetadata: $decision->sourceMetadata,
);
$normalized = $normalizer->normalize($payload);
app(CoverageEvidenceWriter::class)->append(
resource: $missingIdentityResource,
resourceType: $resourceType,
providerConnection: $connection,
operationRun: $run,
decision: $decision,
rawPayload: $payload,
normalizedPayload: $normalized,
payloadHash: $normalizer->payloadHash($normalized),
);
$missingIdentityResource->refresh();
expect($missingIdentityResource->latest_identity_state)->toBe(IdentityState::MissingExternalId)
->and($missingIdentityResource->latest_claim_state)->toBe(ClaimState::ClaimBlocked);
});
it('Spec417 enforces canonical key kind constraints in PostgreSQL', function (): void {
if (DB::getDriverName() !== 'pgsql') {
test()->markTestSkipped('PostgreSQL identity constraint coverage.');
}
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
$resource = TenantConfigurationResource::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider_connection_id' => (int) spec417PersistenceConnection($tenant)->getKey(),
'resource_type_id' => (int) spec417PersistenceResourceType('deviceAndAppManagementAssignmentFilter')->getKey(),
]);
expect(fn () => DB::table('tenant_configuration_resources')
->whereKey((int) $resource->getKey())
->update(['canonical_key_kind' => 'display_name_only']))
->toThrow(QueryException::class);
});
function spec417PersistenceResourceType(string $canonicalType): TenantConfigurationResourceType
{
app(ResourceTypeRegistry::class)->syncDefaults();
return TenantConfigurationResourceType::query()
->where('canonical_type', $canonicalType)
->firstOrFail();
}
function spec417PersistenceConnection($tenant): ProviderConnection
{
return ProviderConnection::factory()->withCredential()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
]);
}

View File

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\CoverageResourceUpserter;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\IdentityState;
it('Spec417 preserves resource rows across stable identity renames', function (): void {
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
$connection = spec417UpsertConnection($tenant);
$resourceType = spec417UpsertResourceType('deviceAndAppManagementAssignmentFilter');
$decision = app(CoverageSourceContractResolver::class)->resolve($resourceType);
$first = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: ['id' => 'assignment-filter-1', 'displayName' => 'Old name'],
sourceMetadata: $decision->sourceMetadata,
);
$second = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: ['id' => 'assignment-filter-1', 'displayName' => 'New name'],
sourceMetadata: $decision->sourceMetadata,
);
expect($second->getKey())->toBe($first->getKey())
->and($second->source_display_name)->toBe('New name')
->and(TenantConfigurationResource::query()->count())->toBe(1);
});
it('Spec417 keeps duplicate display names with different stable ids as separate resources', function (): void {
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
$connection = spec417UpsertConnection($tenant);
$resourceType = spec417UpsertResourceType('deviceAndAppManagementAssignmentFilter');
$decision = app(CoverageSourceContractResolver::class)->resolve($resourceType);
foreach (['assignment-filter-1', 'assignment-filter-2'] as $sourceId) {
app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: ['id' => $sourceId, 'displayName' => 'Same visible name'],
sourceMetadata: $decision->sourceMetadata,
);
}
expect(TenantConfigurationResource::query()->count())->toBe(2)
->and(TenantConfigurationResource::query()->pluck('source_display_name')->unique()->values()->all())->toBe(['Same visible name'])
->and(TenantConfigurationResource::query()->pluck('canonical_resource_key')->unique())->toHaveCount(2);
});
it('Spec417 never marks display-name-only resources stable', function (): void {
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
$connection = spec417UpsertConnection($tenant);
$resourceType = spec417UpsertResourceType('deviceAndAppManagementAssignmentFilter');
$decision = app(CoverageSourceContractResolver::class)->resolve($resourceType);
$resource = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: ['displayName' => 'Only a label'],
sourceMetadata: $decision->sourceMetadata,
);
expect($resource->latest_identity_state)->toBe(IdentityState::MissingExternalId)
->and($resource->canonical_resource_key)->not->toContain('Only a label');
});
it('Spec417 does not merge repeated display-name-only resources as identity truth', function (): void {
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
$connection = spec417UpsertConnection($tenant);
$resourceType = spec417UpsertResourceType('deviceAndAppManagementAssignmentFilter');
$decision = app(CoverageSourceContractResolver::class)->resolve($resourceType);
$first = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: ['displayName' => 'Only a label'],
sourceMetadata: $decision->sourceMetadata,
);
$second = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: ['displayName' => 'Only a label'],
sourceMetadata: $decision->sourceMetadata,
);
$first->refresh();
expect($second->getKey())->not->toBe($first->getKey())
->and(TenantConfigurationResource::query()->count())->toBe(2)
->and(TenantConfigurationResource::query()->pluck('canonical_resource_key')->unique())->toHaveCount(2)
->and(TenantConfigurationResource::query()->pluck('latest_identity_state')->all())->each->toBe(IdentityState::IdentityConflict)
->and(TenantConfigurationResource::query()->pluck('latest_claim_state')->all())->each->toBe(ClaimState::ClaimBlocked);
});
it('Spec417 does not merge unsupported identity observations by fallback candidate key', function (): void {
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
$connection = spec417UpsertConnection($tenant);
$resourceType = TenantConfigurationResourceType::factory()->create([
'canonical_type' => 'unsupportedIdentityType',
'default_claim_state' => ClaimState::ClaimAllowed->value,
]);
$first = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: ['id' => 'unsupported-1', 'displayName' => 'Unsupported A'],
);
$second = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: ['id' => 'unsupported-2', 'displayName' => 'Unsupported B'],
);
$first->refresh();
expect($second->getKey())->not->toBe($first->getKey())
->and(TenantConfigurationResource::query()->count())->toBe(2)
->and(TenantConfigurationResource::query()->pluck('canonical_resource_key')->unique())->toHaveCount(2)
->and(TenantConfigurationResource::query()->pluck('latest_identity_state')->all())->each->toBe(IdentityState::IdentityConflict)
->and(TenantConfigurationResource::query()->pluck('latest_claim_state')->all())->each->toBe(ClaimState::ClaimBlocked);
});
function spec417UpsertResourceType(string $canonicalType): TenantConfigurationResourceType
{
app(ResourceTypeRegistry::class)->syncDefaults();
return TenantConfigurationResourceType::query()
->where('canonical_type', $canonicalType)
->firstOrFail();
}
function spec417UpsertConnection($tenant): ProviderConnection
{
return ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
]);
}

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\CoverageResourceUpserter;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\IdentityState;
it('Spec417 stores blocked or limited claim state from identity evaluation during upsert', function (): void {
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
$connection = spec417ClaimConnection($tenant);
$resourceType = spec417ClaimResourceType('deviceAndAppManagementAssignmentFilter');
$decision = app(CoverageSourceContractResolver::class)->resolve($resourceType);
$derived = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: [
'platform' => 'windows10AndLater',
'assignmentFilterManagementType' => 'devices',
'rule' => '(device.deviceId -ne null)',
'displayName' => 'Derived',
],
sourceMetadata: $decision->sourceMetadata,
);
$missing = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: ['displayName' => 'Missing id'],
sourceMetadata: $decision->sourceMetadata,
);
expect($derived->latest_identity_state)->toBe(IdentityState::Derived)
->and($derived->latest_claim_state)->toBe(ClaimState::ClaimLimited)
->and($missing->latest_identity_state)->toBe(IdentityState::MissingExternalId)
->and($missing->latest_claim_state)->toBe(ClaimState::ClaimBlocked);
});
it('Spec417 keeps beta identity internal or claim-blocked by default', function (): void {
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
$connection = spec417ClaimConnection($tenant);
$resourceType = spec417ClaimResourceType('roleScopeTag');
$resource = app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: ['id' => 'scope-tag-1', 'displayName' => 'Pilot'],
sourceMetadata: [
'source_contract_key' => 'roleScopeTag',
'source_version' => 'beta',
],
);
expect($resource->latest_identity_state)->toBe(IdentityState::Derived)
->and($resource->latest_claim_state)->toBe(ClaimState::InternalOnly);
});
function spec417ClaimResourceType(string $canonicalType): TenantConfigurationResourceType
{
app(ResourceTypeRegistry::class)->syncDefaults();
return TenantConfigurationResourceType::query()
->where('canonical_type', $canonicalType)
->firstOrFail();
}
function spec417ClaimConnection($tenant): ProviderConnection
{
return ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
]);
}

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\CoverageResourceUpserter;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\IdentityState;
it('Spec417 marks same-scope unsafe derived identity collisions as conflicts', function (): void {
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
$connection = spec417ConflictConnection($tenant);
$resourceType = spec417ConflictResourceType('deviceAndAppManagementAssignmentFilter');
$decision = app(CoverageSourceContractResolver::class)->resolve($resourceType);
$basePayload = [
'platform' => 'windows10AndLater',
'assignmentFilterManagementType' => 'devices',
'rule' => '(device.osVersion -startsWith "10.")',
];
app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: [...$basePayload, 'displayName' => 'Candidate A'],
sourceMetadata: $decision->sourceMetadata,
);
app(CoverageResourceUpserter::class)->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: [...$basePayload, 'displayName' => 'Candidate B'],
sourceMetadata: $decision->sourceMetadata,
);
$resources = TenantConfigurationResource::query()->orderBy('id')->get();
expect($resources)->toHaveCount(2)
->and($resources->pluck('latest_identity_state')->all())->each->toBe(IdentityState::IdentityConflict)
->and($resources->pluck('latest_claim_state')->all())->each->toBe(ClaimState::ClaimBlocked)
->and($resources->last()->identity_diagnostics['reason_code'])->toBe('same_scope_derived_identity_collision');
});
it('Spec417 does not merge the same stable source key across workspace, environment, or provider scope', function (): void {
[, $tenant] = createMinimalUserWithTenant(role: 'owner');
[, $otherTenant] = createMinimalUserWithTenant(role: 'owner');
$resourceType = spec417ConflictResourceType('deviceAndAppManagementAssignmentFilter');
$decision = app(CoverageSourceContractResolver::class)->resolve($resourceType);
$connection = spec417ConflictConnection($tenant);
$otherConnection = spec417ConflictConnection($otherTenant);
$sameEnvironmentSecondProvider = spec417ConflictConnection($tenant);
foreach ([
[$tenant, $connection],
[$otherTenant, $otherConnection],
[$tenant, $sameEnvironmentSecondProvider],
] as [$scopedTenant, $scopedConnection]) {
app(CoverageResourceUpserter::class)->upsert(
tenant: $scopedTenant,
providerConnection: $scopedConnection,
resourceType: $resourceType,
payload: ['id' => 'same-provider-key', 'displayName' => 'Same resource name'],
sourceMetadata: $decision->sourceMetadata,
);
}
expect(TenantConfigurationResource::query()->count())->toBe(3)
->and(TenantConfigurationResource::query()->pluck('canonical_resource_key')->unique())->toHaveCount(1)
->and(TenantConfigurationResource::query()->pluck('provider_connection_id')->unique())->toHaveCount(3);
});
function spec417ConflictResourceType(string $canonicalType): TenantConfigurationResourceType
{
app(ResourceTypeRegistry::class)->syncDefaults();
return TenantConfigurationResourceType::query()
->where('canonical_type', $canonicalType)
->firstOrFail();
}
function spec417ConflictConnection($tenant): ProviderConnection
{
return ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
]);
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Schema;
it('Spec417 keeps canonical identity backend-only without legacy ownership or UI activation', function (): void {
expect(Schema::hasColumn('tenant_configuration_resources', 'tenant_id'))->toBeFalse()
->and(Schema::hasColumn('tenant_configuration_resources', 'canonical_key'))->toBeFalse();
$identityUiMentions = collect([
...glob(base_path('app/Filament/**/*'), GLOB_BRACE),
...glob(base_path('resources/views/**/*'), GLOB_BRACE),
...glob(base_path('routes/*.php'), GLOB_BRACE),
])
->filter(static fn (string $path): bool => is_file($path))
->filter(static fn (string $path): bool => str_contains(file_get_contents($path) ?: '', 'canonical-identity'))
->values();
expect($identityUiMentions->all())->toBe([]);
$activeTenantConfigurationFiles = collect([
...glob(base_path('app/Services/TenantConfiguration/*.php'), GLOB_BRACE),
...glob(base_path('app/Support/TenantConfiguration/*.php'), GLOB_BRACE),
])->filter(static fn (string $path): bool => is_file($path));
foreach ([
'ambiguous_match',
'policy_record_missing',
'foundation_not_policy_backed',
'meta_fallback',
'raw_gap_count',
'primary_gap_count',
'v1_to_v2',
'fallback_to_latest',
'dual_write',
] as $legacyTerm) {
$matches = $activeTenantConfigurationFiles
->filter(static fn (string $path): bool => str_contains(file_get_contents($path) ?: '', $legacyTerm))
->values()
->all();
expect($matches)->toBe([], "{$legacyTerm} must not be active Coverage v2 identity truth.");
}
});

View File

@ -5,6 +5,7 @@
use App\Services\TenantConfiguration\ClaimGuard; use App\Services\TenantConfiguration\ClaimGuard;
use App\Support\TenantConfiguration\ClaimState; use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel; use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\RestoreTier; use App\Support\TenantConfiguration\RestoreTier;
use App\Support\TenantConfiguration\SourceClass; use App\Support\TenantConfiguration\SourceClass;
@ -71,3 +72,40 @@
customerFacing: true, customerFacing: true,
))->toBe(ClaimState::ClaimAllowed); ))->toBe(ClaimState::ClaimAllowed);
}); });
it('Spec417 blocks unsafe identity states before customer claims can be made', function (IdentityState $identityState): void {
$guard = new ClaimGuard;
expect($guard->evaluate(
scopeKey: 'intune_tcm_core',
requestedLevel: CoverageLevel::ContentBacked,
actualLevel: CoverageLevel::ContentBacked,
scopeComplete: true,
identityState: $identityState,
))->toBe(ClaimState::ClaimBlocked);
})->with([
'identity conflict' => IdentityState::IdentityConflict,
'missing external id' => IdentityState::MissingExternalId,
'unsupported identity' => IdentityState::UnsupportedIdentity,
]);
it('Spec417 limits derived identity unless explicitly allowed', function (): void {
$guard = new ClaimGuard;
expect($guard->evaluate(
scopeKey: 'intune_tcm_core',
requestedLevel: CoverageLevel::ContentBacked,
actualLevel: CoverageLevel::ContentBacked,
scopeComplete: true,
identityState: IdentityState::Derived,
))->toBe(ClaimState::ClaimLimited);
expect($guard->evaluate(
scopeKey: 'intune_tcm_core',
requestedLevel: CoverageLevel::ContentBacked,
actualLevel: CoverageLevel::ContentBacked,
scopeComplete: true,
customerFacing: true,
identityState: IdentityState::Derived,
))->toBe(ClaimState::ClaimBlocked);
});

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\CanonicalIdentityResolver;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\SourceClass;
it('Spec417 resolves stable TCM identity from provider source ids', function (): void {
$type = spec417UnitResourceType('deviceAndAppManagementAssignmentFilter', SourceClass::Tcm);
$result = app(CanonicalIdentityResolver::class)->resolve($type, [
'id' => 'assignment-filter-1',
'displayName' => 'Corporate devices',
], [
'source_contract_key' => 'assignmentFilter',
]);
expect($result->identityState)->toBe(IdentityState::Stable)
->and($result->keyKind)->toBe(CanonicalKeyKind::TcmResourceIdentifier)
->and($result->sourceResourceId)->toBe('assignment-filter-1')
->and($result->canonicalResourceKey)->toContain('deviceAndAppManagementAssignmentFilter:tcm_resource_identifier:')
->and($result->secondaryKeys['displayName'])->toBe('Corporate devices');
});
it('Spec417 resolves documented composite identity as derived instead of stable', function (): void {
$type = spec417UnitResourceType('deviceAndAppManagementAssignmentFilter', SourceClass::Tcm);
$result = app(CanonicalIdentityResolver::class)->resolve($type, [
'displayName' => 'Corporate devices',
'platform' => 'windows10AndLater',
'assignmentFilterManagementType' => 'devices',
'rule' => '(device.osVersion -startsWith "10.")',
], [
'source_contract_key' => 'assignmentFilter',
]);
expect($result->identityState)->toBe(IdentityState::Derived)
->and($result->keyKind)->toBe(CanonicalKeyKind::SourceComposite)
->and($result->derivedClaimsAllowed)->toBeFalse()
->and($result->diagnostics['reason_code'])->toBe('source_composite_identity_resolved');
});
it('Spec417 rejects display-name-only payloads as stable identity', function (): void {
$type = spec417UnitResourceType('deviceAndAppManagementAssignmentFilter', SourceClass::Tcm);
$result = app(CanonicalIdentityResolver::class)->resolve($type, [
'displayName' => 'Corporate devices',
], [
'source_contract_key' => 'assignmentFilter',
]);
expect($result->identityState)->toBe(IdentityState::MissingExternalId)
->and($result->keyKind)->toBe(CanonicalKeyKind::Unsupported)
->and($result->diagnostics['reason_code'])->toBe('missing_external_id')
->and($result->canonicalResourceKey)->not->toContain('displayName');
});
it('Spec417 keeps beta Graph identity derived and experimental', function (): void {
$type = spec417UnitResourceType('roleScopeTag', SourceClass::GraphBetaExperimental);
$result = app(CanonicalIdentityResolver::class)->resolve($type, [
'id' => 'scope-tag-1',
'displayName' => 'Pilot',
], [
'source_contract_key' => 'roleScopeTag',
'source_version' => 'beta',
]);
expect($result->identityState)->toBe(IdentityState::Derived)
->and($result->keyKind)->toBe(CanonicalKeyKind::ExperimentalSourceKey)
->and($result->derivedClaimsAllowed)->toBeFalse()
->and($result->diagnostics['reason_code'])->toBe('experimental_identity_resolved');
});
it('Spec417 marks unknown resource types as unsupported identity', function (): void {
$type = spec417UnitResourceType('unknownCanonicalType', SourceClass::Tcm);
$result = app(CanonicalIdentityResolver::class)->resolve($type, [
'id' => 'unknown-1',
]);
expect($result->identityState)->toBe(IdentityState::UnsupportedIdentity)
->and($result->keyKind)->toBe(CanonicalKeyKind::Unsupported)
->and($result->diagnostics['reason_code'])->toBe('unsupported_identity_strategy');
});
function spec417UnitResourceType(string $canonicalType, SourceClass $sourceClass): TenantConfigurationResourceType
{
return new TenantConfigurationResourceType([
'canonical_type' => $canonicalType,
'source_class' => $sourceClass->value,
]);
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\CoverageIdentityStrategyRegistry;
it('Spec417 defines canonical identity strategies for the initial Coverage v2 resource types', function (): void {
$strategies = app(CoverageIdentityStrategyRegistry::class)->strategies();
expect(array_keys($strategies))->toBe([
'deviceAndAppManagementAssignmentFilter',
'deviceEnrollmentLimitRestriction',
'deviceEnrollmentPlatformRestriction',
'deviceEnrollmentStatusPageWindows10',
'appProtectionPolicyAndroid',
'appProtectionPolicyiOS',
'notificationMessageTemplate',
'roleScopeTag',
]);
foreach ($strategies as $canonicalType => $strategy) {
expect($strategy['strategy_identifier'])->toBeString()->not->toBe('')
->and($strategy['preferred_identity_fields'])->toBeArray()->not->toBeEmpty()
->and($strategy['display_fields'])->toContain('displayName')
->and($strategy['requires_provider_connection_scope'])->toBeTrue()
->and($strategy['derived_claims_allowed'])->toBeFalse("{$canonicalType} must not certify derived identity by default");
}
});
it('Spec417 keeps beta identity experimental and claim-blocked by default', function (): void {
$strategy = app(CoverageIdentityStrategyRegistry::class)->strategies()['roleScopeTag'];
expect($strategy['allows_experimental_identity'])->toBeTrue()
->and($strategy['allows_derived_identity'])->toBeTrue()
->and($strategy['derived_claims_allowed'])->toBeFalse();
});

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\CoverageSecondaryKeyBuilder;
it('Spec417 builds secondary keys as redacted diagnostic metadata only', function (): void {
$keys = app(CoverageSecondaryKeyBuilder::class)->build([
'display_fields' => ['displayName'],
'secondary_fields' => ['platform', 'client_secret', 'source_metadata.authorization', 'source_metadata.source_contract_key'],
], [
'displayName' => 'Corporate devices',
'platform' => 'windows10AndLater',
'client_secret' => 'secret-value',
], [
'authorization' => 'Bearer top-secret',
'source_contract_key' => 'assignmentFilter',
]);
expect($keys)->toMatchArray([
'displayName' => 'Corporate devices',
'platform' => 'windows10AndLater',
'client_secret' => '[redacted]',
'source_metadata.authorization' => '[redacted]',
'source_metadata.source_contract_key' => 'assignmentFilter',
]);
});

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\IdentityConflictDiagnosticsBuilder;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\IdentityState;
it('Spec417 builds bounded redacted identity diagnostics', function (): void {
$diagnostics = app(IdentityConflictDiagnosticsBuilder::class)->build(
reasonCode: 'same_scope_derived_identity_collision',
identityState: IdentityState::IdentityConflict,
keyKind: CanonicalKeyKind::DerivedComposite,
missingFields: ['id', 'sourceId', 'id'],
metadata: [
'candidate_count' => 2,
'authorization' => 'Bearer secret',
'candidate_values' => range(1, 20),
],
);
expect($diagnostics['reason_code'])->toBe('same_scope_derived_identity_collision')
->and($diagnostics['identity_state'])->toBe('identity_conflict')
->and($diagnostics['key_kind'])->toBe('derived_composite')
->and($diagnostics['missing_fields'])->toBe(['id', 'sourceId'])
->and($diagnostics['authorization'])->toBe('[redacted]')
->and($diagnostics['candidate_values'])->toHaveCount(16);
});

View File

@ -0,0 +1,77 @@
# Specification Quality Checklist: Spec 417 - Canonical Identity Engine
## Candidate And Scope
- [x] Candidate is user-provided, not auto-selected from an empty active candidate queue.
- [x] Spec 414 is completed/validated dependency context only.
- [x] Spec 415 is completed/validated dependency context only.
- [x] No existing `417-canonical-identity-engine` spec or branch was found before creation.
- [x] Scope is limited to Coverage v2 canonical identity for captured resources.
- [x] No Coverage v2 customer/operator activation is included.
- [x] No compare, render, restore, certification, or full TCM catalog import is included.
## Ownership And Isolation
- [x] Internal scope truth is `workspace_id`, `managed_environment_id`, and `provider_connection_id`.
- [x] Provider connection same-scope validation is required.
- [x] External Microsoft/Entra tenant IDs remain metadata only.
- [x] `tenant_id` is forbidden as Coverage v2 ownership truth.
- [x] Cross-workspace identity collisions cannot merge.
- [x] Cross-managed-environment identity collisions cannot merge.
- [x] Cross-provider identity collisions cannot merge.
## Identity Requirements
- [x] Initial eight Coverage v2 resource types are listed.
- [x] Identity strategy fields are defined.
- [x] Stable provider/Graph/TCM IDs are preferred.
- [x] Source/composite fallback behavior is defined.
- [x] Display-name-only stable identity is forbidden.
- [x] Existing `IdentityState` values are used.
- [x] Canonical key-kind values are bounded.
- [x] Existing `canonical_resource_key` duplicate-truth risk is addressed.
- [x] Missing external ID behavior is explicit.
- [x] Unsupported identity behavior is explicit.
- [x] Beta/experimental identity cannot certify by default.
## Claim And Evidence Safety
- [x] Claim Guard blocks `identity_conflict`.
- [x] Claim Guard blocks or limits `missing_external_id`.
- [x] Claim Guard blocks `unsupported_identity`.
- [x] Claim Guard limits or blocks `derived` unless explicitly allowed.
- [x] OperationRun execution truth remains separate from identity/evidence/customer proof.
- [x] Evidence payload truth remains append-only evidence, not customer proof by default.
- [x] No fallback-to-latest evidence behavior is allowed.
## Diagnostics And Redaction
- [x] Secondary keys are diagnostic metadata only.
- [x] Conflict diagnostics are bounded.
- [x] Raw payloads and full provider responses are forbidden in diagnostics.
- [x] Tokens, credentials, cookies, authorization headers, private keys, certificates, passwords, and unredacted PII are forbidden in diagnostics, OperationRun context/messages, and audit metadata.
## No Legacy / No Product Surface
- [x] No v1-to-v2 identity adapter is allowed.
- [x] No old snapshot identity promotion is allowed.
- [x] No old v1 gap taxonomy is active v2 runtime truth.
- [x] No dual write or fallback reader is allowed.
- [x] No reachable UI surface changes are allowed.
- [x] Browser proof is `N/A - no rendered UI surface changed`.
- [x] Product Surface exceptions are `none`.
- [x] Completed historical specs must not be rewritten.
## Tests And Readiness
- [x] Unit test targets are identified.
- [x] Feature test targets are identified.
- [x] PostgreSQL-lane trigger is identified for migrations/indexes/constraints/JSONB.
- [x] No browser/heavy-governance lane is planned.
- [x] Validation commands are listed.
- [x] Implementation report close-out fields are defined.
## Gate Results
- [x] Candidate Selection Gate: PASS.
- [x] Spec Readiness Gate: PASS for preparation; implementation must still follow `tasks.md`.

View File

@ -0,0 +1,149 @@
# Implementation Report: Spec 417 - Canonical Identity Engine
## Preflight
- Branch: `417-canonical-identity-engine`
- Starting HEAD: `332f6325 feat: add tenantpilot agent skill layer v1 (#483)`
- Starting dirty state: `specs/417-canonical-identity-engine/` untracked as the active spec artifact set.
- Dirty-state assessment: active Spec 417 preparation artifacts only; no runtime code was dirty before implementation.
- Activated skills: `spec-kit-implementation-loop`, `pest-testing`, `tenantpilot-spec-readiness-gate`, `tenantpilot-workspace-scope-safety`, `tenantpilot-evidence-anchor-contract`, `tenantpilot-tcm-cutover-guard`.
- Hard-gate stop conditions before implementation: none observed. Coverage v2 remains inactive; no UI/customer proof surface is in scope.
## Dependency Context
- Spec 414: completed/validated context only; not modified.
- Spec 415: completed/validated context only; not modified.
- Existing canonical identity storage: `tenant_configuration_resources.canonical_resource_key` remains the single persisted canonical key truth.
- Browser proof decision: `N/A - no rendered UI surface changed`.
## Implementation Summary
- Added a bounded canonical identity engine for the initial eight Coverage v2 resource types.
- Added `CanonicalKeyKind` values without display-name/name-only stable key kinds.
- Added identity strategy, resolver, secondary-key, diagnostics, and same-scope conflict evaluation services under `App\Services\TenantConfiguration`.
- Extended `tenant_configuration_resources` with additive identity metadata: `canonical_key_kind`, `identity_strategy`, `source_identity`, `secondary_identity_keys`, `identity_diagnostics`, and `identity_evaluated_at`.
- Kept `canonical_resource_key` as the single persisted canonical key truth; no `canonical_key` column or duplicate key truth was added.
- Updated `CoverageResourceUpserter` to consume resolver/evaluator output instead of hardcoding `id`/`sourceId`.
- Updated `CoverageEvidenceWriter` to preserve resource identity/claim state instead of resetting to resource-type defaults.
- Updated `ClaimGuard` to block `identity_conflict`, `missing_external_id`, and `unsupported_identity`, and to limit/block `derived` identity unless explicitly allowed.
- Kept Coverage v2 inactive; no UI, route, navigation, report, review, restore, customer-output, or browser-visible activation was added.
## Identity Strategy Matrix
| Canonical type | Strategy | Stable ID posture | Derived/experimental posture | Default claim consequence |
| --- | --- | --- | --- | --- |
| `deviceAndAppManagementAssignmentFilter` | `tcm.assignment_filter.v1` | TCM/provider IDs stable | source/derived composite allowed | derived limited |
| `deviceEnrollmentLimitRestriction` | `tcm.device_enrollment_limit_restriction.v1` | TCM/provider IDs stable | source/derived composite allowed | derived limited |
| `deviceEnrollmentPlatformRestriction` | `tcm.device_enrollment_platform_restriction.v1` | TCM/provider IDs stable | source/derived composite allowed | derived limited |
| `deviceEnrollmentStatusPageWindows10` | `tcm.device_enrollment_status_page_windows10.v1` | TCM/provider IDs stable | source/derived composite allowed | derived limited |
| `appProtectionPolicyAndroid` | `tcm.app_protection_policy_android.v1` | TCM/provider IDs stable | source/derived composite allowed | derived limited |
| `appProtectionPolicyiOS` | `tcm.app_protection_policy_ios.v1` | TCM/provider IDs stable | source/derived composite allowed | derived limited |
| `notificationMessageTemplate` | `graph.notification_message_template.v1` | Graph/template IDs stable | source/derived composite allowed | no certification by default |
| `roleScopeTag` | `graph_beta.role_scope_tag.v1` | beta IDs are experimental, not stable proof | experimental source key resolves as derived | internal-only by default |
## Schema And Persistence
- Migration added: `apps/platform/database/migrations/2026_06_26_000417_extend_tenant_configuration_resource_identity.php`.
- Added PostgreSQL check constraint for `canonical_key_kind`.
- Added PostgreSQL JSONB object constraints for `source_identity`, `secondary_identity_keys`, and `identity_diagnostics`.
- Added targeted indexes for scope/type/identity-state and resource-type/key-kind lookup paths.
- No speculative JSONB GIN index was added.
- Tombstone behavior is deferred; no tombstone field or active delete/drift workflow is implemented in this slice.
## Scope, Claim, And Evidence Safety
- Scope tuple remains `workspace_id`, `managed_environment_id`, `provider_connection_id`, `resource_type_id`, and `canonical_resource_key`.
- Provider connections are still validated against the same workspace and managed environment before upsert/capture.
- Stable ID rename updates the same same-scope resource row.
- Duplicate display names with different stable IDs remain separate rows.
- Display-name-only payloads resolve to `missing_external_id`, not `stable`; repeated same-scope display-only observations are promoted to `identity_conflict` instead of merging by secondary/display fingerprint.
- Unsupported identity observations are promoted to `identity_conflict` on same-scope fallback-key collision instead of merging by unsupported fallback key.
- Same-scope unsafe derived identity collisions mark existing/incoming resources as `identity_conflict` and `claim_blocked`.
- Cross-workspace, cross-managed-environment, and cross-provider resources do not merge because the existing scope tuple remains part of identity uniqueness.
- Diagnostics and secondary keys are redacted and bounded; no raw provider payloads, tokens, secrets, cookies, authorization headers, private keys, certificates, or full provider response dumps are persisted in identity diagnostics.
- No fallback-to-latest evidence path, v1-to-v2 adapter, dual-write path, old snapshot promotion, or old v1 gap taxonomy was introduced.
## Product Surface Close-Out
- UI Surface Impact: none.
- Product Surface Impact: `N/A - no rendered product surface changed`.
- Browser smoke result: `N/A - no rendered UI surface changed`.
- Human Product Sanity: `N/A - no product surface changed`; visible complexity outcome is neutral.
- Livewire v4 compliance: Livewire v4 baseline unchanged; no Livewire code changed.
- Provider registration location: no panel provider change; Laravel 12 providers remain in `apps/platform/bootstrap/providers.php`.
- Global search posture: no Filament resource/global search change.
- Destructive/high-impact actions: none added.
- Asset strategy: no assets registered; `filament:assets` is not required by this spec.
- No completed historical spec was rewritten or stripped of validation, task, smoke, browser, or review history.
## Validation
- PASS: `php -l` syntax sweep for tracked and untracked changed PHP files.
- PASS: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec417` (24 passed, 1 PostgreSQL-only skipped, 162 assertions).
- PASS: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration` (35 passed, 170 assertions).
- PASS: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration` (35 passed, 8 PostgreSQL-only skipped, 190 assertions).
- PASS: `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/TenantConfiguration` (43 passed, 203 assertions).
- PASS: `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- PASS: `git diff --check`.
- PASS: untracked-file trailing-whitespace scan.
## Analysis And Fix Loop
- Iteration 1 finding: secondary-key and diagnostics builders redacted nested values without preserving key context, so sensitive scalar fields were not redacted. Fixed by redacting with field-name context; regression tests pass.
- Iteration 2 finding: PostgreSQL JSONB object constraints rejected empty PHP arrays encoded as `[]`. Fixed by normalizing empty metadata/defaults to JSON objects; PostgreSQL lane passes.
- Post-implementation scope finding: optional tombstone timestamp was unnecessary without tombstone behavior. Fixed by removing the unused field and documenting tombstone deferral.
- Manual final-review finding: repeated same-scope display-name-only payloads and unsupported identity observations could merge by fallback candidate keys even though they were not marked stable. Fixed by treating repeated `missing_external_id` and `unsupported_identity` candidate collisions as `identity_conflict`, marking existing candidates `claim_blocked`, generating a separate conflict key for the new unsafe observation, normalizing empty `source_metadata` to JSON object form, and adding feature regressions for both paths.
- Remaining confirmed in-scope findings: none.
## Files Changed
- `apps/platform/app/Models/TenantConfigurationResource.php`
- `apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResolver.php`
- `apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResult.php`
- `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php`
- `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php`
- `apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php`
- `apps/platform/app/Services/TenantConfiguration/CoverageResourceIdentityEvaluator.php`
- `apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php`
- `apps/platform/app/Services/TenantConfiguration/CoverageSecondaryKeyBuilder.php`
- `apps/platform/app/Services/TenantConfiguration/IdentityConflictDiagnosticsBuilder.php`
- `apps/platform/app/Support/TenantConfiguration/CanonicalKeyKind.php`
- `apps/platform/database/factories/TenantConfigurationResourceFactory.php`
- `apps/platform/database/migrations/2026_06_26_000417_extend_tenant_configuration_resource_identity.php`
- `apps/platform/tests/Feature/TenantConfiguration/Spec417CanonicalIdentityPersistenceTest.php`
- `apps/platform/tests/Feature/TenantConfiguration/Spec417CoverageResourceIdentityUpsertTest.php`
- `apps/platform/tests/Feature/TenantConfiguration/Spec417IdentityClaimGuardFeatureTest.php`
- `apps/platform/tests/Feature/TenantConfiguration/Spec417IdentityConflictScopeTest.php`
- `apps/platform/tests/Feature/TenantConfiguration/Spec417IdentityNoLegacyNoUiActivationTest.php`
- `apps/platform/tests/Unit/Support/TenantConfiguration/ClaimGuardTest.php`
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CanonicalIdentityResolverTest.php`
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php`
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageSecondaryKeyBuilderTest.php`
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec417IdentityConflictDiagnosticsTest.php`
- `specs/417-canonical-identity-engine/implementation-report.md`
- `specs/417-canonical-identity-engine/tasks.md`
## Deployment Impact
- Migrations: yes, additive identity metadata/check constraints/indexes on `tenant_configuration_resources`.
- Environment variables: none.
- Queues/workers: no new queue or OperationRun path.
- Scheduler: no change.
- Storage/volumes: no change.
- Assets: no change.
- Staging/production: run migrations before any later Coverage v2 activation; validate PostgreSQL migration in staging.
## Deferred Work
- Tombstone/delete workflow remains out of scope.
- Customer/operator identity diagnostics UI remains out of scope.
- Coverage v2 activation, legacy cutover/removal, compare/render/restore/report/customer claims remain future specs.
## Final Gate Result
- Spec Readiness Gate: PASS.
- Implementation Scope Gate: PASS.
- Test Gate: PASS.
- Browser Smoke Test Gate: PASS as not applicable, `N/A - no rendered UI surface changed`.
- Post-Implementation Analysis Gate: PASS.
- Merge Readiness Gate: PASS; ready for manual review/merge.

View File

@ -0,0 +1,233 @@
# Implementation Plan: Spec 417 - Canonical Identity Engine
**Branch**: `417-canonical-identity-engine` | **Date**: 2026-06-26 | **Spec**: `specs/417-canonical-identity-engine/spec.md`
**Input**: Feature specification from `/specs/417-canonical-identity-engine/spec.md`
## Summary
Add a bounded canonical identity engine for Coverage v2 resources captured by the Spec 415 pipeline. The implementation should define per-resource-type identity strategies for the initial eight Coverage v2 resource types, resolve deterministic canonical identities without relying on display names as stable truth, persist one canonical key truth plus key kind and redacted diagnostics, integrate resolver output into resource upsert and Claim Guard, and keep Coverage v2 inactive with no UI/customer-output activation.
## Technical Context
**Language/Version**: PHP 8.4.x, Laravel 12.x
**Primary Dependencies**: Laravel Eloquent, PostgreSQL, existing TenantConfiguration services, existing OperationRun service if evaluation becomes separate from capture
**Storage**: PostgreSQL; existing `tenant_configuration_resources`, `tenant_configuration_resource_evidence`, and `tenant_configuration_resource_types` tables
**Testing**: Pest 4 / PHPUnit 12 via Sail
**Validation Lanes**: fast-feedback, confidence, pgsql where migration/constraint/index behavior is involved; browser N/A unless scope is amended
**Target Platform**: Laravel Sail locally, Dokploy/container deployment for staging/production
**Project Type**: Laravel monolith under `apps/platform`
**Performance Goals**: identity resolution is deterministic and DB-bounded; no Graph/provider calls beyond existing capture path; no new render-time work
**Constraints**: no UI activation, no v1/v2 compatibility adapter, no `tenant_id` ownership truth, no raw payload/secrets in diagnostics/OperationRun/audit, no duplicate canonical key truth
**Scale/Scope**: initial eight Coverage v2 resource types only; future resource packs are separate specs
## UI / Surface Guardrail Plan
- **Guardrail scope**: no operator-facing surface change.
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: N/A.
- **No-impact class, if applicable**: backend/internal runtime and data-safety only.
- **Native vs custom classification summary**: N/A.
- **Shared-family relevance**: evidence/claim safety only; no rendered interaction family.
- **State layers in scope**: internal resource identity and claim state only.
- **Audience modes in scope**: no rendered customer/operator/support UI. Internal diagnostics must remain support/platform data if later exposed.
- **Decision/diagnostic/raw hierarchy plan**: N/A for UI; raw payloads/source keys/diagnostics remain technical data.
- **Raw/support gating plan**: N/A now; any future exposure must be capability-gated and Product Surface-reviewed.
- **One-primary-action / duplicate-truth control**: no UI action; ensure one canonical persisted identity key truth.
- **Handling modes by drift class or surface**: hard-stop if UI, route, navigation, report, review, restore, or customer-output work appears.
- **Repository-signal treatment**: review-mandatory for any changed guarded UI/product file path; expected none.
- **Special surface test profiles**: N/A.
- **Required tests or manual smoke**: functional-core, persistence, scope, claim guard, no-UI static guard.
- **Exception path and spread control**: none.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
- **UI/Productization coverage decision**: No UI surface impact.
- **Coverage artifacts to update**: none.
- **No-impact rationale**: Canonical identity is internal runtime/data truth needed before later UI/customer activation.
- **Navigation / Filament provider-panel handling**: no panel/provider change.
- **Screenshot or page-report need**: no.
## Product Surface Contract Plan
- **Product Surface Contract reference**: `docs/product/standards/product-surface-contract.md`.
- **No-legacy posture**: canonical replacement; no compatibility exception.
- **Page archetype and surface budget plan**: N/A.
- **Technical Annex and deep-link demotion plan**: no rendered product view. OperationRun, raw evidence, IDs, source keys, payloads, and diagnostics remain internal data.
- **Canonical status vocabulary plan**: N/A for product-facing UI; internal `IdentityState` remains non-rendered domain state.
- **Product Surface exceptions**: none.
- **Browser verification plan**: `N/A - no rendered UI surface changed`.
- **Human Product Sanity plan**: N/A.
- **Visible complexity outcome target**: neutral.
- **Implementation report target**: `specs/417-canonical-identity-engine/implementation-report.md`.
## Filament / Livewire / Deployment Posture
- **Livewire v4 compliance**: Livewire v4.x baseline confirmed; no Livewire code expected.
- **Panel provider registration location**: no panel change; Laravel 12 panel providers remain `apps/platform/bootstrap/providers.php`.
- **Global search posture**: no resource changed or added; N/A.
- **Destructive/high-impact action posture**: none expected. If a separate identity re-evaluation start action is introduced, it is high-impact and must be authorized, audited, OperationRun-backed, confirmation-protected when user-triggered, and spec-amended for UI if rendered.
- **Asset strategy**: no assets; `filament:assets` not required by this spec.
- **Testing plan**: unit tests for strategy/resolver/secondary keys/diagnostics/claim guard; feature tests for persistence/upsert/conflict/scope/no-legacy/no-UI; PostgreSQL lane for migration/index/constraint behavior where needed.
- **Deployment impact**: expected migration(s) for identity columns/indexes; no env vars, scheduler, storage, assets, or new queue worker if evaluation stays in existing capture. If a new evaluation job is added, queue impact must be documented.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes, internal evidence/claim/capture semantics.
- **Systems touched**: TenantConfiguration resource persistence, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `GenericContentEvidenceCaptureService`, `ClaimGuard`, `ResourceTypeRegistry`, tests/factories.
- **Shared abstractions reused**: existing TenantConfiguration services, `CoveragePayloadRedactor`, existing OperationRun lifecycle if evaluation becomes operation-backed.
- **New abstraction introduced? why?**: bounded identity strategy/resolver/evaluator helpers. Existing upsert is insufficient because identity strategy varies by resource type/source class and must drive claim consequences.
- **Why the existing abstraction was sufficient or insufficient**: Existing resource type registry is sufficient for the initial resource set; current upsert is insufficient because it hardcodes `id`/`sourceId`, throws on missing ID, and defaults identity to stable.
- **Bounded deviation / spread control**: do not introduce a multi-provider framework or UI presenter. Keep identity services inside `App\Services\TenantConfiguration` / `App\Support\TenantConfiguration`.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no new UX expected.
- **Central contract reused**: existing Spec 415 capture OperationRun lifecycle if identity evaluation runs inside capture.
- **Delegated UX behaviors**: N/A.
- **Surface-owned behavior kept local**: none.
- **Queued DB-notification policy**: N/A; no new DB notifications.
- **Terminal notification path**: existing central lifecycle mechanism only if new operation-backed evaluation is added.
- **Exception path**: none.
If implementation adds a distinct `tenant_configuration.identity_evaluation` path, add or extend the operation catalog/type with service-owned transitions, numeric-only summary counts, no raw payloads in run context/messages, no custom terminal notifications, and tests for lifecycle, scope, idempotency, capability access, readonly denial, wrong-scope 404, and missing-capability 403. Do not add a UI start surface without amending spec/plan/tasks first.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes.
- **Provider-owned seams**: Graph/TCM payload field names, source IDs, source endpoint/version metadata, beta/fallback source classes.
- **Platform-core seams**: workspace/managed-environment/provider-connection ownership, canonical identity state, canonical key kind, claim state, no-customer-claim behavior.
- **Neutral platform terms / contracts preserved**: provider connection, managed environment, resource type, canonical resource key, identity state, claim state.
- **Retained provider-specific semantics and why**: Microsoft Graph/TCM field extraction remains per-strategy because Microsoft is the current concrete provider and the initial resource types are Microsoft/Intune-backed.
- **Bounded extraction or follow-up path**: document-in-feature; future providers/resource packs add strategies, not a speculative provider framework.
## Constitution Check
- Inventory-first / evidence-first: PASS. Resource identity is internal observed-resource truth; evidence payload truth stays in append-only evidence rows.
- Read/write separation: PASS. No provider write/restore/remediation behavior.
- Graph contract path: PASS. This spec should not add new Graph calls; existing capture remains through `ProviderGateway`/Graph contract path.
- Deterministic capabilities: N/A for new capabilities unless separate re-evaluation start path is added.
- RBAC-UX: PASS with conditions. Identity evaluation inherits capture authorization unless a new start path is added; any new start path requires 404/403 semantics and capability tests.
- Workspace isolation: PASS with required tests for workspace/environment/provider scope and no cross-scope merge.
- OperationRun: PASS with conditions. No new OperationRun path expected; if added, service-owned lifecycle and summary-count rules apply.
- Evidence/currentness: PASS. Identity diagnostics must not become customer proof and must not fallback to latest evidence.
- Customer output: PASS. No customer output or download surface.
- Provider boundary: PASS with strategy-local provider fields only.
- Product Surface: PASS. No rendered UI; stop if UI becomes necessary.
- Test governance: PASS. Unit/feature/pgsql lanes named; browser/heavy-governance N/A.
- Proportionality: PASS with bounded exception. Identity engine adds structural complexity because current release needs claim-safety before Coverage v2 activation.
- No premature abstraction: PASS with bounded exception. Eight concrete resource types, existing capture/upsert, and Claim Guard provide real consumers.
- Persisted truth: PASS. Canonical identity is durable resource truth and claim-safety input.
- Behavioral state: PASS. Identity states change claim behavior and conflict handling.
- No legacy / pre-production lean: PASS. No alias, fallback reader, dual write, v1 adapter, or `tenant_id`.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for pure identity logic; Feature for persistence/upsert/claim guard/no-legacy/no-UI; PostgreSQL for schema/index/constraint behavior.
- **Affected validation lanes**: fast-feedback, confidence, pgsql.
- **Why this lane mix is the narrowest sufficient proof**: service and DB behavior are the risk; browser cannot add value without UI changes.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/TenantConfiguration`
- **Fixture / helper / factory / seed / context cost risks**: keep new TenantConfiguration factory states opt-in; do not broaden unrelated workspace/provider setup.
- **Expensive defaults or shared helper growth introduced?**: no expected default broadening.
- **Heavy-family additions, promotions, or visibility changes**: none.
- **Surface-class relief / special coverage rule**: N/A - no rendered UI.
- **Closing validation and reviewer handoff**: implementation report must include exact tests run, any PostgreSQL migration smoke, dirty state, and no-browser rationale.
- **Budget / baseline / trend follow-up**: none expected; document if TenantConfiguration lane cost materially increases.
- **Review-stop questions**: lane fit, no hidden browser/heavy family, no fixture default broadening, no old v1 terms, no duplicate canonical key truth.
- **Escalation path**: document-in-feature.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
- **Why no dedicated follow-up spec is needed**: identity hardening is contained to the current Coverage v2 resource/capture/claim path.
## Project Structure
### Documentation (this feature)
```text
specs/417-canonical-identity-engine/
├── spec.md
├── plan.md
├── tasks.md
└── checklists/
└── requirements.md
```
### Source Code (likely affected in later implementation)
```text
apps/platform/app/
├── Models/
│ └── TenantConfigurationResource.php
├── Services/TenantConfiguration/
│ ├── CanonicalIdentityResolver.php
│ ├── CoverageIdentityStrategyRegistry.php
│ ├── CoverageResourceIdentityEvaluator.php
│ ├── CoverageResourceUpserter.php
│ ├── CoverageEvidenceWriter.php
│ ├── CoverageSecondaryKeyBuilder.php
│ ├── GenericContentEvidenceCaptureService.php
│ └── IdentityConflictDiagnosticsBuilder.php
└── Support/TenantConfiguration/
├── CanonicalKeyKind.php
└── IdentityState.php
apps/platform/database/
├── factories/
│ └── TenantConfigurationResourceFactory.php
└── migrations/
└── 2026_06_26_000417_extend_tenant_configuration_resource_identity.php
apps/platform/tests/
├── Unit/Support/TenantConfiguration/
└── Feature/TenantConfiguration/
```
## Implementation Phases
### Phase 0 - Preflight And Dependency Confirmation
Confirm branch, HEAD, dirty state, completed Spec 414/415 guardrail, current code paths, current schema, and no existing 417 package/branch collision.
### Phase 1 - Identity Schema And Strategy Shape
Define the minimal persisted identity shape. Use existing `canonical_resource_key` as the canonical key. If implementation proves it cannot serve, stop and amend `spec.md`, `plan.md`, and `tasks.md` before replacing it; do not treat replacement as an in-loop implementation choice. Add key kind, external/source identity, strategy identifier, secondary keys, diagnostics, evaluated timestamp, and optional tombstone timestamp only where needed.
### Phase 2 - Resolver And Strategy Implementation
Implement the strategy registry and resolver for the initial eight resource types. Extract stable IDs first, then documented source IDs/composites, then derived only where allowed, and unsupported/conflict/missing states otherwise.
### Phase 3 - Upsert, Conflict, And Claim Guard Integration
Update upsert/evidence writing to consume identity resolver output, detect same-scope unsafe collisions, preserve rename behavior, prevent cross-scope merges, and block/limit unsafe claims through Claim Guard.
### Phase 4 - Redaction, No-Legacy, And Static Guards
Ensure diagnostics/secondary keys are redacted, no v1 gap terms or adapters are introduced, no `tenant_id` ownership appears, and no UI/customer claim surface is added.
### Phase 5 - Validation And Implementation Report
Run focused validation, document schema/strategy matrix, scope proof, redaction proof, no-legacy/no-UI proof, tests, deployment impact, and deferred work.
## Complexity Tracking
Allowed complexity:
- Bounded identity strategy/resolver/evaluator services for existing resource types.
- A small key-kind value family with behavioral claim consequences.
- Additional columns/JSONB diagnostics on existing resource rows.
Rejected complexity:
- New identity conflict table by default.
- Generic multi-provider strategy framework.
- Runtime Product Surface framework or UI presenter.
- v1/v2 adapter, dual write, fallback reader, old gap taxonomy.
- Customer-facing identity UI or reports.
## Deployment Impact
- **Migrations**: likely yes, additive identity fields/indexes/check constraints on `tenant_configuration_resources`.
- **Env vars**: none expected.
- **Queues**: none if identity runs inside existing capture. Queue impact only if a new identity re-evaluation job is added.
- **Scheduler**: none.
- **Storage/volumes**: none.
- **Assets**: none; `filament:assets` not required.
- **Staging/production**: run migrations before any later Coverage v2 activation; validate no provider connection/resource scope constraint issues on staging.

View File

@ -0,0 +1,350 @@
# Feature Specification: Spec 417 - Canonical Identity Engine
**Feature Branch**: `417-canonical-identity-engine`
**Created**: 2026-06-26
**Status**: Draft
**Input**: User-provided draft "Spec 417 - Canonical Identity Engine" plus repo checks against Specs 414/415, roadmap, candidate queue, constitution, and current TenantConfiguration runtime.
## Candidate Selection
- **Selected candidate**: Spec 417 - Canonical Identity Engine.
- **Source location**: User attachment `/Users/ahmeddarrazi/.codex/attachments/7ecaf0a8-0997-4ef9-a7cc-207cdb7d1271/pasted-text.txt`.
- **Why selected**: Spec 414 implemented the inactive Coverage v2 kernel and Spec 415 implemented generic content-backed capture. Current code now persists captured `tenant_configuration_resources`, but `CoverageResourceUpserter` still derives identity directly from `id`/`sourceId` and defaults identity to `stable`. The next safety gap is deterministic provider-scoped identity before Coverage v2 can make any customer/operator claim.
- **Roadmap relationship**: `docs/product/spec-candidates.md` currently says no safe automatic next-best target remains in the active queue. This package is therefore not auto-selected from the queue; it is a user-promoted P0 follow-up in the Coverage v2 sequence and aligns with roadmap themes around evidence/coverage hardening, provider-boundary discipline, workspace/managed-environment ownership, and no-legacy cutover.
- **Close alternatives deferred**: Management-report runtime validation, artifact lifecycle retention, provider readiness productization, cross-domain indicator runtime follow-through, system-panel browser fixture work, and first governed AI consumer remain manual-promotion backlog items. They are deferred because the user supplied a specific Coverage v2 identity draft and because Coverage v2 identity is the immediate blocker after Specs 414/415.
- **Related completed-spec guardrail**: `specs/414-tcm-first-coverage-core-cutover/` and `specs/415-generic-content-backed-capture/` are completed/validated dependency context only and must not be rewritten. The earlier 414/415 follow-up label "Spec 416 - Canonical Identity Engine" is superseded by current repo truth because Spec 416 is now `tenantpilot-agent-skill-layer-v1`.
- **Smallest viable implementation slice**: Add a canonical identity strategy/resolution/evaluation path for the initial eight Coverage v2 resource types, persist identity key kind and safe diagnostics on existing resource rows, integrate identity output into capture upsert and Claim Guard, and prove no display-name-only stable truth, cross-scope merge, customer claim, UI activation, or v1 adapter exists.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Captured Coverage v2 resources need stable, deterministic, provider-scoped identity before future compare, review, report, restore, or certification flows can trust them.
- **Today's failure**: A captured payload with a weak or missing provider ID can be treated as stable, while duplicate display names or unsafe derived identities could later produce false coverage, compare, restore, or customer-evidence claims.
- **User-visible improvement**: No direct UI change in this slice. Future operators and customers are protected from false "covered" or "ready" claims because unsafe identity blocks or limits claims before those surfaces are activated.
- **Smallest enterprise-capable version**: Resolve identity for the initial Spec 414/415 resource types only; integrate with existing capture/upsert/claim guard; keep diagnostics bounded and internal; do not activate UI or implement compare/render/restore/certification.
- **Explicit non-goals**: No Coverage v2 operator dashboard, Evidence Overview conversion, Baseline Compare conversion, Customer Review Workspace conversion, Review Pack/report identity claims, restore readiness conversion, full Microsoft TCM catalog import, v1-to-v2 adapter, old gap taxonomy, or broad legacy removal.
- **Permanent complexity imported**: A bounded identity strategy registry, canonical identity resolver, secondary-key/diagnostics helpers, a key-kind value family, extra identity columns/JSONB metadata on existing Coverage v2 resource rows, and focused unit/feature/PostgreSQL tests.
- **Why now**: Spec 415 introduced concrete resource/evidence capture. Identity safety is the next prerequisite before any customer/operator proof, compare, render, restore, certification, or legacy cutover work can proceed safely.
- **Why not local**: Identity decisions are consumed by capture upsert, resource persistence, conflict handling, Claim Guard, provider scope enforcement, and future claim surfaces. Keeping this logic local to one capture method would preserve the exact drift this spec must eliminate.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: New status/key kind axis; new resolver/registry infrastructure; foundation-like language. Defense: `IdentityState` already exists from Spec 414, eight concrete resource types already exist, current capture/upsert already needs identity, and unsafe identity has direct claim-safety consequences.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve, with strict no-UI/no-customer-claim/no-legacy scope.
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view / environment-owned internal runtime. No rendered UI surface.
- **Primary Routes**: N/A - no routes, Filament pages, navigation, customer routes, downloads, reports, or restore surfaces are in scope.
- **Data Ownership**: Existing `tenant_configuration_resources` and `tenant_configuration_resource_evidence` remain environment-owned through `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id`. `tenant_configuration_resource_types` remains platform-seeded definition truth.
- **RBAC**: Identity evaluation runs inside already-authorized capture by default. If a separate re-evaluation command/job/start path is introduced, non-member workspace/environment access must be 404, member without capability must be 403, and readonly users must not start evaluation.
## No Legacy / No Backward Compatibility Constraint *(mandatory)*
TenantPilot is pre-production unless this spec explicitly records a compatibility exception.
- **Compatibility posture**: canonical replacement / no compatibility exception.
- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no.
- **Why clean replacement is safe now**: Coverage v2 is inactive and not customer-facing. No production/customer data or external contract requires v1/v2 compatibility. This spec must not dual-write v1/v2 identity, promote old snapshots into v2 proof, or translate old gap taxonomy into v2 runtime truth.
## UI Surface Impact *(mandatory - UI-COV-001)*
Does this spec add, remove, rename, or materially change any reachable UI surface?
- [x] No UI surface impact
- [ ] 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
- [ ] Status/evidence/review presentation changed
- [ ] Workspace/environment context presentation changed
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)*
N/A - no reachable UI surface impact. This slice is internal runtime/data/claim-safety work only. If implementation needs any UI file, route, navigation entry, customer/report/review/evidence surface, or restore readiness surface, implementation must stop and update `spec.md`, `plan.md`, and `tasks.md` first.
## Product Surface Impact *(mandatory for UI-affecting specs; otherwise write `N/A - no rendered product surface changed` plus rationale)*
Reference: `docs/product/standards/product-surface-contract.md`.
- **Product Surface Contract applies?**: no rendered product surface changed; only the no-UI/no-customer-claim posture applies.
- **Page archetype**: N/A.
- **Primary user question**: N/A.
- **Primary action**: N/A.
- **Surface budget result**: N/A.
- **Technical Annex / deep-link demotion**: N/A for UI. Raw payloads, source keys, diagnostics, and provider identifiers remain internal persistence/support data and must not become default-visible output in this spec.
- **Canonical status vocabulary**: N/A for UI. Internal identity states are not product-facing status vocabulary in this slice.
- **Visible complexity impact**: neutral.
- **Product Surface exceptions**: none.
## Browser Verification Plan *(mandatory)*
- **Browser proof required?**: no.
- **No-browser rationale**: `N/A - no rendered UI surface changed`.
- **Focused path when required**: N/A.
- **Primary interaction to execute**: N/A.
- **Console, Livewire, Filament, network, and 500-error checks**: N/A.
- **Full-suite failure triage**: N/A unless scope is amended to UI.
## Human Product Sanity Check *(mandatory)*
- **Required?**: no.
- **No-human-sanity rationale**: N/A - no product surface changed.
- **Reviewer questions**: N/A.
- **Planned result location**: implementation report records no-surface rationale.
## Product Surface Merge Gate Checklist *(mandatory)*
- [x] No-legacy posture or approved exception recorded.
- [x] Product Surface Impact is completed or `N/A` is justified.
- [x] Browser proof is completed or `N/A - no rendered UI surface changed` is justified.
- [x] Human Product Sanity is completed or not applicable with rationale.
- [x] Product Surface exceptions are documented or `none`.
- [x] Implementation report will state Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, visible complexity outcome, and no completed-spec rewrite assertion.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes, internal claim/evidence/capture semantics only.
- **Interaction class(es)**: evidence-backed claim safety; no rendered notifications/status UI/action links.
- **Systems touched**: TenantConfiguration capture/upsert, Claim Guard, evidence/resource persistence, OperationRun execution context only if evaluation is separated from existing capture.
- **Existing pattern(s) to extend**: existing `ClaimGuard`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `GenericContentEvidenceCaptureService`, `CoveragePayloadRedactor`, and OperationRun service patterns.
- **Shared contract / presenter / builder / renderer to reuse**: no UI presenter/renderer; use existing OperationRun lifecycle and capture service path where relevant.
- **Why the existing shared path is sufficient or insufficient**: Existing capture and Claim Guard are the right integration points, but current upsert lacks strategy-driven canonical identity and safe conflict diagnostics.
- **Allowed deviation and why**: none for UI/customer output. Bounded identity services are allowed because identity safety must be reused across capture upsert, conflict evaluation, and claim blocking.
- **Consistency impact**: Identity state must remain the only internal identity-safety axis; no old v1 gap reason or display-name-only stable concept may be introduced.
- **Review focus**: Verify no page-local or capture-local identity shortcut bypasses the canonical resolver/evaluator.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no new UX. Existing Spec 415 capture OperationRuns may execute identity evaluation as part of capture.
- **Shared OperationRun UX contract/layer reused**: N/A unless a separate re-evaluation start path is introduced.
- **Delegated start/completion UX behaviors**: N/A.
- **Local surface-owned behavior that remains**: none.
- **Queued DB-notification policy**: N/A; no new queued DB notifications.
- **Terminal notification path**: existing central lifecycle mechanism only if an OperationRun-backed evaluation job is added.
- **Exception required?**: none.
If implementation adds `tenant_configuration.identity_evaluation` or another new operation type, it must use `OperationRunService` for lifecycle transitions, numeric-only summary counts, no raw payloads in context/messages, no custom terminal DB notifications, no local UX composition, and focused authorization tests for capability access, readonly denial, wrong-scope 404, and missing-capability 403.
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: yes.
- **Boundary classification**: mixed. Canonical identity state/key kind and workspace/environment/provider ownership are platform-core; Graph/TCM field extraction and source ID names are provider-owned source metadata.
- **Seams affected**: TenantConfiguration resource persistence, resource-type strategy metadata, capture upsert, provider connection scope validation, Claim Guard.
- **Neutral platform terms preserved or introduced**: provider connection, managed environment, resource type, canonical key kind, identity state, claim state.
- **Provider-specific semantics retained and why**: Microsoft Graph/TCM source fields are retained only inside per-resource-type identity strategies and source metadata because Microsoft is the current concrete provider.
- **Why this does not deepen provider coupling accidentally**: Provider-specific fields do not become platform ownership truth; `workspace_id`, `managed_environment_id`, and `provider_connection_id` remain the internal scope boundary.
- **Follow-up path**: none for this slice; additional provider/resource strategies are future specs.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
N/A - no operator-facing surface change.
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
N/A - no operator-facing surface change.
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
N/A - no operator-facing surface change. Customer/read-only output must not expose identity diagnostics, source keys, raw payloads, provider request details, or OperationRun proof by default in this spec.
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
N/A - no operator-facing surface change.
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
N/A - no operator-facing surface change.
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes. Canonical resource identity becomes internal truth for captured Coverage v2 resources.
- **New persisted entity/table/artifact?**: no new table is expected. Existing `tenant_configuration_resources` should be extended where needed. A new identity-conflict table is out of scope unless implementation proves JSON diagnostics cannot satisfy current behavior.
- **New abstraction?**: yes. Bounded identity strategy/resolver/evaluator helpers.
- **New enum/state/reason family?**: yes. `IdentityState` already exists; a bounded canonical key-kind value family is expected.
- **New cross-domain UI framework/taxonomy?**: no.
- **Current operator problem**: Future operators must not review, compare, restore, report, or certify the wrong resource because the system silently matched by display name or weak identity.
- **Existing structure is insufficient because**: Current upsert extracts only `id`/`sourceId`, builds `canonical_resource_key`, and marks new resources stable. It cannot express missing IDs, derived identities, conflicts, beta/experimental identity, secondary diagnostic keys, or claim blocking by identity state.
- **Narrowest correct implementation**: Implement identity resolution only for the eight initial Coverage v2 resource types, integrate only with existing capture/upsert/Claim Guard, store bounded diagnostics on the resource row, and keep all UI/customer activation deferred.
- **Ownership cost**: Maintainers must preserve strategy definitions, identity tests, scope constraints, key-kind semantics, and redaction rules as new resource types are added.
- **Alternative intentionally rejected**: Keep `id`/`sourceId` hardcoded in `CoverageResourceUpserter` and fail when missing. Rejected because it overclaims stability for beta/fallback types, cannot diagnose conflicts, and blocks safe future activation.
- **Release truth**: Current-release foundation after completed Specs 414 and 415; no future-only UI behavior is introduced.
### Compatibility posture
This feature assumes a pre-production environment. Backward compatibility, legacy aliases, migration shims, historical fixtures, fallback readers, dual writes, and compatibility-specific tests are out of scope.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature, PostgreSQL-focused where migration/constraints/indexes require it. Browser N/A.
- **Validation lane(s)**: fast-feedback, confidence, pgsql for schema/index/constraint behavior, no browser unless scope is amended.
- **Why this classification and these lanes are sufficient**: Identity resolution and Claim Guard behavior are service-level business truth; persistence/upsert/scope/conflict behavior requires feature/database tests; UI/browser cannot prove this slice because no UI changes are allowed.
- **New or expanded test families**: Focused TenantConfiguration unit/feature tests only; no heavy-governance or browser family.
- **Fixture / helper cost impact**: Use existing TenantConfiguration factories and workspace/environment/provider setup. Any new factory state must remain opt-in and not make full provider/workspace setup a default for unrelated tests.
- **Heavy-family visibility / justification**: none.
- **Special surface test profile**: N/A.
- **Standard-native relief or required special coverage**: no browser proof required because no rendered UI changes.
- **Reviewer handoff**: Reviewers must verify lane fit, no hidden browser/heavy-governance cost, no broad fixture default, and exact focused commands in the implementation report.
- **Budget / baseline / trend impact**: none expected; document if identity tests materially expand TenantConfiguration lane runtime.
- **Escalation needed**: document-in-feature for contained identity test cost; follow-up-spec only if a broad static governance lane becomes necessary.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration`
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/TenantConfiguration`
- `git diff --check`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Resolve Captured Resource Identity Safely (Priority: P1)
As a platform engineer preparing Coverage v2 activation, I need every captured tenant configuration resource to resolve to a deterministic canonical identity state so future claims never rely on display name alone.
**Why this priority**: This is the minimum viable identity safety guarantee for all later Coverage v2 work.
**Independent Test**: Unit tests pass payload/resource-type/provider-scope examples into the resolver and assert stable, derived, missing, unsupported, and conflict outcomes without using display-name-only as stable truth.
**Acceptance Scenarios**:
1. **Given** a captured payload has a provider/Graph/TCM stable ID, **When** identity is resolved, **Then** the resource receives a deterministic canonical key, key kind, and `identity_state = stable`.
2. **Given** a captured payload has only allowed composite source fields, **When** identity is resolved, **Then** the resource receives `identity_state = derived` and claim behavior is limited unless explicitly allowed.
3. **Given** a captured payload has only a display name, **When** identity is resolved, **Then** it cannot be marked stable.
### User Story 2 - Prevent Unsafe Merges And False Claims (Priority: P1)
As a release reviewer, I need duplicate names, derived collisions, missing IDs, beta identity paths, and cross-provider/resource collisions to fail safe so Coverage v2 cannot silently merge the wrong records.
**Why this priority**: Wrong identity is worse than no identity because it can create false compare, restore, or audit claims.
**Independent Test**: Feature tests persist captured resources across same/different workspace, managed environment, provider connection, and resource type boundaries, then assert conflicts do not merge and unsafe states block claims.
**Acceptance Scenarios**:
1. **Given** two same-scope resources share a display name but have different stable IDs, **When** capture upserts them, **Then** they remain separate resources.
2. **Given** two same-scope resources produce the same unsafe derived key, **When** identity is evaluated, **Then** neither is silently chosen and unsafe claims are blocked.
3. **Given** the same source key appears under another workspace, managed environment, or provider connection, **When** identity is evaluated, **Then** resources do not collide across scope.
### User Story 3 - Keep Identity Diagnostics Useful And Safe (Priority: P2)
As a support/platform operator, I need identity diagnostics that explain why a resource is blocked without exposing secrets, raw payloads, tokens, or provider response dumps.
**Why this priority**: Diagnostics are necessary for later supportability, but raw evidence cannot become customer-safe proof or default output.
**Independent Test**: Unit/feature tests assert diagnostic fields include bounded reason/candidate metadata and redact configured secret keys.
**Acceptance Scenarios**:
1. **Given** identity cannot be resolved safely, **When** diagnostics are persisted, **Then** they include bounded reason metadata such as key kinds, missing fields, and candidate count.
2. **Given** payload/source metadata contains sensitive keys, **When** diagnostics and secondary keys are built, **Then** secrets/tokens/cookies/authorization values are redacted or omitted.
### User Story 4 - Preserve No-UI, No-Legacy Coverage v2 Boundaries (Priority: P1)
As a reviewer, I need proof that identity hardening does not activate Coverage v2 as customer/operator truth, revive v1 gap semantics, or add compatibility adapters.
**Why this priority**: Coverage v2 remains inactive until a later explicit activation/cutover spec.
**Independent Test**: Guard/feature tests assert no UI route/resource/view/navigation is added, no `tenant_id` ownership appears, no old gap taxonomy is emitted, and no v1-to-v2 adapter is introduced.
**Acceptance Scenarios**:
1. **Given** the implementation completes, **When** changed files are reviewed, **Then** no reachable UI surface or browser-visible Coverage v2 claim is added.
2. **Given** v1 baseline/evidence code still exists, **When** v2 identity services are inspected, **Then** old v1 gap terms such as `ambiguous_match` are not emitted as active v2 outcomes.
## Functional Requirements *(mandatory)*
- **FR-417-001**: The system MUST keep Coverage v2 inactive for customer-facing and operator-facing proof surfaces in this spec.
- **FR-417-002**: The system MUST define identity strategies for the initial resource types from Specs 414/415: `deviceAndAppManagementAssignmentFilter`, `deviceEnrollmentLimitRestriction`, `deviceEnrollmentPlatformRestriction`, `deviceEnrollmentStatusPageWindows10`, `appProtectionPolicyAndroid`, `appProtectionPolicyiOS`, `notificationMessageTemplate`, and `roleScopeTag`.
- **FR-417-003**: Each identity strategy MUST define preferred identity fields, fallback identity fields, display/secondary fields, whether provider connection scope is required, whether derived identity is allowed, whether experimental identity is allowed, and claim behavior for derived/conflict states.
- **FR-417-004**: Identity resolution MUST prefer immutable provider/Graph/TCM IDs, then documented provider-native stable IDs, then documented source identity, then source composite, then derived composite only when explicitly allowed.
- **FR-417-005**: Display name, name, label, or other human-readable text alone MUST NOT produce stable identity.
- **FR-417-006**: Identity resolution MUST produce one of the existing identity states: `stable`, `derived`, `identity_conflict`, `missing_external_id`, or `unsupported_identity`.
- **FR-417-007**: Identity resolution MUST produce a canonical key-kind value such as provider external ID, Graph object ID, TCM resource identifier, source composite, derived composite, experimental source key, or unsupported. Forbidden stable key kinds include display-name-only and name-only.
- **FR-417-008**: The implementation MUST use the existing `canonical_resource_key` column as the canonical identity key. Replacing it is out of scope unless implementation stops and updates `spec.md`, `plan.md`, and `tasks.md` with the reason existing storage cannot serve and a single-key migration plan. It MUST NOT persist duplicate canonical key truths such as both `canonical_resource_key` and `canonical_key` with competing semantics.
- **FR-417-009**: Existing resource rows MUST be extended with the minimum additional identity fields needed for key kind, external/source identity, strategy identifier, secondary keys, redacted diagnostics, evaluation timestamp, and optional tombstone timestamp.
- **FR-417-010**: Secondary keys MAY include display name, name, provider tenant/directory metadata, app/object IDs, UPN/mail, scope tag, assignment target, platform, policy family, source endpoint/version, or schema version, but they MUST remain diagnostic metadata only.
- **FR-417-011**: Identity diagnostics MUST be bounded and redacted. They MUST NOT persist raw provider payloads, tokens, secrets, authorization headers, cookies, private keys, certificates, passwords, full provider response dumps, or unredacted PII.
- **FR-417-012**: Identity uniqueness and conflict detection MUST be scoped by `workspace_id`, `managed_environment_id`, `provider_connection_id`, `resource_type_id`, and canonical identity key.
- **FR-417-013**: Provider connections used for identity evaluation MUST belong to the same workspace and managed environment as the resource being evaluated.
- **FR-417-014**: Cross-workspace, cross-managed-environment, and cross-provider identity collisions MUST NOT merge resources.
- **FR-417-015**: Rename behavior MUST preserve the same resource row when a stable provider identity remains the same and only display metadata changes.
- **FR-417-016**: Derived identity rename behavior MUST remain limited or blocked according to Claim Guard and MUST NOT silently certify the resource.
- **FR-417-017**: Missing expected stable external IDs MUST produce `missing_external_id` or `derived` according to strategy; they MUST NOT default to stable.
- **FR-417-018**: Unsupported resource identity MUST produce `unsupported_identity` and block customer-facing claims.
- **FR-417-019**: Beta/experimental identity paths MUST remain internal-only or claim-blocked by default and MUST NOT certify by default.
- **FR-417-020**: `CoverageResourceUpserter` or its repo-equivalent MUST consume canonical identity resolver output and MUST NOT upsert by display name only, first candidate, latest candidate, or old v1 subject resolver output.
- **FR-417-021**: Claim Guard MUST block customer-facing claims when identity state is `identity_conflict`, `missing_external_id`, or `unsupported_identity`, and MUST limit or block `derived` identity unless the supported scope explicitly permits derived identity.
- **FR-417-022**: Full missing/deleted-resource drift workflow is out of scope. If a tombstone field is added, it MUST preserve last known canonical identity and MUST NOT make tombstoned rows active proof.
- **FR-417-023**: The implementation MUST NOT introduce `tenant_id` as Coverage v2 ownership truth, compatibility alias, dual-write target, fallback reader, or parallel scope key.
- **FR-417-024**: The implementation MUST NOT add a v1-to-v2 identity adapter, old snapshot identity promotion, old gap taxonomy, fallback-to-latest evidence behavior, or dual customer-facing truth.
- **FR-417-025**: No Filament Resource/Page, Blade view, route, navigation entry, customer report/review/evidence surface, restore readiness surface, browser-visible identity warning, or Coverage v2 activation may be added in this spec.
## Key Entities / Data Truth *(include if feature involves data)*
- **Tenant Configuration Resource Type**: Platform-seeded definition of a Coverage v2 resource type, source class, support defaults, and identity strategy metadata.
- **Tenant Configuration Resource**: Environment-owned concrete Coverage v2 resource observed in one workspace, managed environment, provider connection, and resource type. It is the canonical internal identity holder for captured resources.
- **Tenant Configuration Resource Evidence**: Append-only payload evidence linked to a resource, OperationRun, provider connection, and source metadata. It remains evidence payload truth, not identity strategy truth.
- **Canonical Identity Strategy**: Bounded per-resource-type rule set for preferred IDs, fallbacks, secondary fields, derived allowance, experimental behavior, and claim consequences.
- **Canonical Identity Result**: Resolver output containing identity state, canonical resource key, key kind, source/external identity, secondary keys, diagnostics, and claim consequence hints.
Truth separation:
- **Execution truth**: `OperationRun` for capture/evaluation execution lifecycle only.
- **Artifact/evidence truth**: `tenant_configuration_resource_evidence` append-only payload rows.
- **Resource identity truth**: `tenant_configuration_resources` canonical identity fields.
- **Claim truth**: Claim Guard output derived from identity, coverage, evidence, support, and scope gates.
- **Operator next action**: out of scope until a later UI/customer-output activation spec.
## Success Criteria *(mandatory)*
- **SC-417-001**: Stable provider/Graph/TCM IDs produce stable deterministic identity scoped by workspace, managed environment, provider connection, and resource type.
- **SC-417-002**: Display-name-only payloads never become stable identity.
- **SC-417-003**: Duplicate display names with different stable IDs remain separate resources.
- **SC-417-004**: Duplicate unsafe derived identities produce conflict/blocked behavior instead of silent merge.
- **SC-417-005**: Claim Guard blocks or limits claims for unsafe identity states.
- **SC-417-006**: Diagnostics are actionable and redacted.
- **SC-417-007**: No `tenant_id`, v1 adapter, old gap taxonomy, fallback reader, dual write, or UI activation is introduced.
- **SC-417-008**: Focused unit, feature, PostgreSQL where applicable, Pint, and `git diff --check` validation pass or exact failures are documented.
## Assumptions *(mandatory)*
- Specs 414 and 415 are completed/validated and remain read-only context.
- Current Coverage v2 remains inactive and not customer/operator-facing.
- Existing `tenant_configuration_resources.canonical_resource_key` is the repo-real canonical key storage for this scope. Any replacement requires a spec/plan/tasks amendment before implementation continues.
- The eight initial resource types from Specs 414/415 are enough to justify a bounded strategy registry/resolver.
- Existing `CoveragePayloadRedactor` can be reused or extended for identity diagnostics and secondary keys.
- No production data or external API contract requires legacy compatibility.
## Risks *(mandatory)*
| Risk | Severity | Mitigation |
| --- | ---: | --- |
| Display name accidentally becomes stable truth | High | Resolver rules, static/feature guards, no display-name-only key kind |
| Cross-provider or cross-environment merge | High | Scope-aware uniqueness and same-scope provider tests |
| Identity diagnostics leak sensitive data | High | Redaction builder and tests for secret keys |
| Derived identity is overtrusted | High | Claim Guard blocks/limits derived unless explicitly allowed |
| New identity layer becomes too generic | Medium | Limit strategies to eight concrete resource types and current capture path |
| UI/customer activation sneaks in | Medium | No-UI guard tests and Product Surface stop condition |
| Existing `canonical_resource_key` vs draft `canonical_key` creates duplicate truth | Medium | Use one canonical field; no competing persisted key semantics |
## Open Questions *(must be resolved before implementation if blocking)*
No blocking product questions remain for the preparation scope.
Non-blocking implementation checks:
- Confirm during implementation whether a focused additive migration or an in-place pre-production migration adjustment is preferred for the identity columns.
- Confirm exact Graph/TCM ID field names per contract/resource type from repo-real payloads and tests; strategy defaults may use current contract metadata where available.
- Confirm whether tombstone persistence is required in this slice or whether it remains field-ready/deferred until drift/delete workflow.
## Follow-up Spec Candidates *(out of scope for Spec 417)*
- Coverage v2 Operator Surface / internal diagnostics UI.
- Legacy Coverage v1 cutover and removal.
- Intune core comparable/renderable pack.
- Certified Intune core coverage pack.
- Restore/readiness identity integration.
- Customer-facing identity warnings and review/report identity claims.
- Full Microsoft TCM catalog identity mapping.

View File

@ -0,0 +1,102 @@
# Tasks: Spec 417 - Canonical Identity Engine
**Input**: `specs/417-canonical-identity-engine/spec.md`, `specs/417-canonical-identity-engine/plan.md`, `specs/417-canonical-identity-engine/checklists/requirements.md`
**Prerequisites**: completed Specs 414 and 415 as read-only context
**Tests**: Required. Runtime/data/security behavior must be covered with focused Pest unit, feature, and PostgreSQL-lane tests. Browser tests are N/A unless scope is amended to rendered UI.
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for changed identity behavior.
- [x] New or changed tests stay in Unit/Feature/PostgreSQL lanes; any heavy-governance or browser addition requires spec amendment.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default and opt-in.
- [x] Planned validation commands cover the change without pulling unrelated lane cost.
- [x] Browser proof is `N/A - no rendered UI surface changed` unless spec/plan/tasks are amended first.
- [x] Human Product Sanity is N/A because no product surface changes.
- [x] Material budget, baseline, trend, or escalation notes are recorded in the implementation report if test cost changes.
## Phase 1: Preflight And Guardrails
- [x] T001 Capture branch, HEAD, and `git status --short` in `specs/417-canonical-identity-engine/implementation-report.md`.
- [x] T002 Confirm `specs/414-tcm-first-coverage-core-cutover/` and `specs/415-generic-content-backed-capture/` are completed/validated context only; do not modify their artifacts.
- [x] T003 Inspect current `apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php`, `CoverageEvidenceWriter.php`, `GenericContentEvidenceCaptureService.php`, `ClaimGuard.php`, and `ResourceTypeRegistry.php` before implementation.
- [x] T004 Inspect current `tenant_configuration_resources`, `tenant_configuration_resource_evidence`, and `tenant_configuration_resource_types` schema before deciding whether to add a new migration or make an approved pre-production schema adjustment.
- [x] T005 Confirm the implementation uses exactly one persisted canonical key truth via existing `canonical_resource_key`; if replacement appears necessary, stop and amend `spec.md`, `plan.md`, and `tasks.md` before continuing.
- [x] T006 Confirm no UI, route, navigation, customer output, review/report/evidence page, restore readiness surface, or browser-visible Coverage v2 activation is in scope.
## Phase 2: Tests First - Identity Strategy And Resolver
- [x] T007 Add unit tests under `apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php` for the eight initial resource types and their preferred/fallback/display fields, provider connection scope requirement, derived allowance, experimental allowance, and claim behavior.
- [x] T008 Add unit tests under `apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CanonicalIdentityResolverTest.php` proving stable provider/Graph/TCM ID, source composite, derived composite, missing external ID, unsupported identity, beta experimental identity, and display-name-only rejection.
- [x] T009 Add unit tests under `apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageSecondaryKeyBuilderTest.php` proving secondary keys are diagnostic metadata only and do not authorize, scope, or stabilize identity.
- [x] T010 Add unit tests under `apps/platform/tests/Unit/Support/TenantConfiguration/Spec417IdentityConflictDiagnosticsTest.php` proving diagnostics are bounded and redacted.
- [x] T011 Add or extend unit tests for `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php` proving `identity_conflict`, `missing_external_id`, and `unsupported_identity` block customer claims and `derived` is limited/blocked unless explicitly allowed.
## Phase 3: Tests First - Persistence, Scope, And No-Legacy Behavior
- [x] T012 Add feature tests under `apps/platform/tests/Feature/TenantConfiguration/Spec417CanonicalIdentityPersistenceTest.php` proving identity fields persist on captured resources and no duplicate canonical key truth appears.
- [x] T013 Add feature tests under `apps/platform/tests/Feature/TenantConfiguration/Spec417CoverageResourceIdentityUpsertTest.php` proving stable ID rename updates the same resource, duplicate display names with different stable IDs stay separate, and display-name-only payloads never become stable.
- [x] T014 Add feature tests under `apps/platform/tests/Feature/TenantConfiguration/Spec417IdentityConflictScopeTest.php` proving same-scope unsafe collisions conflict, while cross-workspace, cross-managed-environment, and cross-provider resources never merge.
- [x] T015 Add feature tests under `apps/platform/tests/Feature/TenantConfiguration/Spec417IdentityClaimGuardFeatureTest.php` proving unsafe identity states block or limit claim state during capture/upsert.
- [x] T016 Add or extend `apps/platform/tests/Feature/TenantConfiguration/Spec415NoLegacyNoUiActivationTest.php` or create `Spec417IdentityNoLegacyNoUiActivationTest.php` proving no UI files/routes/navigation are added, no `tenant_id` ownership appears, no old v1 gap taxonomy is emitted, and no v1-to-v2 adapter exists.
- [x] T017 Add feature coverage for tombstone behavior if implemented, proving last canonical identity is preserved and tombstoned resources do not become active proof; otherwise document tombstone deferral in the implementation report. Completed by documenting tombstone deferral; no tombstone behavior or field is implemented in this slice.
- [x] T018 Add PostgreSQL-lane coverage for any new identity indexes, check constraints, JSONB columns, or composite uniqueness/foreign-key behavior if SQLite cannot prove them.
## Phase 4: Identity Persistence
- [x] T019 Add a focused migration under `apps/platform/database/migrations/` for Spec 417 identity fields, unless implementation explicitly documents an approved pre-production adjustment to the existing 415 migration.
- [x] T020 Extend `tenant_configuration_resources` with the minimum necessary identity fields: key kind, canonical external/source identity if needed, strategy identifier, secondary keys JSONB, source identity JSONB, diagnostics JSONB, evaluated timestamp, and optional tombstone timestamp.
- [x] T021 Add targeted indexes for proven query paths: scope/type/canonical key, scope/type/identity state, and resource type/key kind where needed; avoid speculative JSONB indexes.
- [x] T022 Update `apps/platform/app/Models/TenantConfigurationResource.php` casts for new JSONB/datetime/enum fields.
- [x] T023 Update `apps/platform/database/factories/TenantConfigurationResourceFactory.php` with opt-in Spec 417 identity states without broadening unrelated defaults.
## Phase 5: Identity Strategy And Resolver Implementation
- [x] T024 Add `apps/platform/app/Support/TenantConfiguration/CanonicalKeyKind.php` with bounded key-kind values and no display-name/name-only stable key kind.
- [x] T025 Add `apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php` with strategies for the eight initial resource types.
- [x] T026 Add `apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResolver.php` implementing the preferred ID -> stable source ID -> source composite -> derived composite -> conflict/missing/unsupported hierarchy.
- [x] T027 Add `apps/platform/app/Services/TenantConfiguration/CoverageSecondaryKeyBuilder.php` reusing or extending `CoveragePayloadRedactor` for redacted diagnostic metadata.
- [x] T028 Add `apps/platform/app/Services/TenantConfiguration/IdentityConflictDiagnosticsBuilder.php` for bounded candidate/reason/missing-field diagnostics without raw payloads or secrets.
- [x] T029 Add `apps/platform/app/Services/TenantConfiguration/CoverageResourceIdentityEvaluator.php` or a narrower repo-equivalent to detect same-scope unsafe collisions and assign claim-safe identity states.
- [x] T030 Extend `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php` metadata only where needed for strategy defaults; do not introduce a broad provider framework. No `ResourceTypeRegistry` metadata change was needed; the bounded strategy registry owns identity defaults.
## Phase 6: Capture, Upsert, And Claim Guard Integration
- [x] T031 Update `apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php` to require canonical identity resolver output and stop upserting by `id`/`sourceId` shortcut alone.
- [x] T032 Ensure stable identities upsert exact same-scope resources, derived identities upsert only when unique and allowed, and unsafe identities persist blocked/diagnostic state instead of throwing away actionable proof.
- [x] T033 Ensure duplicate display names do not merge and first/latest/fallback candidate behavior is not introduced.
- [x] T034 Update `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php` so latest identity/claim state follows resolver/evaluator output rather than resource-type defaults alone.
- [x] T035 Update `apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php` only as needed to pass normalized/source payload data into identity resolution without adding new provider calls. No code change was needed; the existing capture path already passes raw payload and source metadata into upsert.
- [x] T036 Update `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php` signature or call path to account for identity state while preserving existing coverage/source/restore behavior.
- [x] T037 If a distinct identity re-evaluation command/job is introduced, add OperationRun type/catalog support, service-owned lifecycle, numeric-only summary counts, no raw payload context, no custom terminal notifications, and focused tests for lifecycle, scope, idempotency, capability access, readonly denial, wrong-scope 404, and missing-capability 403. Otherwise document `No new OperationRun UX impact`.
## Phase 7: No-Legacy, Redaction, And Product Surface Guards
- [x] T038 Add a guard/static test proving no `tenant_id` appears as Coverage v2 identity ownership truth in new migrations/models/services.
- [x] T039 Add a guard/static test proving active v2 identity code does not emit old gap terms such as `ambiguous_match`, `policy_record_missing`, `foundation_not_policy_backed`, `meta_fallback`, `raw_gap_count`, or `primary_gap_count`.
- [x] T040 Add a guard/static test proving no v1 subject resolver, old snapshot identity promotion, v1-to-v2 adapter, fallback reader, or dual-write path is introduced.
- [x] T041 Add a no-UI guard proving no Filament Resource/Page, Blade view, route, navigation entry, customer report/review/evidence surface, restore readiness surface, or browser-visible Coverage v2 activation was added.
- [x] T042 Verify diagnostics and OperationRun/audit metadata do not persist raw payloads, tokens, secrets, cookies, authorization headers, private keys, certificates, or full provider response dumps.
## Phase 8: Validation And Close-Out
- [x] T043 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- [x] T044 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration`.
- [x] T045 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration`.
- [x] T046 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/TenantConfiguration` if migrations/indexes/constraints/JSONB changed.
- [x] T047 Run `git diff --check`.
- [x] T048 Complete `specs/417-canonical-identity-engine/implementation-report.md` with candidate gate, dirty state before/after, files changed, identity strategy matrix, identity schema changes, Claim Guard integration, capture/upsert integration, conflict diagnostics, RBAC/scope proof, redaction proof, no-tenant_id, no-legacy/no-dual-truth, tests run, browser/no-browser decision, Livewire v4, provider registration, global search, destructive/high-impact actions, asset strategy, deployment impact, and deferred work.
- [x] T049 Confirm no completed historical spec was rewritten or stripped of close-out, validation, task, smoke, browser, or review history.
## Stop Conditions
Stop and update `spec.md`, `plan.md`, and `tasks.md` before continuing if any of these appear:
- A reachable UI surface, route, navigation entry, report/review/customer output, restore readiness surface, or browser-visible Coverage v2 activation is needed.
- `tenant_id` is introduced as Coverage v2 ownership truth.
- Display-name-only identity can become stable.
- Cross-workspace, cross-managed-environment, or cross-provider resources can merge.
- Identity conflicts do not block or limit claims.
- Old v1 gap taxonomy or v1 subject matching becomes active v2 identity truth.
- A v1-to-v2 adapter, fallback reader, dual write, fallback-to-latest evidence path, or old snapshot promotion is added.
- Raw provider/evidence payloads, secrets, credentials, tokens, or unredacted PII enter diagnostics, OperationRun context/messages, audit metadata, or customer output.