*/ private const CONTRACT_KEYS = [ 'conditionalAccessPolicy' => 'conditionalAccessPolicy', 'securityDefaults' => 'securityDefaults', 'deviceAndAppManagementAssignmentFilter' => 'assignmentFilter', 'notificationMessageTemplate' => 'notificationMessageTemplate', 'roleScopeTag' => 'roleScopeTag', ]; /** * Out-of-scope catalog rows that have a bounded, explicit source contract. * * @var list */ private const EXPLICIT_OUT_OF_SCOPE_CAPTURE_TYPES = [ 'conditionalAccessPolicy', ]; /** * Resource types that must fail closed until a production-safe source contract exists. * * @var list */ private const MISSING_SOURCE_CONTRACT_TYPES = [ 'dlpCompliancePolicy', ]; /** * Source-contract review targets that have safe helper/identity metadata but no verified repo adapter. * * @var list */ private const EXCHANGE_TEAMS_REVIEWED_CONTRACT_TYPES = [ 'transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', ]; private readonly ExchangePowerShellCommandContracts $exchangePowerShellContracts; public function __construct( private readonly GraphContractRegistry $contracts, ?ExchangePowerShellCommandContracts $exchangePowerShellContracts = null, ) { $this->exchangePowerShellContracts = $exchangePowerShellContracts ?? new ExchangePowerShellCommandContracts; } public function resolve(TenantConfigurationResourceType $resourceType, bool $allowBetaCapture = false): CoverageSourceContractDecision { $canonicalType = (string) $resourceType->canonical_type; $sourceClass = $resourceType->source_class instanceof SourceClass ? $resourceType->source_class : SourceClass::tryFrom((string) $resourceType->source_class); $supportState = $resourceType->support_state instanceof SupportState ? $resourceType->support_state : SupportState::tryFrom((string) $resourceType->support_state); $contractKey = self::CONTRACT_KEYS[$canonicalType] ?? null; if (! is_string($contractKey) || $contractKey === '') { if ($this->exchangePowerShellContracts->hasContractForCanonicalType($canonicalType)) { return $this->verifiedExchangePowerShellAdapterContract($canonicalType, $sourceClass, $supportState); } if (in_array($canonicalType, self::EXCHANGE_TEAMS_REVIEWED_CONTRACT_TYPES, true)) { return $this->blockedReviewedSourceContract($canonicalType, $sourceClass, $supportState); } if (in_array($canonicalType, self::MISSING_SOURCE_CONTRACT_TYPES, true)) { return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping'); } if (in_array($supportState, [SupportState::Unsupported, SupportState::OutOfScope], true)) { return $this->blocked($canonicalType, CaptureOutcome::BlockedUnsupported, 'resource_type_unsupported'); } return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping'); } if ($sourceClass === SourceClass::GraphBetaExperimental && ! $allowBetaCapture) { return $this->blocked($canonicalType, CaptureOutcome::BlockedBeta, 'beta_capture_disabled'); } if (in_array($supportState, [SupportState::Unsupported, SupportState::OutOfScope], true) && ! in_array($canonicalType, self::EXPLICIT_OUT_OF_SCOPE_CAPTURE_TYPES, true) ) { return $this->blocked($canonicalType, CaptureOutcome::BlockedUnsupported, 'resource_type_unsupported'); } $contract = $this->contracts->get($contractKey); $resource = is_string($contract['resource'] ?? null) ? trim((string) $contract['resource']) : ''; if ($contract === [] || $resource === '') { return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_graph_contract_resource'); } $sourceVersion = $this->sourceVersion($contract); $sourceSchemaHash = $this->sourceSchemaHash($contract); $metadata = [ 'source_contract_key' => $contractKey, 'source_endpoint' => '/'.ltrim($resource, '/'), 'source_class' => $sourceClass?->value, 'registry_source_class' => $sourceClass?->value, 'support_state' => $supportState?->value, 'registry_support_state' => $supportState?->value, 'source_version' => $sourceVersion, 'source_schema_hash' => $sourceSchemaHash, 'source_schema_hash_available' => $sourceSchemaHash !== null, ]; return new CoverageSourceContractDecision( canonicalType: $canonicalType, outcome: CaptureOutcome::Captured, contractKey: $contractKey, sourceEndpoint: '/'.ltrim($resource, '/'), sourceVersion: $sourceVersion, sourceSchemaHash: $sourceSchemaHash, reasonCode: null, contract: $contract, sourceMetadata: array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== ''), ); } private function blocked(string $canonicalType, CaptureOutcome $outcome, string $reasonCode): CoverageSourceContractDecision { return new CoverageSourceContractDecision( canonicalType: $canonicalType, outcome: $outcome, reasonCode: $reasonCode, sourceMetadata: ['reason_code' => $reasonCode], ); } private function blockedReviewedSourceContract( string $canonicalType, ?SourceClass $sourceClass, ?SupportState $supportState, ): CoverageSourceContractDecision { $state = CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING; $metadata = [ 'reason_code' => $state, 'source_contract_state' => $state, 'contract_blocker_reason' => $state, 'capture_eligibility_state' => 'blocked', 'source_class' => $sourceClass?->value, 'registry_source_class' => $sourceClass?->value, 'support_state' => $supportState?->value, 'registry_support_state' => $supportState?->value, 'workload' => $this->exchangeTeamsWorkload($canonicalType), 'source_version' => 'not_verified', 'source_contract_name' => $this->exchangeTeamsContractName($canonicalType), 'source_contract_version' => 'review-2026-07-03', 'provider_adapter_state' => 'missing', 'provider_adapter_proof' => 'No repo provider adapter or explicit Graph contract exists for this source contract.', 'provider_calls_allowed' => false, 'evidence_promotion_allowed' => false, 'customer_claims_allowed' => false, 'restore_allowed' => false, 'certification_allowed' => false, 'not_certifiable' => true, 'not_customer_claimable' => true, 'permission_model' => $this->exchangeTeamsPermissionModel($canonicalType), 'response_shape' => $this->exchangeTeamsResponseShape($canonicalType), 'identity_handoff' => $this->exchangeTeamsIdentityHandoff($canonicalType), 'normalization_handoff' => $this->exchangeTeamsNormalizationHandoff($canonicalType), 'redaction_rules' => $this->exchangeTeamsRedactionRules($canonicalType), 'documentation_reference' => 'repo-proof: Spec 426 removed unverified Graph endpoint claims; Spec 427 keeps the target blocked until a repo adapter/source contract exists.', ]; return new CoverageSourceContractDecision( canonicalType: $canonicalType, outcome: CaptureOutcome::BlockedMissingContract, sourceVersion: 'not_verified', reasonCode: $state, sourceContractState: $state, sourceMetadata: array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== ''), ); } private function verifiedExchangePowerShellAdapterContract( string $canonicalType, ?SourceClass $sourceClass, ?SupportState $supportState, ): CoverageSourceContractDecision { $contract = $this->exchangePowerShellContracts->contractForCanonicalType($canonicalType); if ($contract === null) { return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping'); } $state = CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE; $contractKey = (string) $contract['contract_key']; $metadata = [ 'source_contract_key' => $contractKey, 'source_contract_name' => $contractKey, 'source_contract_state' => $state, 'contract_blocker_reason' => null, 'capture_eligibility_state' => 'pending_capture', 'source_class' => $sourceClass?->value, 'registry_source_class' => $sourceClass?->value, 'support_state' => $supportState?->value, 'registry_support_state' => $supportState?->value, 'workload' => 'exchange', 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, 'adapter_pattern' => ExchangePowerShellCommandContracts::ADAPTER_PATTERN, 'source_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, 'source_schema_hash' => $this->sourceSchemaHash($contract), 'source_schema_hash_available' => true, 'provider_adapter_state' => 'adapter_contract_available', 'provider_adapter_proof' => 'Spec 430 verifies a structured Exchange PowerShell command contract only; live execution is deferred.', 'provider_calls_allowed' => false, 'execution_enabled' => false, 'fake_runner_testable' => true, 'evidence_promotion_allowed' => false, 'customer_claims_allowed' => false, 'restore_allowed' => false, 'certification_allowed' => false, 'not_certifiable' => true, 'not_customer_claimable' => true, 'command_contract' => [ 'command_name' => $contract['command_name'], 'command_contract_version' => $contract['command_contract_version'], 'read_only' => $contract['read_only'], 'allowed_parameters' => $contract['allowed_parameters'], ], 'permission_model' => $contract['permission_model'], 'response_shape' => $contract['response_shape'], 'identity_handoff' => $contract['identity_handoff'], 'normalization_handoff' => $contract['normalization_handoff'], 'redaction_rules' => $contract['redaction_rules'], 'claim_boundary' => $contract['claim_boundary'], 'restore_tier' => $contract['restore_tier'], 'future_execution_scope' => $contract['future_execution_scope'], 'documentation_reference' => 'repo-proof: Spec 430 introduces a no-live-execution Exchange PowerShell adapter contract boundary for transportRule, remoteDomain, and inboundConnector.', ]; return new CoverageSourceContractDecision( canonicalType: $canonicalType, outcome: CaptureOutcome::BlockedMissingContract, contractKey: $contractKey, sourceVersion: ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, sourceSchemaHash: $this->sourceSchemaHash($contract), reasonCode: $state, sourceContractState: $state, contract: $contract, sourceMetadata: array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== ''), ); } private function exchangeTeamsWorkload(string $canonicalType): string { return match ($canonicalType) { 'transportRule', 'acceptedDomain' => 'exchange', 'appPermissionPolicy', 'meetingPolicy' => 'teams', default => 'microsoft_365', }; } private function exchangeTeamsContractName(string $canonicalType): string { return match ($canonicalType) { 'transportRule' => 'exchange.transportRule.source_contract_review', 'acceptedDomain' => 'exchange.acceptedDomain.source_contract_review', 'appPermissionPolicy' => 'teams.appPermissionPolicy.source_contract_review', 'meetingPolicy' => 'teams.meetingPolicy.source_contract_review', default => 'microsoft365.unknown.source_contract_review', }; } /** * @return array */ private function exchangeTeamsPermissionModel(string $canonicalType): array { return [ 'status' => 'not_productized', 'required_application_permissions' => [], 'delegated_permissions' => [], 'admin_consent_required' => true, 'least_privilege_note' => 'Blocked until a repo-owned source adapter and least-privilege permission path are productized.', 'permission_failure_mode' => 'block_without_provider_call', 'redacted_permission_context' => true, 'resource_type' => $canonicalType, ]; } /** * @return array */ private function exchangeTeamsResponseShape(string $canonicalType): array { return [ 'raw_payload_shape' => 'provider_collection_candidate', 'collection_semantics' => 'collection_candidate', 'pagination_model' => 'blocked_until_adapter_exists', 'collection_item_path' => 'value', 'pagination_cursor_path' => '@odata.nextLink', 'empty_response_meaning' => 'must_be_distinguished_by_future_adapter', 'permission_denied_response_meaning' => 'must_be_distinguished_by_future_adapter', 'unsupported_response_meaning' => 'must_be_distinguished_by_future_adapter', 'source_unavailable_response_meaning' => 'must_be_distinguished_by_future_adapter', 'malformed_response_meaning' => 'must_be_distinguished_by_future_adapter', 'response_shape_safety' => 'blocked_until_empty_denied_unsupported_unavailable_and_malformed_are_distinguishable', 'display_safe_fields' => $this->exchangeTeamsDisplayFields($canonicalType), 'sensitive_fields' => $this->exchangeTeamsSensitiveFields($canonicalType), 'volatile_fields' => ['@odata.context', '@odata.etag', 'createdDateTime', 'modifiedDateTime', 'whenChanged'], ]; } /** * @return array */ private function exchangeTeamsIdentityHandoff(string $canonicalType): array { return [ 'preferred_identity_fields' => $this->exchangeTeamsPreferredIdentityFields($canonicalType), 'fallback_identity_fields' => $this->exchangeTeamsFallbackIdentityFields($canonicalType), 'identity_stability_class' => 'stable_candidate', 'singleton_or_collection_identity_rule' => 'collection_item_identity_required', 'known_identity_risks' => [ 'display_name_not_stable', 'identity_field_can_be_display_like', 'order_and_payload_hash_not_stable_identity', ], 'display_name_is_stable_identity' => false, ]; } /** * @return array */ private function exchangeTeamsNormalizationHandoff(string $canonicalType): array { return [ 'resource_type' => $canonicalType, 'source_class' => SourceClass::Tcm->value, 'source_contract_name' => $this->exchangeTeamsContractName($canonicalType), 'source_version' => 'not_verified', 'raw_payload_shape' => 'provider_collection_candidate', 'expected_normalized_shape' => 'exchange_teams_comparable_payload', 'identity_fields' => [ ...$this->exchangeTeamsPreferredIdentityFields($canonicalType), ...$this->exchangeTeamsFallbackIdentityFields($canonicalType), ], 'volatile_fields' => ['@odata.context', '@odata.etag', 'createdDateTime', 'modifiedDateTime', 'whenChanged'], 'sensitive_fields' => $this->exchangeTeamsSensitiveFields($canonicalType), 'collection_item_path' => 'value', 'pagination_cursor_path' => '@odata.nextLink', ]; } /** * @return array */ private function exchangeTeamsRedactionRules(string $canonicalType): array { return [ 'raw_payload_default_visible' => false, 'permission_context_default_visible' => false, 'redacted_permission_context' => true, 'sensitive_fields' => $this->exchangeTeamsSensitiveFields($canonicalType), 'forbidden_default_output' => [ 'raw_provider_payload', 'provider_response', 'authorization_header', 'tokens', 'credentials', 'mail_content', 'teams_chat_message_file_recording_or_transcript_content', ], ]; } /** * @return list */ private function exchangeTeamsPreferredIdentityFields(string $canonicalType): array { return match ($canonicalType) { 'transportRule' => ['id', 'sourceId', 'Guid', 'RuleId'], 'acceptedDomain' => ['id', 'sourceId'], 'appPermissionPolicy', 'meetingPolicy' => ['id', 'sourceId', 'policyId'], default => ['id', 'sourceId'], }; } /** * @return list */ private function exchangeTeamsFallbackIdentityFields(string $canonicalType): array { return match ($canonicalType) { 'acceptedDomain' => ['DomainName', 'domainName'], default => [], }; } /** * @return list */ private function exchangeTeamsDisplayFields(string $canonicalType): array { return match ($canonicalType) { 'acceptedDomain' => ['DomainName', 'domainName', 'DisplayName', 'displayName', 'Name', 'name'], default => ['DisplayName', 'displayName', 'Name', 'name'], }; } /** * @return list */ private function exchangeTeamsSensitiveFields(string $canonicalType): array { $common = ['authorization', 'access_token', 'refresh_token', 'client_secret', 'password', 'credential', 'provider_response', 'raw_payload']; return match ($canonicalType) { 'transportRule' => [ ...$common, 'SubjectContainsWords', 'SubjectOrBodyContainsWords', 'BodyContainsWords', 'ApplyHtmlDisclaimerText', 'SetHeaderValue', ], 'meetingPolicy' => [ ...$common, 'RecordingContent', 'RecordingUrl', 'TranscriptContent', 'TranscriptText', 'ChatContent', ], default => $common, }; } /** * @param array $contract */ private function sourceVersion(array $contract): string { $version = $contract['version'] ?? $contract['graph_version'] ?? null; return is_string($version) && trim($version) !== '' ? trim($version) : 'v1.0'; } /** * @param array $contract */ private function sourceSchemaHash(array $contract): ?string { if ($contract === []) { return null; } if (isset($contract['command_name'])) { $schema = [ 'adapter_pattern' => $contract['adapter_pattern'] ?? null, 'allowed_parameters' => $contract['allowed_parameters'] ?? [], 'canonical_type' => $contract['canonical_type'] ?? null, 'command_name' => $contract['command_name'] ?? null, 'response_shape' => $contract['response_shape'] ?? [], 'source_surface' => $contract['source_surface'] ?? null, ]; return hash('sha256', $this->canonicalJson($schema)); } $schema = [ 'resource' => $contract['resource'] ?? null, 'allowed_select' => $contract['allowed_select'] ?? [], 'type_family' => $contract['type_family'] ?? null, ]; return hash('sha256', $this->canonicalJson($schema)); } /** * @param array $payload */ private function canonicalJson(array $payload): string { ksort($payload); return json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } }