From f7d06621a0e2730b17639627bc100feab7a89bbe Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 3 Jul 2026 11:43:11 +0000 Subject: [PATCH] feat: implement Exchange Teams evidence identity readiness (#493) Automated PR for spec 426 exchange teams core evidence identity readiness. Includes service changes and coverage/requirement/spec updates from commit fb4dc20c. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/493 --- .../CoverageIdentityStrategyRegistry.php | 56 ++ .../CoverageSourceContractResolver.php | 19 +- ...changeTeamsComparablePayloadNormalizer.php | 114 +++- .../GenericContentEvidenceCaptureService.php | 46 +- .../GenericPayloadNormalizer.php | 19 +- .../Spec420M365GenericEvidenceCaptureTest.php | 4 +- ...c426ExchangeTeamsClaimGuardFeatureTest.php | 32 ++ ...ExchangeTeamsCoreEvidenceReadinessTest.php | 174 +++++++ ...pec426ExchangeTeamsNoCertificationTest.php | 29 ++ ...Spec426ExchangeTeamsNoMiniPlatformTest.php | 26 + .../Spec426ExchangeTeamsNoRestoreTest.php | 35 ++ .../Spec426ExchangeTeamsNoTenantIdTest.php | 29 ++ .../Spec426ExchangeTeamsProviderScopeTest.php | 138 +++++ ...changeTeamsStableIdentityReadinessTest.php | 112 ++++ ...17CoverageIdentityStrategyRegistryTest.php | 5 + .../Spec420M365CaptureEligibilityTest.php | 4 +- ...0M365CaptureSourceContractResolverTest.php | 2 +- ...c426ExchangeTeamsCanonicalIdentityTest.php | 111 ++++ ...26ExchangeTeamsClaimGuardReadinessTest.php | 29 ++ .../Spec426ExchangeTeamsEvidenceHashTest.php | 39 ++ ...eamsPayloadNormalizationFromSourceTest.php | 172 ++++++ .../Spec426ExchangeTeamsRedactionTest.php | 27 + ...xchangeTeamsSourceContractResolverTest.php | 54 ++ .../checklists/requirements.md | 185 +++++++ .../implementation-report.md | 131 +++++ .../plan.md | 344 ++++++++++++ .../spec.md | 489 ++++++++++++++++++ .../tasks.md | 242 +++++++++ 28 files changed, 2639 insertions(+), 28 deletions(-) create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsClaimGuardFeatureTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoCertificationTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoMiniPlatformTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoRestoreTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoTenantIdTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsProviderScopeTest.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsStableIdentityReadinessTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsCanonicalIdentityTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsClaimGuardReadinessTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsEvidenceHashTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsPayloadNormalizationFromSourceTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsRedactionTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php create mode 100644 specs/426-exchange-teams-core-evidence-identity-readiness/checklists/requirements.md create mode 100644 specs/426-exchange-teams-core-evidence-identity-readiness/implementation-report.md create mode 100644 specs/426-exchange-teams-core-evidence-identity-readiness/plan.md create mode 100644 specs/426-exchange-teams-core-evidence-identity-readiness/spec.md create mode 100644 specs/426-exchange-teams-core-evidence-identity-readiness/tasks.md diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php b/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php index f874a40e..be46b290 100644 --- a/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php +++ b/apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php @@ -119,6 +119,62 @@ final class CoverageIdentityStrategyRegistry 'derived_claims_allowed' => false, 'stable_key_kind' => 'graph_object_id', ], + 'transportRule' => [ + 'strategy_identifier' => 'tcm.exchange.transport_rule.v1', + 'preferred_identity_fields' => ['id', 'sourceId', 'Guid', 'RuleId'], + 'fallback_identity_fields' => [], + 'source_composite_fields' => [], + 'derived_composite_fields' => [], + 'display_fields' => ['displayName', 'DisplayName', 'name', 'Name'], + 'secondary_fields' => ['Priority', 'priority', 'Mode', 'mode', 'State', 'state', 'source_metadata.source_contract_key', 'source_metadata.source_version'], + 'requires_provider_connection_scope' => true, + 'allows_derived_identity' => false, + 'allows_experimental_identity' => false, + 'derived_claims_allowed' => false, + 'stable_key_kind' => 'tcm_resource_identifier', + ], + 'acceptedDomain' => [ + 'strategy_identifier' => 'tcm.exchange.accepted_domain.v1', + 'preferred_identity_fields' => ['id', 'sourceId'], + 'fallback_identity_fields' => ['DomainName', 'domainName'], + 'source_composite_fields' => [], + 'derived_composite_fields' => [], + 'display_fields' => ['displayName', 'DisplayName', 'name', 'Name'], + 'secondary_fields' => ['DomainType', 'domainType', 'IsDefault', 'isDefault', 'State', 'state', 'source_metadata.source_contract_key', 'source_metadata.source_version'], + 'requires_provider_connection_scope' => true, + 'allows_derived_identity' => false, + 'allows_experimental_identity' => false, + 'derived_claims_allowed' => false, + 'stable_key_kind' => 'tcm_resource_identifier', + ], + 'appPermissionPolicy' => [ + 'strategy_identifier' => 'tcm.teams.app_permission_policy.v1', + 'preferred_identity_fields' => ['id', 'sourceId', 'policyId'], + 'fallback_identity_fields' => [], + 'source_composite_fields' => [], + 'derived_composite_fields' => [], + 'display_fields' => ['displayName', 'DisplayName', 'name', 'Name'], + 'secondary_fields' => ['GlobalCatalogAppsType', 'globalCatalogAppsType', 'PrivateCatalogAppsType', 'privateCatalogAppsType', 'source_metadata.source_contract_key', 'source_metadata.source_version'], + 'requires_provider_connection_scope' => true, + 'allows_derived_identity' => false, + 'allows_experimental_identity' => false, + 'derived_claims_allowed' => false, + 'stable_key_kind' => 'tcm_resource_identifier', + ], + 'meetingPolicy' => [ + 'strategy_identifier' => 'tcm.teams.meeting_policy.v1', + 'preferred_identity_fields' => ['id', 'sourceId', 'policyId'], + 'fallback_identity_fields' => [], + 'source_composite_fields' => [], + 'derived_composite_fields' => [], + 'display_fields' => ['displayName', 'DisplayName', 'name', 'Name'], + 'secondary_fields' => ['State', 'state', 'AutoAdmittedUsers', 'autoAdmittedUsers', 'source_metadata.source_contract_key', 'source_metadata.source_version'], + 'requires_provider_connection_scope' => true, + 'allows_derived_identity' => false, + 'allows_experimental_identity' => false, + 'derived_claims_allowed' => false, + 'stable_key_kind' => 'tcm_resource_identifier', + ], 'notificationMessageTemplate' => [ 'strategy_identifier' => 'graph.notification_message_template.v1', 'preferred_identity_fields' => ['id', 'templateId', 'sourceId'], diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php index 68055253..04c11eb4 100644 --- a/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php +++ b/apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php @@ -26,13 +26,24 @@ final class CoverageSourceContractResolver ]; /** - * Spec 420 first-pack types that must fail closed as missing source contracts. + * Out-of-scope catalog rows that have a bounded, explicit source contract. * * @var list */ - private const SPEC_420_MISSING_CONTRACT_TYPES = [ + 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 = [ + 'transportRule', 'acceptedDomain', 'appPermissionPolicy', + 'meetingPolicy', 'dlpCompliancePolicy', ]; @@ -53,7 +64,7 @@ public function resolve(TenantConfigurationResourceType $resourceType, bool $all $contractKey = self::CONTRACT_KEYS[$canonicalType] ?? null; if (! is_string($contractKey) || $contractKey === '') { - if (in_array($canonicalType, self::SPEC_420_MISSING_CONTRACT_TYPES, true)) { + if (in_array($canonicalType, self::MISSING_SOURCE_CONTRACT_TYPES, true)) { return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping'); } @@ -69,7 +80,7 @@ public function resolve(TenantConfigurationResourceType $resourceType, bool $all } if (in_array($supportState, [SupportState::Unsupported, SupportState::OutOfScope], true) - && $canonicalType !== 'conditionalAccessPolicy' + && ! in_array($canonicalType, self::EXPLICIT_OUT_OF_SCOPE_CAPTURE_TYPES, true) ) { return $this->blocked($canonicalType, CaptureOutcome::BlockedUnsupported, 'resource_type_unsupported'); } diff --git a/apps/platform/app/Services/TenantConfiguration/ExchangeTeamsComparablePayloadNormalizer.php b/apps/platform/app/Services/TenantConfiguration/ExchangeTeamsComparablePayloadNormalizer.php index 3e30b9a8..3f822373 100644 --- a/apps/platform/app/Services/TenantConfiguration/ExchangeTeamsComparablePayloadNormalizer.php +++ b/apps/platform/app/Services/TenantConfiguration/ExchangeTeamsComparablePayloadNormalizer.php @@ -56,12 +56,14 @@ final class ExchangeTeamsComparablePayloadNormalizer 'ExceptIfRecipientDomainIs', 'Exceptions', 'From', + 'Guid', 'Identity', 'Mode', 'Name', 'Priority', 'RecipientDomainIs', 'RedirectMessageTo', + 'RuleId', 'SenderDomainIs', 'SentTo', 'State', @@ -78,6 +80,7 @@ final class ExchangeTeamsComparablePayloadNormalizer 'name', 'priority', 'ruleMode', + 'sourceId', 'state', ], 'acceptedDomain' => [ @@ -101,6 +104,7 @@ final class ExchangeTeamsComparablePayloadNormalizer 'isDefault', 'modifiedDateTime', 'name', + 'sourceId', 'state', 'status', 'type', @@ -134,7 +138,9 @@ final class ExchangeTeamsComparablePayloadNormalizer 'mode', 'modifiedDateTime', 'name', + 'policyId', 'policyMode', + 'sourceId', 'targets', ], 'meetingPolicy' => [ @@ -182,8 +188,10 @@ final class ExchangeTeamsComparablePayloadNormalizer 'meetingRecordingExpirationDays', 'modifiedDateTime', 'name', + 'policyId', 'recordingTranscription', 'screenSharingMode', + 'sourceId', 'state', ], ]; @@ -249,9 +257,10 @@ public function supports(string $canonicalType): bool /** * @param array $payload + * @param list $additionalVolatileFields * @return array */ - public function normalize(string $canonicalType, array $payload): array + public function normalize(string $canonicalType, array $payload, array $additionalVolatileFields = []): array { if (! $this->supports($canonicalType)) { return [ @@ -265,13 +274,14 @@ public function normalize(string $canonicalType, array $payload): array ]; } - $redacted = $this->redactedPayload($payload); + $volatileFields = $this->mergedVolatileRootFields($additionalVolatileFields); + $redacted = $this->withoutVolatileRootFields($this->redactedPayload($payload), $volatileFields); return match ($canonicalType) { - 'transportRule' => $this->normalizeTransportRule($payload, $redacted), - 'acceptedDomain' => $this->normalizeAcceptedDomain($payload, $redacted), - 'appPermissionPolicy' => $this->normalizeAppPermissionPolicy($payload, $redacted), - 'meetingPolicy' => $this->normalizeMeetingPolicy($payload, $redacted), + 'transportRule' => $this->normalizeTransportRule($payload, $redacted, $volatileFields), + 'acceptedDomain' => $this->normalizeAcceptedDomain($payload, $redacted, $volatileFields), + 'appPermissionPolicy' => $this->normalizeAppPermissionPolicy($payload, $redacted, $volatileFields), + 'meetingPolicy' => $this->normalizeMeetingPolicy($payload, $redacted, $volatileFields), }; } @@ -288,7 +298,7 @@ public function volatileRootFields(): array * @param array $payload * @return array */ - private function normalizeTransportRule(array $rawPayload, array $payload): array + private function normalizeTransportRule(array $rawPayload, array $payload, array $volatileFields): array { return $this->sortAssociative([ 'canonical_type' => 'transportRule', @@ -315,7 +325,8 @@ private function normalizeTransportRule(array $rawPayload, array $payload): arra 'ExceptIfRecipientDomainIs', ]), 'source' => $this->sourceSummary($payload), - 'diagnostics' => $this->diagnostics('transportRule', $rawPayload, $payload), + 'source_identity' => $this->sourceIdentity($payload, ['id', 'sourceId', 'Guid', 'RuleId']), + 'diagnostics' => $this->diagnostics('transportRule', $rawPayload, $payload, $volatileFields), ]); } @@ -324,7 +335,7 @@ private function normalizeTransportRule(array $rawPayload, array $payload): arra * @param array $payload * @return array */ - private function normalizeAcceptedDomain(array $rawPayload, array $payload): array + private function normalizeAcceptedDomain(array $rawPayload, array $payload, array $volatileFields): array { return $this->sortAssociative([ 'canonical_type' => 'acceptedDomain', @@ -334,7 +345,8 @@ private function normalizeAcceptedDomain(array $rawPayload, array $payload): arr 'is_default' => $this->booleanString($this->firstScalar($payload, ['isDefault', 'IsDefault', 'default', 'Default'])), 'state' => $this->firstString($payload, ['state', 'State', 'status', 'Status']), 'source' => $this->sourceSummary($payload), - 'diagnostics' => $this->diagnostics('acceptedDomain', $rawPayload, $payload), + 'source_identity' => $this->sourceIdentity($payload, ['id', 'sourceId', 'DomainName', 'domainName']), + 'diagnostics' => $this->diagnostics('acceptedDomain', $rawPayload, $payload, $volatileFields), ]); } @@ -343,7 +355,7 @@ private function normalizeAcceptedDomain(array $rawPayload, array $payload): arr * @param array $payload * @return array */ - private function normalizeAppPermissionPolicy(array $rawPayload, array $payload): array + private function normalizeAppPermissionPolicy(array $rawPayload, array $payload, array $volatileFields): array { $globalCatalogMode = $this->firstString($payload, ['GlobalCatalogAppsType', 'globalCatalogAppsType']); $privateCatalogMode = $this->firstString($payload, ['PrivateCatalogAppsType', 'privateCatalogAppsType']); @@ -362,7 +374,8 @@ private function normalizeAppPermissionPolicy(array $rawPayload, array $payload) 'blocked_apps' => $this->appList($this->firstExisting($payload, ['blockedApps', 'BlockAppList', 'blockAppList', 'blockedAppIds'])), 'targets' => $this->settingGroup($payload, ['assignments', 'targets'], ['AssignedGroups', 'assignedGroups', 'AssignedUsers', 'assignedUsers']), 'source' => $this->sourceSummary($payload), - 'diagnostics' => $this->diagnostics('appPermissionPolicy', $rawPayload, $payload), + 'source_identity' => $this->sourceIdentity($payload, ['id', 'sourceId', 'policyId']), + 'diagnostics' => $this->diagnostics('appPermissionPolicy', $rawPayload, $payload, $volatileFields), ]); } @@ -371,7 +384,7 @@ private function normalizeAppPermissionPolicy(array $rawPayload, array $payload) * @param array $payload * @return array */ - private function normalizeMeetingPolicy(array $rawPayload, array $payload): array + private function normalizeMeetingPolicy(array $rawPayload, array $payload, array $volatileFields): array { return $this->sortAssociative([ 'canonical_type' => 'meetingPolicy', @@ -413,7 +426,8 @@ private function normalizeMeetingPolicy(array $rawPayload, array $payload): arra 'AllowParticipantGiveRequestControl', ]), 'source' => $this->sourceSummary($payload), - 'diagnostics' => $this->diagnostics('meetingPolicy', $rawPayload, $payload), + 'source_identity' => $this->sourceIdentity($payload, ['id', 'sourceId', 'policyId']), + 'diagnostics' => $this->diagnostics('meetingPolicy', $rawPayload, $payload, $volatileFields), ]); } @@ -429,6 +443,40 @@ private function redactedPayload(array $payload): array return $this->redactUnsafeContent($redacted); } + /** + * @param list $additionalVolatileFields + * @return list + */ + private function mergedVolatileRootFields(array $additionalVolatileFields): array + { + $fields = [ + ...self::VOLATILE_ROOT_FIELDS, + ...array_values(array_filter( + array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $additionalVolatileFields), + static fn (string $field): bool => $field !== '', + )), + ]; + + $fields = array_values(array_unique($fields)); + sort($fields, SORT_NATURAL | SORT_FLAG_CASE); + + return $fields; + } + + /** + * @param array $payload + * @param list $volatileFields + * @return array + */ + private function withoutVolatileRootFields(array $payload, array $volatileFields): array + { + foreach ($volatileFields as $field) { + unset($payload[$field]); + } + + return $payload; + } + private function redactUnsafeContent(mixed $value, string $path = ''): mixed { if (! is_array($value)) { @@ -587,6 +635,34 @@ private function firstExisting(array $payload, array $fields): mixed return null; } + /** + * @param list $fields + * @return array + */ + private function sourceIdentity(array $payload, array $fields): array + { + foreach ($fields as $field) { + $value = $this->firstScalar($payload, [$field]); + + if (! is_scalar($value)) { + continue; + } + + $value = trim((string) $value); + + if ($value === '') { + continue; + } + + return [ + 'field' => $field, + 'value' => $value, + ]; + } + + return []; + } + private function firstScalar(array $payload, array $fields): mixed { foreach ($fields as $field) { @@ -738,14 +814,15 @@ private function sourceSummary(array $payload): array /** * @param array $rawPayload * @param array $payload + * @param list $volatileFields * @return array> */ - private function diagnostics(string $canonicalType, array $rawPayload, array $payload): array + private function diagnostics(string $canonicalType, array $rawPayload, array $payload, array $volatileFields): array { return [ 'unsupported_fields' => $this->unsupportedRootFields($canonicalType, $payload), 'redacted_fields' => $this->redactedPaths($payload), - 'volatile_fields' => $this->presentVolatileFields($rawPayload), + 'volatile_fields' => $this->presentVolatileFields($rawPayload, $volatileFields), ]; } @@ -768,12 +845,13 @@ private function unsupportedRootFields(string $canonicalType, array $payload): a /** * @param array $payload + * @param list $volatileFields * @return list */ - private function presentVolatileFields(array $payload): array + private function presentVolatileFields(array $payload, array $volatileFields): array { $fields = array_values(array_filter( - self::VOLATILE_ROOT_FIELDS, + $volatileFields, static fn (string $field): bool => array_key_exists($field, $payload), )); diff --git a/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php b/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php index cc929a34..5632aad3 100644 --- a/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php +++ b/apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php @@ -24,6 +24,7 @@ public function __construct( private readonly CoverageSourceContractResolver $contractResolver, private readonly ProviderGateway $providerGateway, private readonly GenericPayloadNormalizer $normalizer, + private readonly ExchangeTeamsComparablePayloadNormalizer $exchangeTeamsNormalizer, private readonly CoveragePayloadRedactor $redactor, private readonly CoverageResourceUpserter $resourceUpserter, private readonly CoverageEvidenceWriter $evidenceWriter, @@ -207,7 +208,7 @@ private function captureResponseItems( $permissionContext = $this->permissionContext($providerConnection); foreach ($this->responseItems($response) as $item) { - $normalizedPayload = $this->redactedNormalizedPayload($this->normalizer->normalize($item, $volatileFields)); + $normalizedPayload = $this->normalizedPayloadForCapture($resourceType, $item, $volatileFields, $decision); $payloadHash = $this->normalizer->payloadHash($normalizedPayload); $resource = $this->resourceUpserter->upsert( @@ -236,6 +237,49 @@ private function captureResponseItems( return $captured; } + /** + * @param array $payload + * @param list $volatileFields + * @return array + */ + private function normalizedPayloadForCapture( + TenantConfigurationResourceType $resourceType, + array $payload, + array $volatileFields, + CoverageSourceContractDecision $decision, + ): array { + $canonicalType = (string) $resourceType->canonical_type; + + if ($this->exchangeTeamsNormalizer->supports($canonicalType)) { + $normalized = $this->redactedNormalizedPayload($this->exchangeTeamsNormalizer->normalize($canonicalType, $payload, $volatileFields)); + $normalized['source'] = $this->typedSourceSummary($normalized['source'] ?? [], $decision); + + return $normalized; + } + + return $this->redactedNormalizedPayload($this->normalizer->normalize($payload, $volatileFields)); + } + + /** + * @param mixed $existing + * @return array + */ + private function typedSourceSummary(mixed $existing, CoverageSourceContractDecision $decision): array + { + $source = is_array($existing) && ! array_is_list($existing) ? $existing : []; + + return array_filter([ + ...$source, + 'source_contract_key' => $decision->contractKey, + 'source_endpoint' => $decision->sourceEndpoint, + 'source_version' => $decision->sourceVersion, + 'source_schema_hash' => $decision->sourceSchemaHash, + 'source_schema_hash_available' => $decision->sourceSchemaHash !== null, + 'source_class' => $decision->sourceMetadata['source_class'] ?? null, + 'registry_support_state' => $decision->sourceMetadata['registry_support_state'] ?? null, + ], static fn (mixed $value): bool => $value !== null && $value !== ''); + } + /** * @return list> */ diff --git a/apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php b/apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php index 77d9ff8d..f39ab210 100644 --- a/apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php +++ b/apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php @@ -23,7 +23,7 @@ public function normalize(array $payload, array $volatileFields = []): array */ public function payloadHash(array $normalizedPayload): string { - return hash('sha256', $this->canonicalJson($normalizedPayload)); + return hash('sha256', $this->canonicalJson($this->hashablePayload($normalizedPayload))); } /** @@ -34,6 +34,23 @@ public function canonicalJson(array $payload): string return json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } + /** + * @param array $payload + * @return array + */ + private function hashablePayload(array $payload): array + { + if (isset($payload['diagnostics']) && is_array($payload['diagnostics'])) { + unset($payload['diagnostics']['volatile_fields']); + + if ($payload['diagnostics'] === []) { + unset($payload['diagnostics']); + } + } + + return $payload; + } + /** * @param array $volatileLookup * @return mixed diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php index 8881862b..29e371e2 100644 --- a/apps/platform/tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php @@ -20,7 +20,7 @@ use App\Support\TenantConfiguration\EvidenceState; use App\Support\TenantConfiguration\IdentityState; -it('Spec420 captures Conditional Access evidence and blocks selected missing-contract types', function (): void { +it('Spec420 captures Conditional Access evidence and blocks remaining missing-contract types', function (): void { app(ResourceTypeRegistry::class)->syncDefaults(); config()->set('graph_contracts.types.conditionalAccessPolicy.volatile_fields', ['modifiedDateTime']); @@ -114,7 +114,7 @@ ->and($evidence->permission_context['scopes_granted'])->toBe(['Policy.Read.All', 'Directory.Read.All']); }); -it('Spec420 creates no fake evidence rows for selected missing-contract types', function (): void { +it('Spec420 creates no fake evidence rows for remaining missing-contract types', function (): void { app(ResourceTypeRegistry::class)->syncDefaults(); [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsClaimGuardFeatureTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsClaimGuardFeatureTest.php new file mode 100644 index 00000000..8dc3fb23 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsClaimGuardFeatureTest.php @@ -0,0 +1,32 @@ +syncDefaults(); + + $claimStates = TenantConfigurationResourceType::query() + ->whereIn('canonical_type', ['transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy']) + ->orderBy('canonical_type') + ->get() + ->mapWithKeys(fn (TenantConfigurationResourceType $row): array => [ + (string) $row->canonical_type => $row->default_claim_state, + ]) + ->all(); + + expect($claimStates)->toBe([ + 'acceptedDomain' => ClaimState::InternalOnly, + 'appPermissionPolicy' => ClaimState::InternalOnly, + 'meetingPolicy' => ClaimState::InternalOnly, + 'transportRule' => ClaimState::InternalOnly, + ]) + ->and(app(ClaimGuard::class)->evaluateStatement('Selected Exchange resources are renderable for internal review', internalOperatorOnly: true)) + ->toBe(ClaimState::InternalOnly) + ->and(app(ClaimGuard::class)->evaluateStatement('Microsoft 365 customer-ready evidence', internalOperatorOnly: true)) + ->toBe(ClaimState::ClaimBlocked); +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php new file mode 100644 index 00000000..7612882b --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php @@ -0,0 +1,174 @@ +syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'scopes_granted' => ['Exchange.ManageAsApp', 'Policy.Read.All'], + ]); + $graph = spec426BlockingGraphClient(); + app()->instance(GraphClientInterface::class, $graph); + + $run = spec426CoreRun($user, $environment, $connection, spec426CoreTypes()); + + $result = app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalTypes: spec426CoreTypes(), + ); + + expect($graph->calls)->toBe([]) + ->and($result['summary_counts'])->toMatchArray([ + 'total' => 4, + 'processed' => 4, + 'succeeded' => 0, + 'skipped' => 4, + 'failed' => 0, + 'errors_recorded' => 0, + ]) + ->and($result['run_outcome'])->toBe(OperationRunOutcome::Blocked->value) + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); + + foreach (collect($result['outcomes'])->keyBy('canonical_type') as $canonicalType => $outcome) { + expect(spec426CoreTypes())->toContain($canonicalType) + ->and($outcome['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value) + ->and($outcome['reason_code'])->toBe('missing_source_contract_mapping') + ->and($outcome['source_contract_key'])->toBeNull(); + } +}); + +it('Spec426 records blocked OperationRun truth without source evidence or provider calls', function (): void { + app(ResourceTypeRegistry::class)->syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'scopes_granted' => ['Exchange.ManageAsApp', 'Policy.Read.All'], + ]); + $graph = spec426BlockingGraphClient(); + app()->instance(GraphClientInterface::class, $graph); + + $run = spec426CoreRun($user, $environment, $connection, spec426CoreTypes()); + + app(CaptureTenantConfigurationEvidenceJob::class, ['run' => $run])->handle( + app(GenericContentEvidenceCaptureService::class), + app(OperationRunService::class), + app(AuditRecorder::class), + ); + + $run->refresh(); + + expect($graph->calls)->toBe([]) + ->and($run->type)->toBe(OperationRunType::TenantConfigurationCapture->value) + ->and($run->status)->toBe(OperationRunStatus::Completed->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value) + ->and($run->summary_counts)->toMatchArray([ + 'total' => 4, + 'processed' => 4, + 'succeeded' => 0, + 'skipped' => 4, + 'failed' => 0, + 'errors_recorded' => 0, + ]) + ->and(collect(data_get($run->context, 'capture.resource_type_outcomes', []))->pluck('outcome')->unique()->values()->all())->toBe([ + CaptureOutcome::BlockedMissingContract->value, + ]) + ->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('spec426-provider-secret') + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0) + ->and(AuditLog::query()->where('action', 'tenant_configuration.capture.completed')->exists())->toBeTrue(); +}); + +/** + * @return list + */ +function spec426CoreTypes(): array +{ + return ['acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', 'transportRule']; +} + +function spec426CoreRun($user, $environment, ProviderConnection $connection, array $resourceTypes): OperationRun +{ + return OperationRun::factory()->withUser($user)->forTenant($environment)->create([ + 'type' => OperationRunType::TenantConfigurationCapture->value, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'target_scope' => [ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + ], + 'resource_types' => $resourceTypes, + 'required_capability' => 'evidence.manage', + ], + ]); +} + +function spec426BlockingGraphClient(): GraphClientInterface +{ + return new class implements GraphClientInterface + { + /** + * @var list}> + */ + public array $calls = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $this->calls[] = ['policy_type' => $policyType, 'options' => $options]; + + return new GraphResponse(false, [], 500); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + }; +} diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoCertificationTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoCertificationTest.php new file mode 100644 index 00000000..b7e78aab --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoCertificationTest.php @@ -0,0 +1,29 @@ +syncDefaults(); + + $rows = TenantConfigurationResourceType::query() + ->whereIn('canonical_type', ['transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy']) + ->get(); + + expect($rows)->toHaveCount(4); + + foreach ($rows as $row) { + expect($row->allows_certified_claims)->toBeFalse() + ->and($row->default_coverage_level)->not->toBe(CoverageLevel::Certified); + } + + expect(app(ClaimGuard::class)->evaluateStatement('Selected Exchange resources are certified', internalOperatorOnly: true)) + ->toBe(ClaimState::ClaimBlocked) + ->and(app(ClaimGuard::class)->evaluateStatement('Selected Teams resources are certified', internalOperatorOnly: true)) + ->toBe(ClaimState::ClaimBlocked); +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoMiniPlatformTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoMiniPlatformTest.php new file mode 100644 index 00000000..92f42030 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoMiniPlatformTest.php @@ -0,0 +1,26 @@ +filter(fn (SplFileInfo $file): bool => str_ends_with($file->getFilename(), '.php')) + ->map(fn (SplFileInfo $file): string => file_get_contents($file->getPathname()) ?: '') + ->implode("\n"); + + expect($runtimeFiles) + ->not->toContain('namespace App\\Services\\TenantConfiguration\\Exchange') + ->not->toContain('namespace App\\Services\\TenantConfiguration\\Teams') + ->not->toContain('ExchangeProviderAdapter') + ->not->toContain('TeamsProviderAdapter') + ->not->toContain('GraphV1Adapter') + ->not->toContain('ProviderV1') + ->not->toContain('dual_write') + ->not->toContain('tenant_configuration_exchange') + ->not->toContain('tenant_configuration_teams'); + + expect(glob(database_path('migrations/*exchange*')) ?: [])->toBe([]) + ->and(glob(database_path('migrations/*teams*')) ?: [])->toBe([]); +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoRestoreTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoRestoreTest.php new file mode 100644 index 00000000..360d59fa --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoRestoreTest.php @@ -0,0 +1,35 @@ +syncDefaults(); + + $rows = TenantConfigurationResourceType::query() + ->whereIn('canonical_type', ['transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy']) + ->get(); + + expect($rows)->toHaveCount(4); + + foreach ($rows as $row) { + expect($row->restore_tier)->not->toBe(RestoreTier::Restorable) + ->and(app(ClaimGuard::class)->evaluate( + scopeKey: 'spec426_exchange_teams_internal', + requestedLevel: CoverageLevel::Renderable, + actualLevel: CoverageLevel::Renderable, + scopeComplete: true, + sourceClass: $row->source_class, + restoreTier: $row->restore_tier, + identityState: IdentityState::Stable, + restoreClaim: true, + ))->toBe(ClaimState::ClaimBlocked); + } +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoTenantIdTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoTenantIdTest.php new file mode 100644 index 00000000..1f345b24 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoTenantIdTest.php @@ -0,0 +1,29 @@ +not->toContain('tenant_id') + ->not->toContain('provider_tenant_id') + ->not->toContain('entra_tenant_id'); + } + + $runtimeFiles = [ + app_path('Services/TenantConfiguration/CoverageSourceContractResolver.php'), + app_path('Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php'), + app_path('Services/TenantConfiguration/GenericContentEvidenceCaptureService.php'), + ]; + + $content = collect($runtimeFiles) + ->map(fn (string $file): string => file_get_contents($file) ?: '') + ->implode("\n"); + + expect($content) + ->not->toContain('tenant_id') + ->not->toContain('provider_tenant_id') + ->not->toContain('entra_tenant_id'); +}); diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsProviderScopeTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsProviderScopeTest.php new file mode 100644 index 00000000..b6d7855c --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsProviderScopeTest.php @@ -0,0 +1,138 @@ +syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + [, $otherEnvironment] = createMinimalUserWithTenant(role: 'owner'); + $foreignConnection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $otherEnvironment->workspace_id, + 'managed_environment_id' => (int) $otherEnvironment->getKey(), + ]); + $graph = spec426ProviderScopeGraphClient(); + app()->instance(GraphClientInterface::class, $graph); + + $run = OperationRun::factory()->withUser($user)->forTenant($environment)->create([ + 'type' => OperationRunType::TenantConfigurationCapture->value, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'target_scope' => [ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'provider_connection_id' => (int) $foreignConnection->getKey(), + ], + 'resource_types' => ['acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', 'transportRule'], + 'required_capability' => 'evidence.manage', + ], + ]); + + expect(fn () => app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $foreignConnection, + operationRun: $run, + canonicalTypes: ['acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', 'transportRule'], + ))->toThrow(InvalidArgumentException::class, 'Provider connection does not belong to the managed environment scope.'); + + expect($graph->calls)->toBe([]) + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec426 reuses existing start authorization for selected Exchange and Teams capture requests', function (): void { + Queue::fake(); + app(ResourceTypeRegistry::class)->syncDefaults(); + + [$owner, $environment] = createMinimalUserWithTenant(role: 'owner'); + [$readonly] = createMinimalUserWithTenant(tenant: $environment, role: 'readonly', workspaceRole: 'readonly'); + $outsider = User::factory()->create(); + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + [, $otherEnvironment] = createMinimalUserWithTenant(role: 'owner'); + $foreignConnection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $otherEnvironment->workspace_id, + 'managed_environment_id' => (int) $otherEnvironment->getKey(), + ]); + + expect(fn () => app(StartTenantConfigurationCapture::class)->start( + $environment, + $connection, + $outsider, + ['acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', 'transportRule'], + ))->toThrow(NotFoundHttpException::class) + ->and(fn () => app(StartTenantConfigurationCapture::class)->start( + $environment, + $connection, + $readonly, + ['acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', 'transportRule'], + ))->toThrow(AuthorizationException::class) + ->and(fn () => app(StartTenantConfigurationCapture::class)->start( + $environment, + $foreignConnection, + $owner, + ['acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', 'transportRule'], + ))->toThrow(NotFoundHttpException::class); + + Queue::assertNothingPushed(); +}); + +function spec426ProviderScopeGraphClient(): GraphClientInterface +{ + return new class implements GraphClientInterface + { + public array $calls = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $this->calls[] = ['policy_type' => $policyType, 'options' => $options]; + + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + }; +} diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsStableIdentityReadinessTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsStableIdentityReadinessTest.php new file mode 100644 index 00000000..13be07b3 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsStableIdentityReadinessTest.php @@ -0,0 +1,112 @@ +syncDefaults(); + + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + $graph = spec426IdentityReadinessGraphClient(); + app()->instance(GraphClientInterface::class, $graph); + + $run = OperationRun::factory()->withUser($user)->forTenant($environment)->create([ + 'type' => OperationRunType::TenantConfigurationCapture->value, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'context' => [ + 'target_scope' => [ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + ], + 'resource_types' => ['appPermissionPolicy'], + 'required_capability' => 'evidence.manage', + ], + ]); + + $result = app(GenericContentEvidenceCaptureService::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalTypes: ['appPermissionPolicy'], + ); + + expect($graph->calls)->toBe([]) + ->and($result['summary_counts'])->toMatchArray([ + 'total' => 1, + 'processed' => 1, + 'succeeded' => 0, + 'skipped' => 1, + 'failed' => 0, + ]) + ->and($result['run_outcome'])->toBe(OperationRunOutcome::Blocked->value) + ->and($result['outcomes'][0]['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value) + ->and($result['outcomes'][0]['reason_code'])->toBe('missing_source_contract_mapping') + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +function spec426IdentityReadinessGraphClient(): GraphClientInterface +{ + return new class implements GraphClientInterface + { + /** + * @var list}> + */ + public array $calls = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $this->calls[] = ['policy_type' => $policyType, 'options' => $options]; + + return new GraphResponse(true, [[ + 'DisplayName' => 'Display-only policy', + 'Name' => 'Display-only policy', + 'Identity' => 'Global', + 'GlobalCatalogAppsType' => 'AllowAllApps', + ]]); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(false, [], 501); + } + }; +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php index 86b5d771..d8a743b8 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php @@ -15,6 +15,11 @@ 'appProtectionPolicyAndroid', 'appProtectionPolicyiOS', 'conditionalAccessPolicy', + 'securityDefaults', + 'transportRule', + 'acceptedDomain', + 'appPermissionPolicy', + 'meetingPolicy', 'notificationMessageTemplate', 'roleScopeTag', ]); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php index a00f362e..cc787b99 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php @@ -17,7 +17,7 @@ use App\Support\TenantConfiguration\SupportState; use App\Support\TenantConfiguration\Workload; -it('Spec420 never derives runtime endpoints from M365 source aliases', function (string $canonicalType): void { +it('Spec420 never derives runtime endpoints from remaining M365 source aliases without explicit contracts', function (string $canonicalType): void { $resourceType = spec420EligibilityResourceType($canonicalType); $aliases = $resourceType->metadata['source_aliases'] ?? []; @@ -28,8 +28,10 @@ ->and($decision->sourceEndpoint)->toBeNull() ->and($decision->sourceMetadata['reason_code'])->toBe('missing_source_contract_mapping'); })->with([ + 'transportRule', 'acceptedDomain', 'appPermissionPolicy', + 'meetingPolicy', 'dlpCompliancePolicy', ]); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php index 8db4274b..829b7825 100644 --- a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php @@ -23,7 +23,7 @@ ->and($decision->sourceMetadata['registry_support_state'])->toBe('out_of_scope'); }); -it('Spec420 blocks selected missing-contract M365 types without falling back to unsupported', function (string $canonicalType): void { +it('Spec420 blocks remaining missing-contract M365 types without falling back to unsupported', function (string $canonicalType): void { $decision = (new CoverageSourceContractResolver(new GraphContractRegistry)) ->resolve(spec420UnitResourceType($canonicalType)); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsCanonicalIdentityTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsCanonicalIdentityTest.php new file mode 100644 index 00000000..86eb8b26 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsCanonicalIdentityTest.php @@ -0,0 +1,111 @@ +strategies(); + + foreach (['transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy'] as $canonicalType) { + $strategy = $strategies[$canonicalType] ?? null; + + expect($strategy)->toBeArray() + ->and($strategy['strategy_identifier'])->toStartWith('tcm.') + ->and($strategy['requires_provider_connection_scope'])->toBeTrue() + ->and($strategy['allows_derived_identity'])->toBeFalse() + ->and($strategy['allows_experimental_identity'])->toBeFalse() + ->and($strategy['derived_claims_allowed'])->toBeFalse(); + + $identityFields = [ + ...$strategy['preferred_identity_fields'], + ...$strategy['fallback_identity_fields'], + ]; + + expect(array_intersect($identityFields, ['displayName', 'DisplayName', 'name', 'Name', 'Identity'])) + ->toBe([], "{$canonicalType} must not treat display-only or name-like Identity fields as stable identity."); + } +}); + +it('Spec426 resolves stable source-backed identity from provider or natural keys', function (string $canonicalType, array $payload, CanonicalKeyKind $expectedKeyKind): void { + $result = app(CanonicalIdentityResolver::class)->resolve( + spec426IdentityResourceType($canonicalType), + $payload, + ['source_contract_key' => $canonicalType, 'source_version' => 'v1.0'], + ); + + expect($result->identityState)->toBe(IdentityState::Stable) + ->and($result->keyKind)->toBe($expectedKeyKind) + ->and($result->sourceResourceId)->not->toStartWith('missing:'); +})->with([ + 'transport rule guid' => ['transportRule', ['Guid' => 'b0f47b86-0875-46ba-8753-37d19bb0789a', 'Identity' => 'Transport Rule 1', 'DisplayName' => 'Mail flow rule'], CanonicalKeyKind::TcmResourceIdentifier], + 'accepted domain natural key' => ['acceptedDomain', ['DomainName' => 'contoso.com', 'DisplayName' => 'Contoso'], CanonicalKeyKind::ProviderExternalId], + 'app permission policy id' => ['appPermissionPolicy', ['policyId' => 'app-policy-global', 'Identity' => 'Global', 'DisplayName' => 'Global app policy'], CanonicalKeyKind::TcmResourceIdentifier], + 'meeting policy id' => ['meetingPolicy', ['policyId' => 'meeting-policy-global', 'Identity' => 'Global', 'DisplayName' => 'Global meeting policy'], CanonicalKeyKind::TcmResourceIdentifier], +]); + +it('Spec426 prefers stable provider IDs over name-like Identity values', function (string $canonicalType, array $payload, string $expectedField, string $expectedValue): void { + $result = app(CanonicalIdentityResolver::class)->resolve( + spec426IdentityResourceType($canonicalType), + $payload, + ['source_contract_key' => $canonicalType, 'source_version' => 'v1.0'], + ); + + expect($result->identityState)->toBe(IdentityState::Stable) + ->and($result->sourceIdentity['values']['field'])->toBe($expectedField) + ->and($result->sourceIdentity['values']['value'])->toBe($expectedValue) + ->and($result->sourceResourceId)->toBe($expectedValue); +})->with([ + 'transport rule guid before Identity' => ['transportRule', ['Identity' => 'Transport Rule 1', 'Guid' => 'b0f47b86-0875-46ba-8753-37d19bb0789a'], 'Guid', 'b0f47b86-0875-46ba-8753-37d19bb0789a'], + 'app permission policy policyId before Identity' => ['appPermissionPolicy', ['Identity' => 'Global', 'policyId' => 'app-policy-global'], 'policyId', 'app-policy-global'], + 'meeting policy policyId before Identity' => ['meetingPolicy', ['Identity' => 'Global', 'policyId' => 'meeting-policy-global'], 'policyId', 'meeting-policy-global'], +]); + +it('Spec426 blocks display-only payloads from stable readiness identity', function (string $canonicalType): void { + $result = app(CanonicalIdentityResolver::class)->resolve( + spec426IdentityResourceType($canonicalType), + ['DisplayName' => 'Shared display name', 'name' => 'Shared display name'], + ['source_contract_key' => $canonicalType, 'source_version' => 'v1.0'], + ); + + expect($result->identityState)->toBe(IdentityState::MissingExternalId) + ->and($result->sourceResourceId)->toStartWith('missing:') + ->and($result->canonicalResourceKey)->not->toContain('Shared display name'); +})->with([ + 'transportRule', + 'acceptedDomain', + 'appPermissionPolicy', + 'meetingPolicy', +]); + +it('Spec426 blocks Identity-only payloads from stable readiness identity', function (string $canonicalType): void { + $result = app(CanonicalIdentityResolver::class)->resolve( + spec426IdentityResourceType($canonicalType), + ['Identity' => 'Global', 'DisplayName' => 'Global'], + ['source_contract_key' => $canonicalType, 'source_version' => 'v1.0'], + ); + + expect($result->identityState)->toBe(IdentityState::MissingExternalId) + ->and($result->sourceResourceId)->toStartWith('missing:') + ->and($result->canonicalResourceKey)->not->toContain('Global'); +})->with([ + 'transportRule', + 'acceptedDomain', + 'appPermissionPolicy', + 'meetingPolicy', +]); + +function spec426IdentityResourceType(string $canonicalType): TenantConfigurationResourceType +{ + $definition = collect(ResourceTypeRegistry::defaultDefinitions()) + ->firstWhere('canonical_type', $canonicalType); + + expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}."); + + return new TenantConfigurationResourceType($definition); +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsClaimGuardReadinessTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsClaimGuardReadinessTest.php new file mode 100644 index 00000000..3c5d45f3 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsClaimGuardReadinessTest.php @@ -0,0 +1,29 @@ +evaluateStatement($claim, internalOperatorOnly: true)) + ->toBe(ClaimState::InternalOnly); +})->with([ + 'Selected Exchange resources are comparable for internal operator review', + 'Selected Exchange resources are renderable for internal review', + 'Selected Teams resources are comparable for internal operator review', + 'Selected Teams resources are renderable for internal review', +]); + +it('Spec426 blocks certification, restore, customer, and broad Microsoft 365 readiness claims', function (string $claim): void { + expect(app(ClaimGuard::class)->evaluateStatement($claim, internalOperatorOnly: true)) + ->toBe(ClaimState::ClaimBlocked); +})->with([ + 'Selected Exchange resources are certified', + 'Selected Teams resources are restore-ready', + 'Exchange customer-ready evidence', + 'Teams customer-ready evidence', + 'Microsoft 365 customer-ready evidence', + 'Full Microsoft 365 evidence coverage', + 'All Exchange and Teams resources are supported', +]); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsEvidenceHashTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsEvidenceHashTest.php new file mode 100644 index 00000000..c660e011 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsEvidenceHashTest.php @@ -0,0 +1,39 @@ +normalize('meetingPolicy', [ + 'policyId' => 'meeting-policy-global', + 'DisplayName' => 'Global meeting policy', + 'AllowCloudRecording' => false, + 'modifiedDateTime' => '2026-07-01T10:00:00Z', + ]); + $second = $typed->normalize('meetingPolicy', [ + 'policyId' => 'meeting-policy-global', + 'DisplayName' => 'Global meeting policy', + 'AllowCloudRecording' => false, + 'modifiedDateTime' => '2026-07-02T10:00:00Z', + ]); + $withoutVolatile = $typed->normalize('meetingPolicy', [ + 'policyId' => 'meeting-policy-global', + 'DisplayName' => 'Global meeting policy', + 'AllowCloudRecording' => false, + ]); + $changed = $typed->normalize('meetingPolicy', [ + 'policyId' => 'meeting-policy-global', + 'DisplayName' => 'Global meeting policy', + 'AllowCloudRecording' => true, + 'modifiedDateTime' => '2026-07-02T10:00:00Z', + ]); + + expect($hashes->payloadHash($first))->toBe($hashes->payloadHash($second)) + ->and($hashes->payloadHash($first))->toBe($hashes->payloadHash($withoutVolatile)) + ->and($hashes->payloadHash($first))->not->toBe($hashes->payloadHash($changed)); +}); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsPayloadNormalizationFromSourceTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsPayloadNormalizationFromSourceTest.php new file mode 100644 index 00000000..9dea7612 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsPayloadNormalizationFromSourceTest.php @@ -0,0 +1,172 @@ +normalize($canonicalType, $payload); + + expect($normalized['supported'])->toBeTrue() + ->and($normalized['canonical_type'])->toBe($canonicalType) + ->and($normalized)->toHaveKey($expectedKey) + ->and($normalized['source_identity']['field'])->toBe($expectedIdentityField) + ->and($normalized['source_identity']['value'])->toBe($expectedIdentityValue) + ->and(data_get($normalized, 'diagnostics.unsupported_fields'))->not->toContain($expectedIdentityField) + ->and($normalized['diagnostics'])->toHaveKeys(['unsupported_fields', 'redacted_fields', 'volatile_fields']); +})->with([ + 'transportRule' => ['transportRule', ['Guid' => 'b0f47b86-0875-46ba-8753-37d19bb0789a', 'DisplayName' => 'Rule 1', 'Enabled' => true], 'enabled_state', 'Guid', 'b0f47b86-0875-46ba-8753-37d19bb0789a'], + 'acceptedDomain' => ['acceptedDomain', ['DomainName' => 'contoso.com', 'DomainType' => 'Authoritative'], 'domain_name', 'DomainName', 'contoso.com'], + 'appPermissionPolicy' => ['appPermissionPolicy', ['policyId' => 'app-policy-global', 'GlobalCatalogAppsType' => 'BlockedAppList'], 'policy_mode', 'policyId', 'app-policy-global'], + 'meetingPolicy' => ['meetingPolicy', ['policyId' => 'meeting-policy-global', 'AllowCloudRecording' => false], 'recording_transcription', 'policyId', 'meeting-policy-global'], +]); + +it('Spec426 treats sourceId as supported source identity for future verified source payloads', function (string $canonicalType, array $payload): void { + $normalized = app(ExchangeTeamsComparablePayloadNormalizer::class)->normalize($canonicalType, $payload); + + expect($normalized['source_identity'])->toBe([ + 'field' => 'sourceId', + 'value' => 'source-'.$canonicalType, + ]) + ->and(data_get($normalized, 'diagnostics.unsupported_fields'))->not->toContain('sourceId'); +})->with([ + 'transportRule sourceId' => ['transportRule', ['sourceId' => 'source-transportRule', 'Guid' => 'rule-guid', 'DisplayName' => 'Rule 1']], + 'acceptedDomain sourceId' => ['acceptedDomain', ['sourceId' => 'source-acceptedDomain', 'DomainName' => 'contoso.com']], + 'appPermissionPolicy sourceId' => ['appPermissionPolicy', ['sourceId' => 'source-appPermissionPolicy', 'policyId' => 'app-policy-global']], + 'meetingPolicy sourceId' => ['meetingPolicy', ['sourceId' => 'source-meetingPolicy', 'policyId' => 'meeting-policy-global']], +]); + +it('Spec426 capture handoff preserves typed source identity instead of global field precedence', function (): void { + $service = app(GenericContentEvidenceCaptureService::class); + $method = new \ReflectionMethod($service, 'normalizedPayloadForCapture'); + $method->setAccessible(true); + + $normalized = $method->invoke( + $service, + spec426PayloadResourceType('acceptedDomain'), + [ + 'DomainName' => 'contoso.com', + 'DomainType' => 'Authoritative', + 'policyId' => 'foreign-policy-id', + ], + [], + new CoverageSourceContractDecision( + canonicalType: 'acceptedDomain', + outcome: CaptureOutcome::Captured, + contractKey: 'verifiedAcceptedDomain', + sourceEndpoint: '/verified/acceptedDomains', + sourceVersion: 'v1.0', + sourceSchemaHash: 'schema-hash', + sourceMetadata: [ + 'source_class' => 'tcm', + 'registry_support_state' => 'out_of_scope', + ], + contract: ['volatile_fields' => []], + ), + ); + + expect($normalized['source_identity'])->toBe([ + 'field' => 'DomainName', + 'value' => 'contoso.com', + ]) + ->and(data_get($normalized, 'diagnostics.unsupported_fields'))->toContain('policyId') + ->and(data_get($normalized, 'source.source_contract_key'))->toBe('verifiedAcceptedDomain'); +}); + +it('Spec426 capture handoff honors contract volatile fields for typed payload hashes', function (): void { + $service = app(GenericContentEvidenceCaptureService::class); + $method = new \ReflectionMethod($service, 'normalizedPayloadForCapture'); + $method->setAccessible(true); + + $decision = new CoverageSourceContractDecision( + canonicalType: 'acceptedDomain', + outcome: CaptureOutcome::Captured, + contractKey: 'verifiedAcceptedDomain', + sourceEndpoint: '/verified/acceptedDomains', + sourceVersion: 'v1.0', + sourceSchemaHash: 'schema-hash', + sourceMetadata: [ + 'source_class' => 'tcm', + 'registry_support_state' => 'out_of_scope', + ], + contract: ['volatile_fields' => ['providerCorrelationId']], + ); + + $first = $method->invoke( + $service, + spec426PayloadResourceType('acceptedDomain'), + [ + 'DomainName' => 'contoso.com', + 'DomainType' => 'Authoritative', + 'providerCorrelationId' => 'run-1', + ], + ['providerCorrelationId'], + $decision, + ); + $second = $method->invoke( + $service, + spec426PayloadResourceType('acceptedDomain'), + [ + 'DomainName' => 'contoso.com', + 'DomainType' => 'Authoritative', + 'providerCorrelationId' => 'run-2', + ], + ['providerCorrelationId'], + $decision, + ); + $withoutVolatile = $method->invoke( + $service, + spec426PayloadResourceType('acceptedDomain'), + [ + 'DomainName' => 'contoso.com', + 'DomainType' => 'Authoritative', + ], + ['providerCorrelationId'], + $decision, + ); + + $hashes = app(GenericPayloadNormalizer::class); + + expect(data_get($first, 'diagnostics.volatile_fields'))->toContain('providerCorrelationId') + ->and(data_get($first, 'diagnostics.unsupported_fields'))->not->toContain('providerCorrelationId') + ->and($hashes->payloadHash($first))->toBe($hashes->payloadHash($second)) + ->and($hashes->payloadHash($first))->toBe($hashes->payloadHash($withoutVolatile)); +}); + +it('Spec426 reports unsupported source fields without copying unsafe raw provider content into normalized output', function (): void { + $normalized = app(ExchangeTeamsComparablePayloadNormalizer::class)->normalize('transportRule', [ + 'Guid' => 'b0f47b86-0875-46ba-8753-37d19bb0789a', + 'DisplayName' => 'Rule 1', + 'ProviderResponse' => 'raw-provider-response-secret', + 'SubjectContainsWords' => ['sensitive subject phrase'], + ]); + + $json = json_encode($normalized, JSON_THROW_ON_ERROR); + + expect(data_get($normalized, 'diagnostics.unsupported_fields'))->toContain('ProviderResponse') + ->and($json)->not->toContain('raw-provider-response-secret') + ->and($json)->not->toContain('sensitive subject phrase') + ->and(data_get($normalized, 'conditions.subject_contains_words'))->toBe('[redacted]'); +}); + +function spec426PayloadResourceType(string $canonicalType): TenantConfigurationResourceType +{ + $definition = collect(ResourceTypeRegistry::defaultDefinitions()) + ->firstWhere('canonical_type', $canonicalType); + + expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}."); + + return new TenantConfigurationResourceType($definition); +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsRedactionTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsRedactionTest.php new file mode 100644 index 00000000..c03bbc98 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsRedactionTest.php @@ -0,0 +1,27 @@ +normalize('transportRule', [ + 'Identity' => 'Rule 1', + 'SubjectContainsWords' => ['payroll acquisition secret'], + 'MessageBody' => 'private message body', + ]); + $meetingPolicy = $normalizer->normalize('meetingPolicy', [ + 'Identity' => 'Meeting 1', + 'RecordingTranscript' => 'meeting transcript secret', + 'AllowTranscription' => true, + ]); + + $json = json_encode([$transportRule, $meetingPolicy], JSON_THROW_ON_ERROR); + + expect($json)->not->toContain('payroll acquisition secret') + ->and($json)->not->toContain('private message body') + ->and($json)->not->toContain('meeting transcript secret') + ->and(data_get($transportRule, 'conditions.subject_contains_words'))->toBe('[redacted]'); +}); diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php new file mode 100644 index 00000000..9c80474f --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php @@ -0,0 +1,54 @@ +resolve(spec426ContractResourceType($canonicalType)); + + expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract) + ->and($decision->reasonCode)->toBe('missing_source_contract_mapping') + ->and($decision->contractKey)->toBeNull() + ->and($decision->sourceEndpoint)->toBeNull() + ->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]); +})->with([ + 'transportRule', + 'acceptedDomain', + 'appPermissionPolicy', + 'meetingPolicy', +]); + +it('Spec426 leaves non-selected M365 resource types fail-closed without alias-derived endpoints', function (): void { + $decision = (new CoverageSourceContractResolver(new GraphContractRegistry)) + ->resolve(spec426ContractResourceType('dlpCompliancePolicy')); + + expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract) + ->and($decision->reasonCode)->toBe('missing_source_contract_mapping') + ->and($decision->contractKey)->toBeNull() + ->and($decision->sourceEndpoint)->toBeNull(); +}); + +it('Spec426 does not register guessed Microsoft Graph endpoints for Exchange and Teams source contracts', function (): void { + $registered = array_keys((array) config('graph_contracts.types', [])); + + expect($registered)->not->toContain('transportRule') + ->and($registered)->not->toContain('acceptedDomain') + ->and($registered)->not->toContain('appPermissionPolicy') + ->and($registered)->not->toContain('meetingPolicy'); +}); + +function spec426ContractResourceType(string $canonicalType): TenantConfigurationResourceType +{ + $definition = collect(ResourceTypeRegistry::defaultDefinitions()) + ->firstWhere('canonical_type', $canonicalType); + + expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}."); + + return new TenantConfigurationResourceType($definition); +} diff --git a/specs/426-exchange-teams-core-evidence-identity-readiness/checklists/requirements.md b/specs/426-exchange-teams-core-evidence-identity-readiness/checklists/requirements.md new file mode 100644 index 00000000..770fbe9d --- /dev/null +++ b/specs/426-exchange-teams-core-evidence-identity-readiness/checklists/requirements.md @@ -0,0 +1,185 @@ +# Requirements Checklist: Spec 426 - Exchange / Teams Core Evidence & Stable Identity Readiness + +**Purpose**: Validate preparation readiness for the user-provided Spec 426 prerequisite unblocker before implementation. +**Created**: 2026-07-02 +**Feature**: [spec.md](../spec.md) + +## Candidate And Scope + +- [x] Candidate is directly user-provided and does not depend on the empty auto-prep queue. +- [x] Completed historical specs are treated as read-only dependency evidence, not artifacts to rewrite. +- [x] Primary operators and user stories are documented. +- [x] Functional requirements are documented. +- [x] Non-functional requirements are documented. +- [x] Success criteria are documented. +- [x] Risks and mitigations are documented. +- [x] Scope is limited to `exchange.transportRule`, `exchange.acceptedDomain`, `teams.appPermissionPolicy`, and `teams.meetingPolicy`. +- [x] Optional Exchange resource types are explicitly excluded. +- [x] Optional Teams resource types are explicitly excluded. +- [x] Certification is excluded and deferred to Spec 427. +- [x] Restore/apply/assisted restore is excluded. +- [x] Customer-facing proof, report, Review Pack, export, or PDF activation is excluded. +- [x] Broad Exchange, Teams, and M365 coverage claims are excluded. + +## Repo Truth Alignment + +- [x] Spec 422 is recorded as comparable/renderable support for content-backed synthetic or existing rows only. +- [x] Spec 425 is recorded as completed Entra certification precedent, not a reason to certify Exchange/Teams. +- [x] Current source preflight checked `CoverageSourceContractResolver`. +- [x] Current source preflight checked `CoverageIdentityStrategyRegistry`. +- [x] Current source preflight checked `GenericContentEvidenceCaptureService`. +- [x] Current source preflight checked `CoverageEvidenceWriter`. +- [x] Current source preflight checked `ExchangeTeamsComparablePayloadNormalizer`, `ExchangeTeamsCoverageComparator`, and `ExchangeTeamsRenderableSummaryBuilder`. +- [x] Current source preflight found no existing `426` spec directory before creation. +- [x] Current source preflight found no existing local `426` branch before creation. +- [x] Repo-canonical capture outcome and identity state values are recorded instead of inventing a parallel status family. + +## Source Contracts + +- [x] Post-review correction requires `transportRule` to fail closed until a verified source contract exists. +- [x] Post-review correction requires `acceptedDomain` to fail closed until a verified source contract exists. +- [x] Post-review correction requires `appPermissionPolicy` to fail closed until a verified source contract exists. +- [x] Post-review correction requires `meetingPolicy` to fail closed until a verified source contract exists. +- [x] Existing contract registry / repo-canonical provider contract pattern is required. +- [x] `GraphClientInterface` or repo-canonical provider abstraction is required. +- [x] Hardcoded endpoint guessing is forbidden. +- [x] Direct HTTP/provider bypass is forbidden. +- [x] Runtime documentation fetch is forbidden. +- [x] Missing contract fails safe. +- [x] Missing permission fails safe. +- [x] Unsupported or beta/experimental-only source blocks certification readiness. + +## Evidence + +- [x] Raw payload persistence is required when captured. +- [x] Typed/usable normalized payload persistence is required when captured. +- [x] Deterministic payload hash is required. +- [x] OperationRun linkage is required for provider/source capture. +- [x] Source class, source contract, source version/schema hash, and source metadata are required where available. +- [x] Empty collections are handled safely only after successful provider/source proof. +- [x] Fake/synthetic evidence cannot count as source-backed. +- [x] Old gap taxonomy is forbidden for Spec 426 outcomes. +- [x] OperationRun context must remain sanitized and numeric-only for summary counts. + +## Identity + +- [x] Stable identity is required for all four mandatory types. +- [x] `CanonicalIdentityResolver` usage is required. +- [x] Display-name-only identity is impossible. +- [x] Array-index identity is impossible. +- [x] Priority/order-only identity is impossible. +- [x] Payload-hash identity is impossible. +- [x] OperationRun identity is impossible. +- [x] Random UUID identity is impossible. +- [x] Identity conflict blocks readiness. +- [x] Derived-only identity blocks certification readiness. +- [x] Missing external ID and unsupported identity block certification readiness. + +## Normalization And Compare/Render + +- [x] Source payloads must align with Spec 422 compare/render shape. +- [x] `transportRule` material fields are named. +- [x] `acceptedDomain` material fields are named. +- [x] `appPermissionPolicy` material fields are named. +- [x] `meetingPolicy` material fields are named. +- [x] Volatile fields must be excluded from material hashes where configured. +- [x] Unsupported fields must be diagnosed rather than silently ignored. +- [x] Source-backed compare/render readiness requires source-backed evidence plus stable identity. +- [x] No certification assignment is allowed. + +## Claim Guard + +- [x] Evidence-ready internal claim is allowed only when proven. +- [x] Stable-identity-ready internal claim is allowed only when proven. +- [x] Compare/render-ready internal claim is allowed only when proven. +- [x] Certified Exchange/Teams wording is blocked. +- [x] Full Exchange claim is blocked. +- [x] Full Teams claim is blocked. +- [x] Certified M365 claim is blocked. +- [x] Restore-ready claim is blocked. +- [x] Customer-ready proof claim is blocked. + +## Ownership / Architecture + +- [x] No `tenant_id` platform-core ownership truth. +- [x] Uses Coverage v2 shared architecture. +- [x] No Exchange-specific table family. +- [x] No Teams-specific table family. +- [x] No separate Exchange/Teams engine or mini-platform. +- [x] No v1 compatibility. +- [x] No fallback reader, dual write, or legacy adapter. +- [x] Provider connection must be same workspace and same managed environment. +- [x] Provider-native tenant identifiers remain provider/source metadata only. + +## Product Surface + +- [x] Default decision is no runtime UI impact. +- [x] Product Surface no-impact rationale is documented. +- [x] Browser proof is required if UI changes. +- [x] Human Product Sanity is required if UI changes. +- [x] No new route/navigation is allowed. +- [x] No customer-facing route is allowed. +- [x] No certify action is allowed. +- [x] No restore/apply action is allowed. +- [x] No dashboard/report/export/PDF/Review Pack output is allowed. +- [x] Product Surface exceptions are `none`. + +## Redaction / Safe Logging + +- [x] Raw payload default display is forbidden. +- [x] Secrets and tokens are forbidden in logs/UI/output. +- [x] Authorization/token/cookie fields are redacted. +- [x] OperationRun context is sanitized. +- [x] Permission context is sanitized. +- [x] Mail body/subject/content leakage is forbidden. +- [x] Teams chat/message/file/recording/transcript leakage is forbidden. + +## Test Readiness + +- [x] Unit tests cover source contracts. +- [x] Unit tests cover capture eligibility. +- [x] Unit tests cover identity strategies. +- [x] Unit tests cover canonical identity. +- [x] Unit tests cover source payload normalization. +- [x] Unit tests cover evidence hash determinism. +- [x] Unit tests cover Claim Guard readiness. +- [x] Unit tests cover redaction. +- [x] Feature tests cover blocked capture with no fake content-backed evidence readiness. +- [x] Feature tests cover blocked capture with no fake stable identity readiness rows. +- [x] Feature tests cover OperationRun linkage. +- [x] Feature tests cover provider scope and RBAC semantics. +- [x] Feature tests cover no certification. +- [x] Feature tests cover no restore. +- [x] Feature tests cover no customer claim. +- [x] Feature tests cover no `tenant_id`. +- [x] Feature tests cover no mini-platform. +- [x] Browser tests are conditional on UI changes. +- [x] No real provider calls are allowed in tests. +- [x] Test lane impact is documented. + +## Implementation Report Readiness + +- [x] Candidate gate result requirement is defined. +- [x] Dirty state before/after requirement is defined. +- [x] Files changed requirement is defined. +- [x] Source contract matrix is defined. +- [x] Evidence matrix is defined. +- [x] Identity matrix is defined. +- [x] Compare/render readiness matrix is defined. +- [x] Claim Guard proof requirement is defined. +- [x] Redaction proof requirement is defined. +- [x] No certification proof requirement is defined. +- [x] No restore proof requirement is defined. +- [x] No customer claim proof requirement is defined. +- [x] No `tenant_id` confirmation is defined. +- [x] No mini-platform confirmation is defined. +- [x] Product Surface no-impact/impact requirement is defined. +- [x] Tests run and deferred work requirements are defined. + +## Review Outcome + +- [x] Candidate Selection Gate: PASS for direct user-provided manual promotion. +- [x] Spec Readiness Gate: PASS for preparation artifacts. +- [x] Open question/source blocker is recorded: verified source contracts remain required before source-backed readiness or Spec 427 can proceed. +- [x] Hard implementation preflight remains required at T001-T008 before runtime code changes. +- [x] Preparation scope stops before application implementation. diff --git a/specs/426-exchange-teams-core-evidence-identity-readiness/implementation-report.md b/specs/426-exchange-teams-core-evidence-identity-readiness/implementation-report.md new file mode 100644 index 00000000..51640d14 --- /dev/null +++ b/specs/426-exchange-teams-core-evidence-identity-readiness/implementation-report.md @@ -0,0 +1,131 @@ +# Implementation Report: Spec 426 - Exchange / Teams Core Evidence & Stable Identity Readiness + +**Branch**: `426-exchange-teams-core-evidence-identity-readiness` +**HEAD**: `33e496c1 feat: complete spec 425 enta certified compare pack (#492)` +**Implementation date**: 2026-07-02 +**Result**: PASS WITH CONDITIONS for Spec 426 closure; FAIL-safe for source-backed evidence readiness + +## Gate Result + +- Activated skills/gates: `pest-testing`, Spec Readiness Gate, provider-freshness semantics, and TCM cutover guard for the post-review fix. Earlier implementation work also used workspace/RBAC/OperationRun/evidence/customer-output/Product Surface gates. +- Closure gate: `PASS WITH CONDITIONS`. +- Condition: Spec 426 proves fail-safe behavior for Exchange/Teams source-backed evidence. It does not prove readiness, capture support, compare support, render support, or certification. Typed normalizer/hash tests are future-contract helper proof only. +- Source-backed evidence readiness gate: FAIL-safe/blocked because no verified production-safe source contract exists for the four mandatory types. +- Completed dependency specs 414, 415, 417, 418, 419, 420, 422, and 425 remain read-only context. No completed spec artifacts were rewritten. +- Review correction: removed unverified Graph v1.0 endpoint claims for `mailFlowRule`, `acceptedDomains`, `teamsAppPermissionPolicy`, and `teamsMeetingPolicy`. +- Final dirty state: runtime service/config/test changes plus active Spec 426 artifacts, as expected for this active spec. + +## Files Changed + +- Source contracts and capture: + - `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` + - `apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php` + - `apps/platform/config/graph_contracts.php` was verified to contain no unverified final Spec 426 contract entries; it has no final diff in this correction. +- Stable identity: + - `apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php` +- Tests: + - Focused Spec 426 unit and feature tests under `apps/platform/tests/Unit/Support/TenantConfiguration/` and `apps/platform/tests/Feature/TenantConfiguration/` + - Updated Spec 417/420 expectations so the four Exchange/Teams types remain fail-closed until verified contracts exist +- Spec artifacts: + - `spec.md`, `plan.md`, `tasks.md`, this implementation report + +## Source Contract Matrix + +| Type | Contract key | Endpoint | Source class | Outcome | +|---|---|---|---|---| +| `transportRule` | none | none | none | `capture_blocked_missing_contract` | +| `acceptedDomain` | none | none | none | `capture_blocked_missing_contract` | +| `appPermissionPolicy` | none | none | none | `capture_blocked_missing_contract` | +| `meetingPolicy` | none | none | none | `capture_blocked_missing_contract` | + +No endpoint guessing, direct HTTP, runtime documentation lookup, provider bypass, or guessed Microsoft Graph source contract remains. Capture attempts stop before `ProviderGateway` / `GraphClientInterface` calls for these four types. + +## Evidence Matrix + +| Type | Capture outcome | Provider call | Resource row | Evidence row | Empty/fake behavior | +|---|---|---:|---:|---:|---| +| `transportRule` | `capture_blocked_missing_contract` | no | no | no | no fake resource/evidence | +| `acceptedDomain` | `capture_blocked_missing_contract` | no | no | no | no fake resource/evidence | +| `appPermissionPolicy` | `capture_blocked_missing_contract` | no | no | no | no fake resource/evidence | +| `meetingPolicy` | `capture_blocked_missing_contract` | no | no | no | no fake resource/evidence | + +OperationRun summaries remain flat numeric counts. Blocked runs record sanitized blocked outcomes only; no raw payload, provider response body, secret, mail content, Teams content, or raw permission context is placed in run context or audit metadata. + +## Identity Matrix + +| Type | Strategy | Stable identity inputs | Explicitly rejected | +|---|---|---|---| +| `transportRule` | `tcm.exchange.transport_rule.v1` | `id`, `sourceId`, `Guid`, `RuleId` | `Identity`, display/name fields, order, payload hash | +| `acceptedDomain` | `tcm.exchange.accepted_domain.v1` | `id`, `sourceId`, `DomainName`, `domainName` | `Identity`, display/name fields, domain type/default flag | +| `appPermissionPolicy` | `tcm.teams.app_permission_policy.v1` | `id`, `sourceId`, `policyId` | `Identity`, display/name fields, settings/app hash | +| `meetingPolicy` | `tcm.teams.meeting_policy.v1` | `id`, `sourceId`, `policyId` | `Identity`, display/name fields, settings hash | + +`CanonicalIdentityResolver` remains the only identity path. `Identity`-only payloads resolve to `missing_external_id`, not stable identity. + +## Readiness Matrix + +| Type | `content_backed` | `identity_strategy_hardened` | `compare_render_ready` | `certified` | `restore_ready` | `customer_claimable` | +|---|---:|---:|---:|---:|---:|---:| +| `transportRule` | no | yes | no | no | no | no | +| `acceptedDomain` | no | yes | no | no | no | no | +| `appPermissionPolicy` | no | yes | no | no | no | no | +| `meetingPolicy` | no | yes | no | no | no | no | + +Typed normalizer/hash/redaction tests remain as helper proof for future valid source payloads. They are not used to claim source-backed evidence or certification readiness. Post-review coverage now also proves that typed helper output keeps stable source identity fields (`Guid`, `RuleId`, `DomainName`, `policyId`, `sourceId`), that the future typed capture handoff preserves that type-specific `source_identity`, that contract-level volatile fields are excluded from typed unsupported diagnostics/material hashes, and that payload hashes ignore volatile-field diagnostics. + +## Claim Guard And Redaction + +- Certified, restore-ready, full Exchange/Teams/Microsoft 365, and customer-ready claims remain blocked. +- Internal compare/render wording remains bounded by Claim Guard, but this report does not claim source-backed compare/render readiness for the four mandatory types. +- Redaction tests cover provider secrets, tokens, raw provider markers, mail subject/body-like fields, and Teams recording/transcript-like fields in normalized/helper output. + +## Product Surface / Filament Contract + +- No runtime UI files, routes, navigation, Filament resources/pages/widgets, actions, reports, exports, or customer outputs were changed. +- Product Surface decision: `N/A - no rendered UI surface changed`. +- Product Surface exceptions: none. +- Browser proof: `N/A - no rendered UI surface changed`. +- Human Product Sanity: `N/A - no rendered UI surface changed`. +- Livewire v4 compliance: unchanged; Filament v5 remains on Livewire v4. +- Provider registration: unchanged; Laravel provider registration remains in `apps/platform/bootstrap/providers.php`. +- Global search: unchanged; no resource/global search behavior was added. +- Destructive/high-impact actions: none added. +- Asset strategy: no new assets; no new `filament:assets` deployment requirement. + +## Deployment Impact + +- Migrations: none. +- Environment variables: none. +- Queues/cron: no new workers or schedules; existing OperationRun-backed capture job reused. +- Storage/volumes: none. +- Assets: none. +- Runtime config: no new Exchange/Teams graph contract entries remain; deploy through the normal container/config-cache path. +- Staging/Production: do not promote Exchange/Teams certification from this branch. Next sequence is Spec 427 verified source contract enablement, Spec 428 content-backed evidence promotion, Spec 429 compare/render promotion, and only later certification. A future verified provider contract must pass staging before evidence promotion or certification proceeds. + +## Validation + +Passed after post-review fix and closure review: + +- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec426` + - terminated with Signal 9 in the container runner. + - Not a closure blocker because the equivalent direct file runs below passed. +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsCanonicalIdentityTest.php` + - 22 passed, 124 assertions. +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsPayloadNormalizationFromSourceTest.php tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsEvidenceHashTest.php tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsClaimGuardReadinessTest.php tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsRedactionTest.php` + - 24 passed, 77 assertions. +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsStableIdentityReadinessTest.php` + - 3 passed, 69 assertions. +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec426ExchangeTeamsProviderScopeTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoCertificationTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoRestoreTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoMiniPlatformTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsClaimGuardFeatureTest.php` + - 7 passed, 55 assertions. +- Direct file Spec 426 total: + - 56 passed, 325 assertions. +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php` + - 16 passed, 259 assertions. +- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php` + - 1 passed, 44 assertions. +- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - passed. +- `git diff --check` + - passed. + +Spec 426 browser validation remains `N/A - no rendered UI surface changed`; the Spec 420 browser regression fixture changed and passed. diff --git a/specs/426-exchange-teams-core-evidence-identity-readiness/plan.md b/specs/426-exchange-teams-core-evidence-identity-readiness/plan.md new file mode 100644 index 00000000..a325e596 --- /dev/null +++ b/specs/426-exchange-teams-core-evidence-identity-readiness/plan.md @@ -0,0 +1,344 @@ +# Implementation Plan: Spec 426 - Exchange / Teams Core Evidence & Stable Identity Readiness + +**Branch**: `426-exchange-teams-core-evidence-identity-readiness` | **Date**: 2026-07-02 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/426-exchange-teams-core-evidence-identity-readiness/spec.md` + +## Summary + +Prepare Exchange/Teams core evidence and stable identity readiness for later certification without overclaiming source support. Post-review correction found that the initially proposed Graph v1.0 endpoints for `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` are not production-safe source contracts. The implementation must therefore keep those four types fail-closed as missing contracts, prove no provider call/resource/evidence is created for blocked capture, and harden identity strategies for future valid source payloads. The work remains a prerequisite unblocker only. It must not certify Exchange/Teams, introduce restore/apply, activate customer claims, add routes/navigation/dashboards, create Exchange/Teams table families, or introduce `tenant_id`. + +## Post-Review Correction + +- Remove unverified `mailFlowRule`, `acceptedDomains`, `teamsAppPermissionPolicy`, and `teamsMeetingPolicy` Graph contract entries. +- Keep `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` blocked as `capture_blocked_missing_contract` until a verified repo-canonical provider contract exists. +- Preserve typed normalization/hash/redaction tests as helper proof only; do not claim source-backed evidence or compare/render readiness from fixtures. +- Harden identity strategies so `Identity`/display-name-like values do not become stable identity. +- Final readiness gate for source-backed evidence is `FAIL` by design; no certification follow-up may proceed from this branch. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4 +**Primary Dependencies**: Existing Coverage v2 Tenant Configuration services: `CoverageSourceContractResolver`, `GraphContractRegistry`, `ProviderGateway`, `GraphClientInterface`, `GenericContentEvidenceCaptureService`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `CoverageIdentityStrategyRegistry`, `CanonicalIdentityResolver`, `ExchangeTeamsComparablePayloadNormalizer`, `ExchangeTeamsCoverageComparator`, `ExchangeTeamsRenderableSummaryBuilder`, `ClaimGuard`, `OperationRunService` +**Storage**: PostgreSQL through existing Coverage v2 resource/evidence/supported-scope tables; no new table planned; readiness labels are derived report assertions over existing states, not new persisted booleans/statuses +**Testing**: Pest 4, PHPUnit 12, focused Unit/Feature; Browser only if UI changes +**Validation Lanes**: fast-feedback for unit/feature; browser conditional; Pint dirty; diff check +**Target Platform**: Laravel Sail locally, Dokploy container deployment +**Project Type**: Laravel web monolith under `apps/platform` +**Performance Goals**: Capture remains bounded to four selected resource types, remote work is OperationRun-backed, and compare/render readiness is deterministic over persisted evidence +**Constraints**: no endpoint guessing, no direct HTTP, no provider bypass, no certification, no restore/apply, no customer output, no new route/navigation/dashboard, no `tenant_id`, no v1 compatibility, no completed-spec rewrites +**Scale/Scope**: exactly four mandatory resource types; no optional Exchange/Teams/M365 expansion + +## Preparation Preflight Result + +- Current branch before Spec Kit creation: `platform-dev`. +- Current branch after Spec Kit creation: `426-exchange-teams-core-evidence-identity-readiness`. +- Current HEAD before creation: `33e496c1 feat: complete spec 425 enta certified compare pack (#492)`. +- Initial dirty state: clean. +- Existing `426-*` spec/branch check: no existing local branch or spec package found before creation. +- Auto-prep queue: `docs/product/spec-candidates.md` currently says no safe automatic next-best-prep target remains. This package is a direct manual promotion from the user-provided candidate. +- Related completed/read-only dependency specs: 414, 415, 417, 418, 419, 420, 422, and 425. +- Source contract gaps confirmed during preparation before implementation: + - `transportRule`: no explicit mapping in `CoverageSourceContractResolver`. + - `acceptedDomain`: was explicitly blocked as `missing_source_contract_mapping` before Spec 426 implementation. + - `appPermissionPolicy`: was explicitly blocked as `missing_source_contract_mapping` before Spec 426 implementation. + - `meetingPolicy`: no explicit mapping in `CoverageSourceContractResolver`. +- Identity strategy gaps confirmed during preparation before implementation: + - No `CoverageIdentityStrategyRegistry` stable strategy for `transportRule`. + - No stable strategy for `acceptedDomain`. + - No stable strategy for `appPermissionPolicy`. + - No stable strategy for `meetingPolicy`. +- Current compare/render evidence: + - `ExchangeTeamsComparablePayloadNormalizer`, `ExchangeTeamsCoverageComparator`, and `ExchangeTeamsRenderableSummaryBuilder` support the four resource types. + - Spec 422 proof is compare/render for content-backed existing/synthetic rows, not live/source-backed capture proof. +- Current capture architecture: + - `GenericContentEvidenceCaptureService` uses `ProviderGateway::listPolicies()` through the repo provider abstraction. + - `CoverageEvidenceWriter` persists raw payload, normalized payload, payload hash, OperationRun link, source metadata, and content-backed evidence state. + - `CoverageResourceUpserter` uses `CanonicalIdentityResolver`. + - Implementation must align source-backed Exchange/Teams payloads with the typed Spec 422 normalizer before claiming compare/render readiness. +- Current enum/vocabulary alignment: + - Use repo-canonical values such as `capture_blocked_missing_contract`, `capture_blocked_permission`, `capture_blocked_beta`, `capture_blocked_unsupported`, `capture_failed`, `content_backed`, `stable`, `derived`, `identity_conflict`, `missing_external_id`, and `unsupported_identity`. + - Do not introduce parallel wording such as `capture_blocked_unsupported_source` unless the enum/status family is deliberately amended with tests and proportionality review. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: no operator-facing surface change by default. +- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: none by default. Existing Coverage v2 readiness/operator surface only if implementation amends artifacts first. +- **No-impact class, if applicable**: service/config/test-only. +- **Native vs custom classification summary**: N/A - no UI change. +- **Shared-family relevance**: evidence capture, identity, source contracts, claim safety, redaction, compare/render readiness. +- **State layers in scope**: none in UI; service/evidence state only. +- **Audience modes in scope**: internal/operator proof only; no customer/read-only output. +- **Decision/diagnostic/raw hierarchy plan**: evidence and readiness proof remain implementation-report/test proof; raw/support details never default-render. +- **Raw/support gating plan**: raw payloads, raw provider responses, source keys, OperationRun internals, provider diagnostics, and permission context stay hidden from default UI and excluded from customer output. +- **One-primary-action / duplicate-truth control**: no new action. +- **Handling modes by drift class or surface**: runtime UI changes require spec/plan/tasks amendment, Product Surface completion, focused browser proof, and Human Product Sanity. New route/navigation/customer/restore/certify scope is a hard stop. +- **Repository-signal treatment**: review-mandatory for evidence/status presentation only if UI is amended; hard-stop for customer output, restore/apply, certification, or mini-platform drift. +- **Special surface test profiles**: N/A by default; existing Coverage v2 Technical Annex browser smoke only if UI changes. +- **Required tests or manual smoke**: focused Unit/Feature always; Browser conditional. +- **Exception path and spread control**: none. +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage. +- **UI/Productization coverage decision**: `N/A - no rendered UI surface changed` by default. +- **Coverage artifacts to update**: none unless implementation amends runtime UI scope. +- **No-impact rationale**: Missing-contract blockers, identity hardening, no-fake-evidence behavior, and no-overclaim guards can be proven by existing shared services and tests without rendered UI changes. +- **Navigation / Filament provider-panel handling**: no panel, provider registration, route, or navigation change. +- **Screenshot or page-report need**: none unless runtime UI is amended into scope. + +## Product Surface Contract Plan + +- **Product Surface Contract reference**: `docs/product/standards/product-surface-contract.md`. +- **No-legacy posture**: canonical Coverage v2 extension; no compatibility exception. +- **Page archetype and surface budget plan**: N/A by default. If amended into existing UI, classify as Technical Annex and preserve existing surface budgets. +- **Technical Annex and deep-link demotion plan**: OperationRun, evidence IDs, source endpoints, source keys, raw payloads, permission context, provider diagnostics, unsupported internals, and raw compare values remain hidden/collapsed/internal-only. +- **Canonical status vocabulary plan**: N/A by default. If rendered, product-facing labels map to canonical vocabulary and never say certified, restore-ready, customer-ready, full Exchange, full Teams, or M365 certified. +- **Product Surface exceptions**: none. +- **Browser verification plan**: `N/A - no rendered UI surface changed` unless UI files change; otherwise focused existing Coverage v2 route smoke. +- **Human Product Sanity plan**: N/A unless UI changes. +- **Visible complexity outcome target**: neutral by default. +- **Implementation report target**: `specs/426-exchange-teams-core-evidence-identity-readiness/implementation-report.md`. + +## Filament / Livewire / Deployment Posture + +- **Livewire v4 compliance**: unchanged; platform remains Filament v5 on Livewire v4. Must be stated in close-out. +- **Panel provider registration location**: no panel change planned. Laravel 12 provider registration remains `apps/platform/bootstrap/providers.php`. +- **Global search posture**: no Resource/global search change planned. +- **Destructive/high-impact action posture**: none. No restore/apply/certify/start action may be introduced. Existing capture workflow may be reused only if already authorized and OperationRun-backed. +- **Asset strategy**: no new assets planned. `filament:assets` is not newly required unless implementation unexpectedly registers assets, which would require spec amendment. +- **Testing plan**: Unit/Feature for source contracts, capture eligibility, evidence persistence, identity, normalization/hash, claim guard, redaction, no certification/restore/customer/tenant_id/mini-platform; Browser only if UI changes. +- **Deployment impact**: no env vars, migrations, queues, scheduler, storage, or assets expected. If graph contracts/resource defaults change, existing deployment/sync steps must be documented in the implementation report. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes. +- **Systems touched**: Coverage v2 resource type registry, source contract resolver, graph contract config, provider gateway, generic capture, resource upsert/evidence write, identity strategy registry/resolver, Exchange/Teams typed normalizer/comparator/summary builder, Claim Guard, redaction, operation capture tests. +- **Shared abstractions reused**: `CoverageSourceContractResolver`, `GraphContractRegistry`, `ProviderGateway`, `GraphClientInterface`, `GenericContentEvidenceCaptureService`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `CoverageIdentityStrategyRegistry`, `CanonicalIdentityResolver`, `ExchangeTeamsComparablePayloadNormalizer`, `ExchangeTeamsCoverageComparator`, `ExchangeTeamsRenderableSummaryBuilder`, `ClaimGuard`, `OperationRunService`. +- **New abstraction introduced? why?**: none expected. A small adapter for typed normalization during capture is allowed only if the existing generic normalizer cannot safely persist deterministic Spec 422-compatible payloads. +- **Why the existing abstraction was sufficient or insufficient**: Existing Coverage v2 paths provide scope, evidence, identity, and claim safety. They lack the exact four source contracts, identity strategies, and source-backed typed normalization alignment required for later certification. +- **Bounded deviation / spread control**: Exchange/Teams-specific logic must remain bounded to typed helpers/contract mappings inside the Tenant Configuration service boundary. No Exchange/Teams platform or generic provider framework. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no new UX. +- **Central contract reused**: existing OperationRun-backed capture path only. +- **Delegated UX behaviors**: N/A - no new start surface. +- **Surface-owned behavior kept local**: none. +- **Queued DB-notification policy**: no new policy. +- **Terminal notification path**: existing central lifecycle mechanism only. +- **Exception path**: none. + +Remote/provider capture must continue to use existing OperationRun service-owned lifecycle and sanitized summary counts. No new operation type is required unless implementation proves the existing `tenant_configuration.capture` cannot represent the capture safely; that would require spec amendment. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes. +- **Provider-owned seams**: Exchange/Teams source contracts, Microsoft Graph/TCM source payload fields, provider IDs/natural keys, provider permission metadata. +- **Platform-core seams**: Coverage v2 evidence state, capture outcome, identity state, coverage level, claim state, payload hash, workspace/managed-environment/provider scope. +- **Neutral platform terms / contracts preserved**: resource type, evidence, identity, source contract, provider connection, managed environment, claim, compare/render readiness. +- **Retained provider-specific semantics and why**: `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` are Microsoft 365 resource semantics required by the exact denominator. +- **Bounded extraction or follow-up path**: document-in-feature. Spec 427 should verify/enable production-safe source contracts for the four mandatory Exchange/Teams types or keep each type explicitly blocked. Spec 428 should promote content-backed evidence only after verified contracts exist. Spec 429 should promote compare/render only after source-backed evidence exists. Certification remains a later separate spec. + +## Constitution Check + +- Inventory-first / evidence truth: FAIL-safe. This branch does not capture observed Exchange/Teams provider configuration evidence because verified source contracts are missing; blocked outcomes prevent fake evidence. +- Read/write separation: PASS with controls. No provider write, restore, apply, destructive UI action, or fake internal evidence write is introduced. +- Graph contract path: FAIL-safe. No unverified contracts are added to `config/graph_contracts.php`; future source contracts must use `GraphClientInterface`/provider gateway only. +- Deterministic capabilities: PASS. Readiness criteria and claim blockers are testable. +- RBAC-UX: PASS with implementation requirement. Non-member/wrong scope is 404; member missing capability is 403; readonly cannot start capture. +- Workspace isolation: PASS with implementation requirement. Same workspace/managed environment/provider connection is required. +- Tenant isolation: PASS in current repo vocabulary; no `tenant_id` ownership truth. +- Run observability: PASS. Blocked capture is OperationRun-backed and records sanitized missing-contract outcomes; readiness evaluation is DB/service-only. +- OperationRun start UX: N/A unless existing start workflow is reused; no new start surface. +- Data minimization: PASS. No raw Exchange/Teams provider payload is captured while contracts are missing; future raw payloads must remain evidence-only and never default-rendered/logged/customer-published. +- Test governance: PASS. Unit/Feature lane is sufficient unless UI changes. +- Proportionality: PASS. Four concrete source/identity gaps justify narrow missing-contract guards, identity strategies, and helper tests. +- No premature abstraction: PASS only if implementation avoids a generic Exchange/Teams framework. +- Persisted truth: PASS. Uses existing evidence/resource tables. +- Behavioral state: PASS if existing enum/status families are reused. +- UI semantics: PASS by no-UI default. +- Shared pattern first: PASS. Existing Coverage v2 services are the path. +- Provider boundary: PASS with provider-specific fields bounded to contracts/typed adapters. +- V1 explicitness / few layers: PASS. Explicit four-type implementation only. +- Spec discipline / bloat check: PASS. Certification, restore, customer output, and optional types are split out. +- Filament-native UI: N/A unless existing UI changes. +- Product Surface Contract: PASS with no-rendered-surface rationale. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for resolver, eligibility, identity, normalization, hash, Claim Guard, redaction. Feature for blocked capture with no fake DB evidence, OperationRun, provider scope, authorization, no certification/restore/customer/tenant_id/mini-platform. Browser only if UI changes. +- **Affected validation lanes**: fast-feedback; browser conditional. +- **Why this lane mix is the narrowest sufficient proof**: Missing-contract decisions, identity hardening, no-fake-evidence behavior, and claim safety are deterministic service/DB behavior. Browser proof is only meaningful when rendered UI changes. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ExchangeTeamsSourceContractResolverTest` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ExchangeTeamsIdentityStrategyRegistryTest` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec426ExchangeTeamsCoreEvidenceReadinessTest` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec426ExchangeTeamsStableIdentityReadinessTest` + - `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec426ExchangeTeamsClaimGuardFeatureTest` +- **Fixture / helper / factory / seed / context cost risks**: Add minimal fake provider payloads for four resource types; avoid full workspace/provider setup defaults outside feature tests. +- **Expensive defaults or shared helper growth introduced?**: no. +- **Heavy-family additions, promotions, or visibility changes**: none unless UI changes. +- **Surface-class relief / special coverage rule**: `N/A - no rendered UI surface changed` by default. +- **Closing validation and reviewer handoff**: verify missing-contract blockers, no fake evidence persistence, stable identity strategy hardening, no compare/render overclaim, claim blocking, redaction, no restore/customer/tenant_id/mini-platform. +- **Budget / baseline / trend follow-up**: none expected. +- **Review-stop questions**: Did any type rely on beta-only source, display-name identity, fake evidence, direct HTTP, endpoint guessing, customer claim, restore path, or `tenant_id`? If yes, fail or split. +- **Escalation path**: reject-or-split for certification/restore/customer/mini-platform scope; document-in-feature for bounded source/identity limitations. +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage. +- **Why no dedicated follow-up spec is needed**: This is the dedicated fail-safe readiness unblocker. Source-contract verification, content-backed evidence promotion, compare/render promotion, and certification are intentionally split into later specs. + +## Risk Controls And Rollout Considerations + +- **Rollout shape**: Service/config/test-only by default. No migration, env var, route, navigation, asset, queue, scheduler, storage, or customer-output rollout is planned. +- **Staging gate**: Later implementation must validate source contracts, capture outcomes, redaction, provider scope, and no-certification/no-restore/no-customer claims on staging before production promotion. +- **Provider-contract risk control**: New source contracts must be explicit registry entries with source class, source version/schema metadata where available, permission metadata where supported, and fake-provider tests. Runtime endpoint guessing is a stop condition. +- **Evidence integrity risk control**: Missing contract, missing permission, unsupported source, beta-only source, unavailable source, and provider failure must remain blocked/failed states, not empty evidence. +- **Identity risk control**: Any type that cannot produce stable source-backed identity fails readiness instead of falling back to display-name, order, payload-hash, operation-run, or generated identity. +- **Claim risk control**: Claim Guard must keep certification, restore-ready, full workload/M365, and customer-ready wording blocked until a later spec. +- **Operational risk control**: OperationRun summaries remain numeric-only and sanitized; compare/render readiness evaluation must not call the provider. +- **Rollback/forward note**: Because no schema or customer surface is planned, rollback is reverting the source/identity/normalization code and tests. If implementation discovers schema or UI changes are necessary, artifacts must be amended before proceeding. + +## Project Structure + +### Documentation (this feature) + +```text +specs/426-exchange-teams-core-evidence-identity-readiness/ +├── checklists/ +│ └── requirements.md +├── plan.md +├── spec.md +└── tasks.md +``` + +### Source Code (repository root) + +Likely affected runtime/test paths for later implementation: + +```text +apps/platform/config/ +└── graph_contracts.php + +apps/platform/app/Services/TenantConfiguration/ +├── CoverageSourceContractResolver.php +├── CoverageIdentityStrategyRegistry.php +├── GenericContentEvidenceCaptureService.php # only if typed capture normalization hook is needed +├── GenericPayloadNormalizer.php # only if deterministic collection/hash behavior must be generalized +├── CoverageEvidenceWriter.php # only if typed normalized payload promotion requires writer support +├── ExchangeTeamsComparablePayloadNormalizer.php +├── ExchangeTeamsCoverageComparator.php +├── ExchangeTeamsRenderableSummaryBuilder.php +└── ClaimGuard.php + +apps/platform/tests/Unit/Support/TenantConfiguration/ +└── ExchangeTeams*.php + +apps/platform/tests/Feature/TenantConfiguration/ +└── Spec426ExchangeTeams*.php + +apps/platform/tests/Fixtures/TenantConfiguration/Spec426/ +└── exchange-teams/ + +apps/platform/tests/Browser/ +└── Spec426ExchangeTeamsCoreEvidenceReadinessSmokeTest.php # only if UI changes +``` + +**Structure Decision**: Use existing TenantConfiguration/Coverage v2 service and test directories. Do not create new base folders, migrations, routes, Filament resources/pages/widgets, commands, jobs, dashboards, reports, exports, PDFs, or customer-output surfaces. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|---|---|---| +| Fail-closed source contract blockers | Later certification needs real source-backed capture for the exact denominator, but no verified contracts exist yet | Registering guessed Graph endpoints would allow fake certification readiness | +| Four stable identity strategies | Stable canonical identity is required to compare, track, and later certify resource instances | Display-name or payload-hash identity is unstable and unsafe | +| Typed source normalization alignment | Source-backed payloads must enter the existing Spec 422 compare/render pipeline deterministically | Generic raw-payload hashing alone does not prove comparable/renderable shape | + +## Proportionality Review + +- **Current operator problem**: Exchange/Teams certification remains blocked because the denominator lacks source-backed evidence and stable identity. +- **Existing structure is insufficient because**: Current source resolver and identity registry do not cover the four mandatory types; Spec 422 does not prove live/source-backed capture. +- **Narrowest correct implementation**: Keep missing source contracts blocked, remove unverified mappings, extend identity/normalization tests for exactly four concrete types, and document the source blocker. +- **Ownership cost created**: Four identity strategies and focused tests must track future provider contract changes. +- **Alternative intentionally rejected**: Certify based on row-level compare/render support or display names. That would weaken evidence integrity and create unsafe claims. +- **Release truth**: Current-release prerequisite for later source-contract verification, evidence promotion, compare/render promotion, and eventual certification. + +## Technical Approach + +### Phase 0 - Hard Preflight + +- Confirm branch, HEAD, dirty state, active spec path, and activated skills/gates. +- Confirm completed dependency specs are read-only context. +- Re-check current source contract and identity gaps. +- Confirm Coverage v2 capture/identity/evidence infrastructure exists. +- Stop if generic capture or canonical identity infrastructure is missing. + +### Phase 1 - Source Contract Gap Map + +- Inspect `CoverageSourceContractResolver`, `config/graph_contracts.php`, `GraphContractRegistry`, `ProviderGateway`, and `GraphClientInterface` patterns. +- Record current resolver state for `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy`. +- Determine source class, contract key, production-safety, permission metadata, response shape, volatile fields, and empty collection semantics. +- Identify whether any type is beta/experimental-only; if yes, mark certification readiness blocked. + +### Phase 2 - Block Unverified Source Contracts + +- Do not add resolver mappings or contract definitions for the four mandatory types until verified source contracts exist. +- Prove blocked capture makes no `ProviderGateway`/`GraphClientInterface` calls. +- Add contract tests proving no endpoint guessing, no direct HTTP, and missing-contract fail-closed behavior. +- Preserve missing-permission and unsupported-source outcomes. + +### Phase 3 - Block Fake Content-Backed Evidence + +- Route all four types through existing generic/shared capture path until the source contract decision blocks them. +- Persist no resource rows or evidence rows while contracts are missing. +- Preserve typed helper payload normalization for future valid source payloads, without claiming readiness/hash proof from blocked capture. +- Represent empty successful collections only as safe source-contract capture proof unless an existing repo-approved type-level evidence artifact can carry an explicit empty normalized payload/hash; do not create fake resource instances. +- Prevent fake/synthetic evidence from counting as source-backed. + +### Phase 4 - Stable Identity + +- Add identity strategies for the four mandatory types. +- Require stable provider/source ID or proven immutable natural key/composite. +- Reject display-name-only, array-index, priority/order, payload-hash, random UUID, and operation-run identity. +- Use `CanonicalIdentityResolver` and block identity conflicts. + +### Phase 5 - Normalization And Compare/Render Helper Proof + +- Align helper payload fixtures with `ExchangeTeamsComparablePayloadNormalizer`, `ExchangeTeamsCoverageComparator`, and `ExchangeTeamsRenderableSummaryBuilder` so future real source-backed payloads have a bounded path. +- Preserve material fields, exclude volatile fields, and record unsupported fields diagnostically. +- Mark readiness as compare/render-ready only when source-backed evidence and stable identity both exist; this branch does not satisfy that gate. +- Do not assign certified coverage. + +### Phase 6 - Claim Guard / Safety + +- Allow internal evidence-ready and stable-identity-ready wording only when proven. +- Block certification, restore-ready, full Exchange/Teams/M365, and customer-ready claims. +- Add redaction tests for secrets, tokens, cookies, authorization headers, raw payloads, raw permission context, mail content, and Teams content. + +### Phase 7 - Product Surface Decision + +- Confirm no runtime UI change. +- If implementation proves UI is needed, stop, amend spec/plan/tasks with exact affected surfaces, then run Product Surface/browser/Human Product Sanity proof. +- Do not add route, navigation, dashboard, certify action, restore action, report, customer output, Review Pack, export, or PDF. + +### Phase 8 - Validation + +- Run Pint dirty. +- Run focused unit tests. +- Run focused feature tests. +- Run browser test only if UI changed. +- Run `git diff --check`. +- Document exact validation results in implementation report. + +### Phase 9 - Implementation Report + +- Produce required matrices and final candidate gate result. +- Record no certification, no restore, no customer claim, no `tenant_id`, no mini-platform, and Product Surface no-impact proof. + +## Stop Conditions + +- Any mandatory type lacks source contract support and is treated as source-backed or compare/render-ready. +- Any mandatory type lacks stable source-backed identity. +- Any type is beta/experimental-only but treated as certification-ready. +- Synthetic/fake evidence is counted as source-backed. +- Display name, order, payload hash, or operation run ID is used as stable identity. +- Capture bypasses provider gateway/GraphClientInterface or guesses endpoints. +- Certification, restore/apply, customer output, full workload/M365 claim, new route/navigation/dashboard, or mini-platform appears. +- `tenant_id` is introduced as platform-core ownership truth or compatibility/fallback path. +- Raw payload, secrets, mail content, Teams content, or raw permission context leak into logs/UI/output. +- Tests cannot prove evidence + identity readiness. diff --git a/specs/426-exchange-teams-core-evidence-identity-readiness/spec.md b/specs/426-exchange-teams-core-evidence-identity-readiness/spec.md new file mode 100644 index 00000000..7a228e5b --- /dev/null +++ b/specs/426-exchange-teams-core-evidence-identity-readiness/spec.md @@ -0,0 +1,489 @@ +# Feature Specification: Spec 426 - Exchange / Teams Core Evidence & Stable Identity Readiness + +**Feature Branch**: `426-exchange-teams-core-evidence-identity-readiness` +**Created**: 2026-07-02 +**Status**: Draft +**Input**: User-provided candidate: "Spec 426 - Exchange / Teams Core Evidence & Stable Identity Readiness" + +## Selection And Preflight + +- **Selected candidate**: Spec 426 - Exchange / Teams Core Evidence & Stable Identity Readiness. +- **Source**: Direct user-provided draft in the 2026-07-02 session. +- **Why selected**: Spec 425 completed the Entra certified compare pack at current HEAD (`33e496c1 feat: complete spec 425 enta certified compare pack (#492)`). Exchange/Teams certification was intentionally blocked because source-backed evidence and stable identity are not ready for the four mandatory denominator types. +- **Roadmap relationship**: Explicit manual promotion outside the empty auto-prep queue in `docs/product/spec-candidates.md`. It continues the Coverage v2 / M365 maturity path after Specs 414, 415, 417, 418, 419, 420, 422, and 425. +- **Close alternatives deferred**: Spec 427 Exchange / Teams Verified Source Contract Enablement, Spec 428 content-backed evidence promotion, Spec 429 compare/render promotion, later Exchange / Teams Certified Compare Pack, Exchange restore/apply, Teams restore/apply, customer reports, Review Pack output, management PDF output, broad Exchange/Teams/M365 coverage claims, optional Exchange/Teams resource expansion, and Security/Compliance certification. +- **Completed-spec guardrail result**: Specs 414, 415, 417, 418, 419, 420, 422, and 425 are completed dependency context only. Their close-out history, validation results, browser proof, and task completion markers must not be rewritten by this spec. +- **Pre-implementation preflight evidence**: + - `CoverageSourceContractResolver` maps `conditionalAccessPolicy`, `securityDefaults`, `deviceAndAppManagementAssignmentFilter`, `notificationMessageTemplate`, and `roleScopeTag`. + - `CoverageSourceContractResolver` explicitly lists `acceptedDomain` and `appPermissionPolicy` as Spec 420 missing-contract blockers. + - `transportRule` and `meetingPolicy` do not currently have explicit source contract mappings. + - `CoverageIdentityStrategyRegistry` has stable strategies for `conditionalAccessPolicy` and `securityDefaults`, but not for `transportRule`, `acceptedDomain`, `appPermissionPolicy`, or `meetingPolicy`. + - `ExchangeTeamsComparablePayloadNormalizer`, `ExchangeTeamsCoverageComparator`, and `ExchangeTeamsRenderableSummaryBuilder` support the four target types for content-backed or synthetic/existing rows from Spec 422. + - `GenericContentEvidenceCaptureService`, `CoverageResourceUpserter`, and `CoverageEvidenceWriter` already provide the shared capture/resource/evidence path with workspace, managed-environment, provider-connection, OperationRun, raw payload, normalized payload, and payload hash storage. + - Current capture outcome enum values include `capture_blocked_missing_contract`, `capture_blocked_permission`, `capture_blocked_beta`, `capture_blocked_unsupported`, and `capture_failed`. Spec wording must use repo-canonical values or deliberately amend enums/tests; it must not create a parallel status family. +- **Hard implementation preflight**: Before runtime code changes, implementation must re-check current source/tests and stop if Coverage v2 generic capture, canonical identity, Graph contract path, OperationRun-backed capture, or workspace/environment/provider ownership infrastructure is missing. + +## Post-Review Source Contract Correction *(2026-07-02)* + +Strict review found that the initially proposed Graph v1.0 resource paths for the four mandatory Exchange/Teams types were not production-safe source contracts. `mailFlowRule`, `acceptedDomains`, `teamsAppPermissionPolicy`, and `teamsMeetingPolicy` must not be registered as Microsoft Graph v1.0 capture endpoints unless a future implementation proves a real repo-canonical provider contract for them. + +This correction supersedes earlier implementation wording that claimed the four mandatory types were source-backed in this branch: + +- `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` remain in scope as the required denominator, but source capture for them MUST fail closed as `capture_blocked_missing_contract` until a verified source contract exists. +- Missing-contract capture MUST NOT call `ProviderGateway`/`GraphClientInterface`, MUST NOT create `TenantConfigurationResource` rows, and MUST NOT create fake per-resource evidence. +- Stable identity strategies may be prepared for future valid source payloads, but `Identity`/name-like values are not stable identity for these types. Preferred stable IDs are provider/source IDs such as `id`, `sourceId`, `Guid`, `RuleId`, or `policyId`; `acceptedDomain` may use `DomainName`/`domainName` as the documented natural key when source-backed. +- The final Spec 426 source-backed evidence readiness gate is therefore **FAIL-safe / blocked**, not source-ready. Spec 426 closure may be `PASS WITH CONDITIONS` only when the condition states that this branch proves fail-safe behavior and does not prove readiness, capture support, compare support, render support, or certification. A later spec or amended implementation must introduce verified source contracts before any certification work can proceed. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: Exchange/Teams compare/render support exists only for content-backed synthetic or existing rows. The mandatory certification denominator cannot be trusted because live/source-contract-backed evidence and stable identity are missing for all four required resource types. +- **Today's failure**: TenantPilot could over-read Exchange/Teams typed compare/render support as certification readiness even though `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` do not all have source contracts, content-backed capture proof, or stable identity strategies. +- **User-visible improvement**: Internal operators and release reviewers can see that the four Exchange/Teams core types do not yet have verified source contracts, fail closed without fake evidence, and have hardened identity strategies ready for future valid source payloads, while certification, restore, customer claims, and broad workload claims remain blocked. +- **Smallest enterprise-capable version**: Remove unverified source mappings, prove missing-contract capture fails closed for exactly `exchange.transportRule`, `exchange.acceptedDomain`, `teams.appPermissionPolicy`, and `teams.meetingPolicy`, harden stable identity strategies, preserve typed normalization/hash/redaction proof, and keep Claim Guard blockers. +- **Explicit non-goals**: No certification, no `certified` coverage level assignment, no Exchange/Teams Certified Compare Pack, no restore/apply/assisted restore, no customer-facing report, no Review Pack output, no management PDF output, no Exchange dashboard, no Teams dashboard, no new route/navigation, no Exchange/Teams table family, no mini-platform, no optional resource expansion, no `tenant_id`, no v1 compatibility, no migration of old snapshots. +- **Permanent complexity imported**: Four identity strategy entries, typed source-payload normalization alignment, focused capture/identity/claim/redaction tests, and implementation-report matrices. No new persisted entity/table/framework is allowed. +- **Why now**: Spec 425 proves the certification bar for Entra. The next Exchange/Teams certification attempt is unsafe until the denominator has real source contracts, content-backed evidence, and stable identity. +- **Why not local**: Certification readiness is a shared Coverage v2 truth problem. A page-local parser or one-off capture path would bypass source contracts, provider scope, canonical identity, OperationRun, redaction, and Claim Guard. +- **Approval class**: Core Enterprise. +- **Red flags triggered**: New source mappings and identity strategies, plus typed normalization alignment. Defense: these are narrow extensions of existing Coverage v2 registries/services for four concrete resource types and directly prevent unsafe certification/customer/restore claims. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve as a prerequisite unblocker only. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: Workspace + managed-environment scoped Coverage v2 capture/evidence/identity/readiness behavior for exactly four Microsoft 365 resource types. +- **Primary Routes**: None by default. Existing Coverage v2 internal/operator surface only if implementation proves a small rendered readiness update is strictly needed and amends this spec/plan/tasks before UI edits. +- **Data Ownership**: Existing Coverage v2 records only: `TenantConfigurationResourceType`, `TenantConfigurationResource`, `TenantConfigurationResourceEvidence`, `TenantConfigurationSupportedScope`, and existing OperationRun linkage. Environment-owned records stay scoped by `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id`. +- **RBAC**: Existing capture/readiness authorization applies. Non-member workspace or missing managed-environment entitlement returns 404. Established member without capability returns 403. Readonly cannot start capture. Provider connections must belong to the same workspace and managed environment. + +For canonical-view specs: + +- **Default filter behavior when tenant-context is active**: N/A - no new canonical route or route filter. +- **Explicit entitlement checks preventing cross-tenant leakage**: All capture/evidence/readiness lookup paths must resolve through workspace + managed environment + provider connection scope and must not use provider-native tenant identifiers as ownership truth. + +## No Legacy / No Backward Compatibility Constraint *(mandatory)* + +TenantPilot is pre-production unless this spec explicitly records a compatibility exception. + +- **Compatibility posture**: canonical Coverage v2 extension; no compatibility exception. +- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no. +- **Why clean replacement is safe now**: This is a new internal readiness slice over active Coverage v2 services. No production data or external contract requires Coverage v1 adapters, fallback readers, dual writes, or old customer-facing coverage vocabulary. + +## Mandatory Resource Scope + +Only these resource types are in scope: + +| Workload | Resource type | In scope? | +|---|---|---| +| Exchange | `transportRule` | yes | +| Exchange | `acceptedDomain` | yes | +| Teams | `appPermissionPolicy` | yes | +| Teams | `meetingPolicy` | yes | + +Explicitly out of scope: `remoteDomain`, `organizationConfig`, `sharedMailbox`, `mailboxPlan`, `mailbox`, `mailboxPermission`, mail-flow connector resources, `appSetupPolicy`, `messagingPolicy`, `teamsUpdateManagementPolicy`, `voiceRoute`, `callingPolicy`, `emergencyCallingPolicy`, `meetingConfiguration`, Teams channels/messages/files/recordings/transcripts, and all other Exchange/Teams/M365 types. + +No silent expansion is allowed. Helper context may be used internally only when needed by an approved source contract and must not become a registered in-scope or certified resource type. + +## Primary Operators And User Stories + +**Primary operators**: internal TenantPilot platform operators, release reviewers, and implementation reviewers preparing a later Exchange/Teams certification pass. No customer-facing operator workflow is introduced by this spec. + +### User Story 1 - Release Reviewer Sees A Safe Certification Prerequisite + +As a release reviewer, I need objective evidence that the four Exchange/Teams denominator types are not falsely source-backed and that identity is hardened before approving a later certification spec. + +**Acceptance**: For each mandatory type, the implementation report and tests show missing-contract capture blocks, no provider call, no fake evidence, hardened stable identity rules for future valid source payloads, compare/render helper compatibility, and `certified = false`. + +### User Story 2 - Platform Operator Captures Evidence Without Scope Leakage + +As a platform operator, I need provider capture for these resource types to stay workspace, managed-environment, and provider-connection scoped. + +**Acceptance**: Non-member access returns 404, missing managed-environment entitlement returns 404, missing capability returns 403, readonly users cannot start capture, and cross-workspace/cross-environment provider connections are rejected. + +### User Story 3 - Implementation Reviewer Verifies Identity Stability + +As an implementation reviewer, I need to prove that resource identity is stable and not based on display labels, ordering, payload hashes, operation runs, or random values. + +**Acceptance**: All four identity strategies use stable source-backed identity inputs or a proven immutable natural key/composite, and invalid identity inputs block readiness. + +### User Story 4 - Security Reviewer Confirms No Overclaim Or Sensitive Exposure + +As a security/release reviewer, I need readiness proof that cannot be mistaken for certification, restore readiness, full workload coverage, or customer-ready output. + +**Acceptance**: Claim Guard blocks certification, restore, full Exchange/Teams/M365, and customer claims; redaction tests prove raw payloads, secrets, mail content, Teams content, and raw permission context are not logged or default-rendered. + +## Functional Requirements + +### Source Contracts + +- **FR-426-001**: `CoverageSourceContractResolver` or the repo-canonical equivalent MUST NOT resolve any mandatory type to a source contract unless the contract is production-safe and verified. In this branch, all four mandatory types MUST fail closed as missing source contracts. +- **FR-426-002**: Source contracts MUST come from the existing contract registry/patterns and route through `GraphClientInterface` or the repo-canonical provider client abstraction. +- **FR-426-003**: Implementation MUST NOT build endpoints by string-concatenating resource type names, guess runtime endpoints, bypass `GraphClientInterface`, fetch documentation at runtime, or treat synthetic/test evidence as source proof. +- **FR-426-004**: Allowed source classes are `tcm`, `graph_v1_fallback`, and repo-canonical provider contracts. `graph_beta_experimental` is allowed only behind an explicit blocker and cannot pass certification-readiness. +- **FR-426-005**: Missing contract, missing permission, unsupported source, experimental-only source, source unavailable, and provider failure MUST produce safe blocked/failed outcomes using repo-canonical outcome values. +- **FR-426-006**: Empty collections count as successful source-contract capture proof only when the source contract succeeded, permission was sufficient, provider response succeeded, and operation/capture proof exists. They MUST NOT create fake resource instances or fake per-resource evidence. In the current per-resource Coverage v2 evidence model, an empty collection does not satisfy per-resource content-backed readiness unless an existing repo-approved type-level evidence artifact can safely carry the explicit empty normalized payload and deterministic hash. + +### Evidence + +- **FR-426-007**: All four mandatory types MUST remain on the existing generic/shared Coverage v2 capture path. Until verified contracts exist, capture MUST stop before provider calls and record only blocked outcomes, not resources or evidence. +- **FR-426-008**: Persisted evidence MUST include or preserve repo-equivalent fields: `workspace_id`, `managed_environment_id`, `provider_connection_id`, `resource_id`, `resource_type_id`, `operation_run_id`, `source_contract_key`, `source_endpoint`, `source_version`, `source_schema_hash`, `source_metadata`, `raw_payload`, `normalized_payload`, `payload_hash`, `capture_outcome`, `evidence_state`, and `captured_at`. +- **FR-426-009**: No `tenant_id` platform-core ownership field, fallback ownership alias, or dual ownership truth may be introduced. +- **FR-426-010**: Payload hashes MUST be deterministic: same normalized payload produces the same hash, volatile metadata is excluded where configured, material field changes change the hash, and unordered provider collections are normalized where semantics are unordered. +- **FR-426-011**: Fake, synthetic, missing-contract, missing-permission, failed, or skipped evidence MUST NOT count as source-backed evidence. +- **FR-426-012**: OperationRun context and summaries MUST remain sanitized: flat numeric `summary_counts`, no raw payloads, no secrets, no raw permission context, no mail/chat/file/recording/transcript content. + +### Stable Identity + +- **FR-426-013**: `CoverageIdentityStrategyRegistry` or the repo-canonical equivalent MUST define stable identity strategies for `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy`. +- **FR-426-014**: Preferred identity is a stable provider/source ID. A documented immutable natural key is allowed only when proven stable within provider/environment scope. A deterministic composite key is allowed only if every component is stable and source-backed. +- **FR-426-015**: Display name, localized label, array index, priority/order, payload hash, operation run ID, random generated UUID, enabled state, conditions/actions/settings hash, domain type, default flag, description, and app/settings hash MUST NOT be sole stable identity. +- **FR-426-016**: Source-backed payloads for all four types MUST reach `stable` identity. `derived`, `identity_conflict`, `missing_external_id`, or `unsupported_identity` blocks certification readiness. +- **FR-426-017**: `CanonicalIdentityResolver` MUST be used; implementation must not create a separate Exchange/Teams identity path. + +### Normalization And Compare/Render Readiness + +- **FR-426-018**: Future verified source-backed payloads MUST normalize into the same comparable/renderable shape used by Spec 422, or through a strictly equivalent typed alignment step using existing `ExchangeTeamsComparablePayloadNormalizer`, `ExchangeTeamsCoverageComparator`, and `ExchangeTeamsRenderableSummaryBuilder` paths. +- **FR-426-019**: Persisted `normalized_payload` for the four target types MUST be typed enough for deterministic hashing and compare/render proof. If implementation retains a generic payload envelope, it must include the typed comparable shape or prove the typed shape is deterministically derived before hash/readiness evaluation. +- **FR-426-020**: Normalized payloads MUST include or derive `resource_type`/`canonical_type`, workload, source identity, safe display label, source contract, source class, material fields, unsupported-field diagnostics, and volatile-field exclusions. Capture timestamps MUST remain evidence metadata such as `captured_at` and MUST NOT be part of the normalized material payload or payload hash. +- **FR-426-021**: `transportRule` material fields include enabled/state, priority/order, conditions, actions, exceptions, mode/enforcement state, and scope where present. +- **FR-426-022**: `acceptedDomain` material fields include domain name, domain type, default-domain flag, and state where present. +- **FR-426-023**: `appPermissionPolicy` material fields include policy ID, display name, allowed apps, blocked apps, policy mode, and assignment/target summary when available from the same approved source. +- **FR-426-024**: `meetingPolicy` material fields include policy ID, display name, external/anonymous access, recording/transcription, lobby/admission, content sharing, and meeting chat/settings where present. +- **FR-426-025**: Unknown source-backed fields MUST be recorded as unsupported-field diagnostics and cannot be silently ignored when material to readiness. +- **FR-426-026**: The final readiness matrix may report `compare_render_ready` for the four types as a derived assertion from existing Coverage v2 evidence, identity, and compare/render proof. It MUST NOT introduce a new persisted readiness boolean/status family and MUST NOT report or assign `certified`. + +### Claim Guard And Safety + +- **FR-426-027**: Claim Guard MUST allow only internal/operator readiness wording when evidence, identity, and compare/render proof pass. +- **FR-426-028**: Claim Guard MUST block Certified Exchange / Teams Core Compare Pack, Certified Exchange, Certified Teams, full Exchange coverage, full Teams coverage, certified Microsoft 365 coverage, Exchange restore-ready, Teams restore-ready, and customer-ready Exchange/Teams proof. +- **FR-426-029**: For all four mandatory types after this post-review correction, the implementation report/readiness matrix MUST derive: `content_backed = false`, `identity_strategy_hardened = true`, `compare_render_ready = false`, `certified = false`, `restore_ready = false`, and `customer_claimable = false`. Future source-backed readiness requires verified contracts and a separate PASS gate. +- **FR-426-030**: Raw provider responses, raw payloads, access tokens, refresh tokens, ID tokens, client secrets, private keys, certificates, authorization headers, cookies, bearer tokens, mail body/subject content, Teams chat/message/file/recording/transcript content, and raw permission context MUST NOT be logged or default-rendered. + +## Non-Functional Requirements + +- **NFR-426-001 Security and privacy**: Logs, OperationRun context, rendered output, diagnostics, and implementation reports must not expose secrets, authorization material, raw provider responses, raw permission context, mail content, Teams chat/message/file/recording/transcript content, recordings, or transcripts. +- **NFR-426-002 Determinism**: Source-backed normalization, identity resolution, and payload hashing must be deterministic for the same provider payload after volatile-field exclusion and semantic ordering normalization. +- **NFR-426-003 Performance and provider cost**: Remote provider work must remain limited to the four mandatory resource types, run through the existing OperationRun-backed capture path, and avoid compare/render-time provider calls. +- **NFR-426-004 Observability**: Capture outcomes, evidence states, identity states, unsupported-field diagnostics, OperationRun linkage, and final blocker reasons must be explicit enough for release review without raw payload exposure. +- **NFR-426-005 Maintainability**: The implementation must extend existing Coverage v2 registries/services and avoid new table families, mini-platforms, broad provider frameworks, or parallel enum/status taxonomies. +- **NFR-426-006 Test isolation**: Tests must use fake provider responses and local fixtures only; no real provider calls, runtime documentation fetches, or network-dependent certification proof are allowed. + +## 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 + +Default decision: no new runtime UI surface and no rendered UI change. A tiny update to the existing Coverage v2 internal/operator surface is allowed only if implementation proves it is strictly needed, and only after this spec, plan, and tasks are amended before editing UI files. New Exchange/Teams dashboard, route, navigation, customer surface, certify action, restore/apply action, report, Review Pack output, export, or PDF output is forbidden. + +## 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)* + +- **Route/page/surface**: N/A - no reachable UI surface impact by default. +- **Current or new page archetype**: N/A. +- **Design depth**: N/A. +- **Repo-truth level**: N/A. +- **Existing pattern reused**: Existing Coverage v2 services and tests; no rendered surface change. +- **New pattern required**: none. +- **Screenshot required**: no unless runtime UI is amended into scope. +- **Page audit required**: no. +- **Customer-safe review required**: negative proof only; no customer-facing output or claim may appear. +- **Dangerous-action review required**: no dangerous/high-impact action in scope. +- **Coverage files updated or explicitly not needed**: + - [ ] `docs/ui-ux-enterprise-audit/route-inventory.md` + - [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` + - [ ] `docs/ui-ux-enterprise-audit/page-reports/...` + - [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md` + - [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md` + - [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md` + - [x] `N/A - no reachable UI surface impact` +- **No-impact rationale when applicable**: Evidence and identity readiness can be proven through shared services, persisted evidence, and tests without adding or changing a rendered surface. + +## 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 for default implementation; yes only if runtime UI is amended into scope. +- **Page archetype**: N/A. +- **Primary user question**: N/A. +- **Primary action**: N/A. +- **Surface budget result**: N/A. +- **Technical Annex / deep-link demotion**: Raw payloads, OperationRun links, source keys, provider IDs, permission context, and diagnostics remain service/test/internal proof only unless a future amended UI path demotes them behind existing internal details. +- **Canonical status vocabulary**: N/A for no rendered UI. If amended into UI, map to Product Surface vocabulary and never show certification, restore-ready, customer-ready, full Exchange, full Teams, or M365 certified claims. +- **Visible complexity impact**: neutral by default. +- **Product Surface exceptions**: none. + +## Browser Verification Plan *(mandatory)* + +- **Browser proof required?**: no by default; yes if runtime UI files or rendered readiness output change. +- **No-browser rationale**: `N/A - no rendered UI surface changed`. +- **Focused path when required**: Existing Coverage v2 readiness/operator route with seeded workspace, managed environment, provider connection, and the four blocked missing-contract Exchange/Teams outcomes with no resource/evidence rows. +- **Primary interaction to execute**: If UI changes are amended in, load the existing route, inspect blocked source-contract readiness, verify certification/restore/customer-ready/raw/secrets/mail/chat/content absent, and verify no console/Livewire/Filament errors. +- **Console, Livewire, Filament, network, and 500-error checks**: required only if browser proof is required. +- **Full-suite failure triage**: unrelated failures must be documented only after focused proof is green. + +## Human Product Sanity Check *(mandatory)* + +- **Required?**: no by default; yes if rendered UI changes. +- **No-human-sanity rationale**: `N/A - no product surface changed`. +- **Reviewer questions**: If amended into UI, reviewers must verify readiness is not confused with certification, restore readiness, full workload coverage, or customer proof; raw/technical details are demoted; and visible complexity is neutral or lower. +- **Planned result location**: implementation report if UI changes; otherwise N/A. + +## 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)* + +- **Cross-cutting feature?**: yes. +- **Interaction class(es)**: evidence capture, identity, claim wording, compare/render readiness, redaction, provider contract handling. +- **Systems touched**: `CoverageSourceContractResolver`, `config/graph_contracts.php`, `GenericContentEvidenceCaptureService` or equivalent capture path, `CoverageIdentityStrategyRegistry`, `CanonicalIdentityResolver`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `ExchangeTeamsComparablePayloadNormalizer`, `ExchangeTeamsCoverageComparator`, `ExchangeTeamsRenderableSummaryBuilder`, `ClaimGuard`, `ProviderGateway`, and focused tests. +- **Existing pattern(s) to extend**: Coverage v2 source contract, capture/evidence, identity, redaction, read-model, and Claim Guard paths. +- **Shared contract / presenter / builder / renderer to reuse**: Existing Tenant Configuration/Coverage v2 services. Do not create a separate Exchange/Teams engine. +- **Why the existing shared path is sufficient or insufficient**: Existing paths already own scope, provider connection, OperationRun, evidence rows, identity evaluation, redaction, and compare/render adapters. This branch keeps the four concrete source contracts blocked until verified provider contracts exist, adds stable identity strategies, and preserves typed normalization helper proof for future source-backed payloads. +- **Allowed deviation and why**: A small typed normalization adapter or capture-normalization handoff is allowed if the current generic normalizer cannot persist the Spec 422 comparable shape. +- **Consistency impact**: Outcome, evidence, identity, claim, source, and compare/render states must stay aligned with Coverage v2 enums and Claim Guard. +- **Review focus**: No endpoint guessing, no direct HTTP, no display-name identity, no fake evidence, no certification, no restore, no customer output, no raw payload/default proof, no `tenant_id`, no mini-platform. + +## OperationRun UX Impact *(mandatory when touched)* + +- **Touches OperationRun start/completion/link UX?**: no new UI start/completion/link UX. +- **Shared OperationRun UX contract/layer reused**: Existing capture OperationRun behavior only; no local UI composition. +- **Delegated start/completion UX behaviors**: N/A - no new start action. If an existing capture workflow is reused, it must continue to use the shared OperationRun path. +- **Local surface-owned behavior that remains**: none. +- **Queued DB-notification policy**: no new queued DB notification policy. +- **Terminal notification path**: existing central lifecycle mechanism only. +- **Exception required?**: none. + +Remote/provider capture is OperationRun-backed. Allowed operation type is existing `tenant_configuration.capture` or a repo-canonical equivalent. No raw payload, permission context, secrets, or mail/chat/content may enter OperationRun context/messages/notifications. + +## Provider Boundary / Platform Core Check *(mandatory when touched)* + +- **Shared provider/platform boundary touched?**: yes. +- **Boundary classification**: mixed. Source contracts and Exchange/Teams payload semantics are provider-owned. Coverage v2 evidence, identity state, claim state, read/model readiness, scope, and redaction are platform-core. +- **Seams affected**: graph contracts, source contract resolver, provider client path, identity strategies, typed normalization, compare/render readiness, Claim Guard wording, and evidence metadata. +- **Neutral platform terms preserved or introduced**: workspace, managed environment, provider connection, resource type, source contract, evidence, identity, claim, capture outcome, coverage level, compare/render readiness. +- **Provider-specific semantics retained and why**: Exchange and Teams resource names and payload fields are necessary to interpret the four mandatory source-backed types. +- **Why this does not deepen provider coupling accidentally**: No provider-native tenant ID ownership, no Exchange/Teams table family, no provider framework, no new dashboard/surface, no customer output, no restore/apply, and no certification in this spec. +- **Follow-up path**: Spec 427 must verify or keep blocked the exact four Exchange/Teams source contracts. Spec 428 may promote content-backed evidence only after verified contracts exist. Spec 429 may promote compare/render only after source-backed evidence exists. Exchange/Teams certification remains a later separate spec after those gates pass. Optional resource expansion, restore, customer reports, Review Pack/PDF output, and broader M365 certification require separate specs. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +N/A - no operator-facing surface change by default. If implementation amends UI into scope, reuse the existing Coverage v2 internal/operator Technical Annex surface only and complete the Product Surface/browser/human sanity requirements before editing runtime UI files. + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change by default. + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change by default. If amended, raw payloads, provider IDs, permission context, unsupported internals, source keys, OperationRun details, and provider diagnostics must remain hidden, collapsed, or support-gated. + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change by default. + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change by default. + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no. Evidence remains existing Coverage v2 resource/evidence truth. +- **New persisted entity/table/artifact?**: no. +- **New abstraction?**: no new broad abstraction. Existing registries/resolvers/normalizers may be extended narrowly for four concrete types. A small typed capture-normalization adapter is allowed only if existing normalizers cannot safely align source payloads to Spec 422 shape. +- **New enum/state/reason family?**: no by default. Use existing `CaptureOutcome`, `EvidenceState`, `IdentityState`, `CoverageLevel`, and `ClaimState` values. Enum/status additions require amendment and tests. +- **New cross-domain UI framework/taxonomy?**: no. +- **Current operator problem**: Exchange/Teams certification remains blocked because source-backed evidence and stable identity are unproven for the exact required denominator. +- **Existing structure is insufficient because**: Spec 422 typed compare/render helpers do not prove live/source-backed capture or stable canonical identity. Current source resolver and identity registry lack the four required entries. +- **Narrowest correct implementation**: Keep the four missing source contracts fail-closed until verified provider contracts exist, add four identity strategies for future valid source payloads, preserve typed normalization alignment, and add claim/redaction/no-overclaim tests; no new tables, UI, restore, certification, or customer output. +- **Ownership cost**: Four identity strategies, typed normalization/hash/redaction guards, and focused source-blocker tests must be maintained with future provider contract changes. +- **Alternative intentionally rejected**: Certify based on Spec 422 synthetic/existing rows or display-name identity. That would create unsafe overclaims and unstable resource matching. +- **Release truth**: Current-release prerequisite unblocker for a later certification spec. + +### Compatibility posture + +This feature assumes a pre-production environment. Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit for source contract resolver, capture eligibility, identity strategy, canonical identity, payload normalization, evidence hash, Claim Guard, and redaction. Feature for blocked capture with no fake evidence/readiness rows, OperationRun linkage, provider scope, no certification, no restore, no customer claim, no `tenant_id`, and no mini-platform. Browser only if UI changes. +- **Validation lane(s)**: fast-feedback for Unit/Feature; browser conditional; Pint dirty; diff check. +- **Why this classification and these lanes are sufficient**: The behavior is service, DB, contract, identity, and claim safety. Browser proof is needed only when rendered UI changes. +- **New or expanded test families**: Spec426 TenantConfiguration Unit/Feature tests and focused fixtures only. +- **Fixture / helper cost impact**: Add minimal fake provider payloads for four resource types. Avoid broad default workspace/provider helper expansion. +- **Heavy-family visibility / justification**: none unless UI is amended into scope. + +## Acceptance Criteria + +### Source Contracts + +- `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` fail closed as missing contracts in this branch because no production-safe source contract has been verified. +- No hardcoded endpoint guessing, direct HTTP bypass, Graph SDK bypass outside repo abstraction, or runtime documentation fetch. +- Missing contract, missing permission, unsupported source, experimental source, source unavailable, and provider failure are safe blocked/failed outcomes. + +### Evidence + +- All four types create no resource/evidence rows while their source contracts are missing. +- `raw_payload`, typed/usable `normalized_payload`, deterministic `payload_hash`, source metadata, capture outcome, evidence state, and `operation_run_id` remain required only when a verified future contract captures items. +- Empty collections are represented safely as source-contract capture proof when provider calls succeed, without fake resource instances or fake per-resource evidence. +- Fake/synthetic evidence never counts as source-backed. + +### Identity + +- All four types have stable identity strategies and use `CanonicalIdentityResolver`. +- Display-name-only, array-index, priority/order-only, payload-hash-only, random UUID, and operation-run identity are impossible as stable identity. +- Identity conflict, missing external ID, unsupported identity, or derived-only identity blocks certification readiness. + +### Compare / Render Readiness + +- Typed payload fixtures normalize into the Spec 422 compare/render-ready shape, but no source-backed compare/render readiness is claimed while contracts are missing. +- Unsupported fields are diagnostic and material unsupported fields block readiness. +- Existing Spec 422 compare/render pipeline remains valid. +- No certification is assigned. + +### Claim Guard + +- Internal evidence-ready, stable-identity-ready, and compare/render-ready wording is allowed only when proven; this branch does not prove source-backed evidence readiness for the four mandatory types. +- Certified, restore-ready, full Exchange, full Teams, full M365, and customer-ready claims are blocked. + +### Architecture And Safety + +- No `tenant_id`, no v1 adapter, no fallback reader, no dual write, no Exchange/Teams table family, no mini-platform, no new route/navigation/dashboard/customer output, and no restore/apply path. +- Raw payloads, secrets, permission context, mail content, and Teams content are not displayed/logged. +- Provider scope is enforced by workspace + managed environment + provider connection. + +### Validation + +- Focused unit tests pass. +- Focused feature tests pass. +- Browser proof passes if UI changes. +- Pint dirty passes. +- `git diff --check` passes. + +## Success Criteria + +- All four mandatory resource types remain blocked as missing source contracts until a verified source exists. +- All four mandatory resource types create no fake resources or fake evidence when capture is attempted without a verified contract. +- All four mandatory resource types have hardened identity strategies that reject display/name-like identity and prefer stable provider/source IDs or the approved accepted-domain natural key. +- Typed payload fixtures for all four types remain compatible with the existing Spec 422 compare/render pipeline, but no source-backed `compare_render_ready` result is claimed. +- Certification, restore readiness, customer claims, full Exchange/Teams claims, and full M365 claims remain blocked. +- No raw payload, secret, mail content, Teams content, raw permission context, `tenant_id` ownership path, Exchange/Teams table family, route/navigation/dashboard, customer output, or restore/apply path is introduced. + +## Risks + +| Risk | Severity | Mitigation | +|---|---|---| +| Certification wording or state sneaks in early | High | Claim Guard requirements, no-certification feature tests, and implementation-report proof require `certified = false`. | +| Fake/synthetic evidence is counted as source-backed | High | Source provenance tests and evidence requirements reject missing-contract, skipped, synthetic, or failed evidence as readiness proof. | +| Identity relies on display name, order, hash, or run ID | High | Identity strategy and canonical identity tests reject unstable identity inputs and block readiness. | +| Missing contract or missing permission is interpreted as empty configuration | High | Capture eligibility tests require blocked outcomes and explicit successful-empty-collection proof. | +| Raw payload, secrets, mail content, or Teams content leak into logs/UI/output | High | Redaction tests and OperationRun sanitization requirements block raw/default exposure. | +| Unsupported source-backed fields are silently ignored | High | Normalization tests require unsupported-field diagnostics and readiness blockers for material unsupported fields. | +| Workspace/environment/provider scope is crossed | High | Provider scope feature tests enforce same workspace, managed environment, provider connection, and RBAC semantics. | +| A separate Exchange/Teams platform grows inside Coverage v2 | Medium | Architecture guard tests forbid new table families, routes, dashboards, reports, exports, PDFs, restore paths, and mini-platform surfaces. | + +## Implementation Report Requirements + +The implementation report MUST include: + +- Candidate gate result. +- Dirty state before/after. +- Files changed. +- Source contract matrix. +- Evidence matrix. +- Identity matrix. +- Compare/render readiness matrix. +- Claim Guard proof. +- Redaction proof. +- No certification proof. +- No restore proof. +- No customer claim proof. +- No `tenant_id` confirmation. +- No mini-platform confirmation. +- Product Surface impact/no-impact. +- Tests run. +- Deferred work. + +Required source/evidence/identity matrix: + +| Workload | Resource Type | Source Contract | Evidence | Identity | Compare/Render Ready | Certified? | Blocker | +|---|---|---|---|---|---|---|---| +| Exchange | `transportRule` | | | | | No | | +| Exchange | `acceptedDomain` | | | | | No | | +| Teams | `appPermissionPolicy` | | | | | No | | +| Teams | `meetingPolicy` | | | | | No | | + +Required claim matrix: + +| Claim | Allowed? | Reason | +|---|---|---| +| Exchange/Teams core evidence-ready | Yes, if proven | Internal/operator readiness only | +| Exchange/Teams core stable-identity-ready | Yes, if proven | Internal/operator readiness only | +| Certified Exchange / Teams Core Compare Pack | No | Deferred until verified source contracts, content-backed evidence, and compare/render promotion pass in later specs | +| 100% Exchange coverage | No | Broad overclaim | +| 100% Teams coverage | No | Broad overclaim | +| Certified Microsoft 365 coverage | No | Broad overclaim | +| Exchange restore-ready | No | Restore out of scope | +| Teams restore-ready | No | Restore out of scope | +| Customer-ready Exchange/Teams proof | No | Customer output deferred | + +## Assumptions + +- Specs 414, 415, 417, 418, 419, 420, 422, and 425 remain accepted/completed dependency context. +- Existing Coverage v2 generic capture and canonical identity services are the only allowed foundation path. +- Source contracts can be represented through repo contract registry patterns without direct HTTP or endpoint guessing. +- No runtime UI change is required for this prep package unless later implementation proves otherwise and amends artifacts first. + +## Open Questions + +- Source contract support for all four mandatory types remains blocked until a future implementation proves production-safe provider contracts. This branch records the blocker instead of weakening evidence criteria or guessing endpoints. + +## Final Candidate Gate + +Choose one in the implementation report: `PASS`, `PASS WITH CONDITIONS`, or `FAIL`. + +`PASS` is not achieved by this branch. A future amended implementation can report `PASS` only when: + +- all four mandatory source contracts resolve through approved source paths, +- all four mandatory types persist source-backed evidence, +- all four mandatory types have stable identity strategies, +- source-backed payloads are compare/render-ready, +- certification remains blocked, +- restore remains blocked, +- customer claims remain blocked, +- no raw/secrets/mail/chat/content exposure, +- no `tenant_id` ownership path, +- no Exchange/Teams mini-platform, +- focused tests pass. + +`PASS WITH CONDITIONS` is allowed for this branch only as a closure gate when the implementation report states the condition explicitly: Spec 426 proves fail-safe behavior for Exchange/Teams source-backed evidence; it does not prove readiness, capture support, compare support, render support, or certification; typed normalizer/hash tests are future-contract helper proof only. + +`FAIL` is required if missing source contracts are treated as source-backed, fake/synthetic evidence is counted as source-backed, display name is used as stable identity, certification/restore/customer output is introduced, broad coverage is claimed, raw/secrets/content leak, `tenant_id` is introduced, a mini-platform appears, or tests do not prove the fail-safe evidence + identity posture. This branch intentionally records `PASS WITH CONDITIONS` for Spec 426 closure while source-backed evidence readiness remains safely blocked because verified contracts are not available. + +## Follow-Up Spec Candidates + +- Spec 427 - Exchange / Teams Verified Source Contract Enablement. +- Spec 428 - Exchange / Teams Content-Backed Evidence Promotion. +- Spec 429 - Exchange / Teams Compare/Render Promotion. +- Later spec - Exchange / Teams Certified Compare Pack. +- Security & Compliance certified/readiness hardening. +- M365 customer reporting Claim Guard pack. +- M365 pilot readiness gate. +- Exchange restore/apply. +- Teams restore/apply. +- Optional Exchange/Teams resource expansion. diff --git a/specs/426-exchange-teams-core-evidence-identity-readiness/tasks.md b/specs/426-exchange-teams-core-evidence-identity-readiness/tasks.md new file mode 100644 index 00000000..ec9ddf97 --- /dev/null +++ b/specs/426-exchange-teams-core-evidence-identity-readiness/tasks.md @@ -0,0 +1,242 @@ +# Tasks: Spec 426 - Exchange / Teams Core Evidence & Stable Identity Readiness + +**Input**: Design documents from `specs/426-exchange-teams-core-evidence-identity-readiness/` +**Prerequisites**: [spec.md](./spec.md), [plan.md](./plan.md), [checklists/requirements.md](./checklists/requirements.md) + +**Tests**: Required. This spec changes runtime source-contract, capture/evidence, identity, readiness, and claim-safety behavior. Use focused Pest Unit/Feature tests first. Browser proof is required only if rendered UI changes. + +**Implementation note**: Post-review correction removed unverified Graph source contracts for the four mandatory types. Fake provider payloads remain only in unit-level normalization/hash/redaction tests; capture/evidence feature tests now prove missing-contract fail-closed behavior, no provider call, and no fake resource/evidence rows. OperationRun blocked proof is covered inside `Spec426ExchangeTeamsCoreEvidenceReadinessTest.php`; no separate OperationRun test file was needed. + +## Test Governance Checklist + +- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit. +- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented. +- [x] Planned validation commands cover the change without pulling in unrelated lane cost. +- [x] Browser proof is explicitly `N/A - no rendered UI surface changed` unless rendered UI changes. +- [x] Human Product Sanity and Product Surface implementation-report close-out are planned if UI changes. +- [x] Any material budget, baseline, trend, or escalation note is recorded in the implementation report. + +## Phase 1: Hard Preflight + +**Purpose**: Re-check the prerequisite gate before runtime implementation. Stop before code changes if this phase fails. + +- [x] T001 Capture current branch, HEAD, and `git status --short` in `specs/426-exchange-teams-core-evidence-identity-readiness/implementation-report.md`. +- [x] T002 Record activated skills/gates and hard-gate stop condition status in the implementation report. +- [x] T003 Confirm Specs 414, 415, 417, 418, 419, 420, 422, and 425 remain completed/read-only dependency context; do not edit their artifacts. +- [x] T004 Confirm current Coverage v2 infrastructure exists: `CoverageSourceContractResolver`, `CoverageIdentityStrategyRegistry`, `GenericContentEvidenceCaptureService`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `CanonicalIdentityResolver`, `SupportedScopeResolver`, `ClaimGuard`, `OperationRunService`, and `GraphClientInterface`/provider gateway. +- [x] T005 Confirm current source contract gaps for `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy`. +- [x] T006 Confirm current identity strategy gaps for `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy`. +- [x] T007 Confirm Coverage v2 ownership paths use `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id`, not `tenant_id`. +- [x] T008 Stop and report the blocker before implementation if generic capture, canonical identity, source contract resolution, or provider-scope infrastructure is missing. + +**Checkpoint**: Source/capture/identity foundation exists or implementation stops. + +## Phase 2: Fixtures And Failing Tests + +**Purpose**: Add focused proof before or alongside runtime changes. + +- [x] T009 [P] Cover `transportRule` normal, volatile, unsupported-field, missing/display-only identity, material-change, and redaction cases with inline fake provider payloads in Spec 426 tests. +- [x] T010 [P] Cover `acceptedDomain` normal, volatile, natural-key identity, display-only identity, and redaction cases with inline fake provider payloads in Spec 426 tests. +- [x] T011 [P] Cover `appPermissionPolicy` normal, unsupported-field, missing/display-only stable ID, app-list material changes, and redaction cases with inline fake provider payloads in Spec 426 tests. +- [x] T012 [P] Cover `meetingPolicy` normal, volatile, missing/display-only stable ID, recording/transcription material changes, and redaction cases with inline fake provider payloads in Spec 426 tests. +- [x] T013 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php` proving all four mandatory types fail closed as missing contracts and non-selected missing contracts also fail closed. +- [x] T014 [P] Cover capture eligibility through `Spec426ExchangeTeamsCoreEvidenceReadinessTest.php`, `Spec426ExchangeTeamsProviderScopeTest.php`, and updated Spec 420 missing-contract tests. +- [x] T015 [P] Cover identity strategy registration in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsCanonicalIdentityTest.php` and updated `Spec417CoverageIdentityStrategyRegistryTest.php`. +- [x] T016 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsCanonicalIdentityTest.php` proving `CanonicalIdentityResolver` produces stable identity for valid source payloads and blocks missing/unsupported identity. +- [x] T017 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsPayloadNormalizationFromSourceTest.php` proving typed helper payloads normalize into the Spec 422 shape with material fields and unsupported-field diagnostics, without claiming source-backed readiness. +- [x] T018 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsEvidenceHashTest.php` proving deterministic payload hashes, volatile-field exclusion, and material-change hash changes. +- [x] T019 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsClaimGuardReadinessTest.php` proving evidence-ready/stable-identity-ready/internal compare-render wording is allowed only when proven and certification/restore/customer/full coverage claims are blocked. +- [x] T020 [P] Add `apps/platform/tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsRedactionTest.php` proving provider/raw/content leakage is removed from normalized/readiness output. +- [x] T021 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php` proving blocked capture for all four types creates no provider call, no resource rows, and no evidence rows. +- [x] T022 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsStableIdentityReadinessTest.php` proving blocked source types cannot create stable identity readiness rows from fake/display-only payloads. +- [x] T023 [P] Cover operation run linkage, sanitized context, numeric summary counts, and no raw/secrets/content in run context in `Spec426ExchangeTeamsCoreEvidenceReadinessTest.php`. +- [x] T024 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsProviderScopeTest.php` proving outsider 404, readonly/capability denial 403, and cross-workspace/cross-environment provider connection rejection. +- [x] T025 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsClaimGuardFeatureTest.php` proving internal readiness claims are bounded and certification/restore/customer claims remain blocked. +- [x] T026 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoCertificationTest.php` proving no certified level, certified supported scope, or certification evaluator/result is introduced. +- [x] T027 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoRestoreTest.php` proving no restore/apply/assisted restore/action/restorable state is introduced. +- [x] T028 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoTenantIdTest.php` proving Spec 426 runtime changes do not introduce `tenant_id`. +- [x] T029 [P] Add `apps/platform/tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoMiniPlatformTest.php` proving no Exchange/Teams migration, table family, model, route, navigation, Filament Resource/Page, dashboard, report, export, PDF, or customer surface is added. +- [x] T030 [P] Add fail-hard provider/HTTP assertions proving readiness/compare/render evaluation makes no remote call and capture calls provider only through the repo provider abstraction. + +**Checkpoint**: New focused tests fail for missing implementation and pass after later phases. + +## Phase 3: Source Contract Mappings + +**Purpose**: Keep the exact four resource types fail-closed until production-safe source contracts exist. + +- [x] T031 Remove unverified `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` source contracts from `apps/platform/config/graph_contracts.php`. +- [x] T032 Do not invent source class metadata, Graph version, resource path, response shape, or read permission metadata for unverified endpoints. +- [x] T033 Update `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` so all four canonical types explicitly fail closed as missing source contracts. +- [x] T034 Keep `acceptedDomain` and `appPermissionPolicy` in the missing-contract blocker list until verified contracts exist and tests prove resolution. +- [x] T035 Ensure `transportRule` and `meetingPolicy` also fail closed as missing source contracts. +- [x] T036 Preserve fail-closed behavior for missing contracts, unsupported sources, beta-only sources, and missing Graph contract resources. +- [x] T037 Ensure no implementation builds URLs by resource type string concatenation, bypasses `GraphClientInterface`, uses direct HTTP, or calls Microsoft documentation/runtime discovery. +- [x] T038 If any mandatory type is beta/experimental-only, mark it `certification_blocked_experimental_source` or repo-equivalent in readiness output and fail the final certification-readiness gate. + +**Checkpoint**: Source contract resolver tests pass for all four mandatory types by proving blocked missing-contract behavior. + +## Phase 4: Capture Eligibility And Content-Backed Evidence + +**Purpose**: Preserve shared Coverage v2 capture behavior without fake source-backed evidence. + +- [x] T039 Update `apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php` only if needed to route the four types through existing capture with correct contract options and empty collection handling. +- [x] T040 Ensure missing permission returns repo-canonical `capture_blocked_permission` and does not record fake captured evidence. +- [x] T041 Ensure missing contract returns `capture_blocked_missing_contract` and does not silently skip or treat empty as no configuration. +- [x] T042 Ensure unsupported or beta-disabled source returns repo-canonical blocked outcome and cannot mark certification-ready. +- [x] T043 Persist no source-backed `raw_payload`, source metadata, `operation_run_id`, `capture_outcome`, `evidence_state`, or `captured_at` rows while contracts are missing. +- [x] T044 Represent successful empty collections as OperationRun/source-contract capture proof, and only persist explicit empty normalized payload/hash when an existing repo-approved type-level evidence artifact can safely carry it; do not create fake resource instances or fake per-resource evidence. +- [x] T045 Ensure `operation_runs.summary_counts` remains flat numeric-only and no raw payload, secrets, permission context, mail content, Teams content, or provider response body enters run context/messages. +- [x] T046 Ensure capture remains scoped to same workspace, managed environment, and provider connection. + +**Checkpoint**: Blocked capture feature tests pass for all four mandatory types and prove no fake evidence. + +## Phase 5: Typed Normalization And Hashing + +**Purpose**: Preserve typed helper normalization and hashing for future verified source payloads without claiming source-backed readiness. + +- [x] T047 Verify `apps/platform/app/Services/TenantConfiguration/ExchangeTeamsComparablePayloadNormalizer.php` helper coverage for selected Exchange/Teams fields without adding unverified source contracts. +- [x] T048 Add a typed normalization handoff from capture to evidence persistence if generic normalized payloads cannot satisfy Spec 426 hashing/readiness requirements. +- [x] T049 Ensure normalized payloads include or deterministically derive `canonical_type`, workload, source identity, safe display label, source contract, source class, material fields, unsupported fields, and volatile fields excluded; keep capture timestamps in evidence metadata such as `captured_at`, outside the normalized material payload and payload hash. +- [x] T050 Ensure `transportRule` preserves enabled/state, priority/order, conditions, actions, exceptions, mode/enforcement state, and scope where present. +- [x] T051 Ensure `acceptedDomain` preserves domain name, domain type, default-domain flag, and state where present. +- [x] T052 Ensure `appPermissionPolicy` preserves policy ID, display name, allowed apps, blocked apps, policy mode, and assignment/target summary when available from the same approved source. +- [x] T053 Ensure `meetingPolicy` preserves policy ID, display name, external/anonymous access, recording/transcription, lobby/admission, content sharing, and meeting chat/settings where present. +- [x] T054 Ensure unsupported helper/source fields are recorded as diagnostics and cannot be used to claim readiness while contracts are missing. +- [x] T055 Ensure deterministic payload hashing is based on normalized material truth, excludes configured volatile fields, and handles semantically unordered collections consistently. + +**Checkpoint**: Normalization and evidence-hash unit tests pass. + +## Phase 6: Stable Identity Strategies + +**Purpose**: Add stable identity for the four mandatory types. + +- [x] T056 Update `apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php` with a stable identity strategy for `transportRule`. +- [x] T057 Update `CoverageIdentityStrategyRegistry.php` with a stable identity strategy for `acceptedDomain`. +- [x] T058 Update `CoverageIdentityStrategyRegistry.php` with a stable identity strategy for `appPermissionPolicy`. +- [x] T059 Update `CoverageIdentityStrategyRegistry.php` with a stable identity strategy for `meetingPolicy`. +- [x] T060 Prefer stable source/provider ID fields. Use an immutable natural key or stable composite only when source-backed and proven unique within provider/environment scope. +- [x] T061 Reject display name, localized label, array index, priority/order, payload hash, operation run ID, random UUID, description, settings hash, domain type, default flag, conditions/actions hash, or enabled state as sole stable identity. +- [x] T062 Ensure `CanonicalIdentityResolver` remains the only identity resolution path and identity conflicts block readiness. +- [x] T063 Ensure derived, missing external ID, unsupported identity, or identity conflict blocks certification readiness for any mandatory type. + +**Checkpoint**: Identity registry and canonical identity tests pass. + +## Phase 7: Compare / Render Readiness + +**Purpose**: Prove typed helper compatibility while preventing source-backed compare/render overclaims. + +- [x] T064 Ensure typed `transportRule` payload fixtures can be compared and rendered by existing Exchange/Teams services without claiming source-backed readiness. +- [x] T065 Ensure typed `acceptedDomain` payload fixtures can be compared and rendered without claiming source-backed readiness. +- [x] T066 Ensure typed `appPermissionPolicy` payload fixtures can be compared and rendered without claiming source-backed readiness. +- [x] T067 Ensure typed `meetingPolicy` payload fixtures can be compared and rendered without claiming source-backed readiness. +- [x] T068 Derive compare/render readiness only from existing Coverage v2 evidence, identity, and compare/render states/services when source-backed content evidence and stable identity exist; do not add a new persisted readiness boolean/status family. +- [x] T069 Ensure derived compare/render readiness does not set `CoverageLevel::Certified`, does not create a certified supported scope, and does not allow customer claims. + +**Checkpoint**: Compare/render readiness tests pass and no certification appears. + +## Phase 8: Claim Guard And Redaction + +**Purpose**: Keep claims conservative and evidence safe. + +- [x] T070 Update `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php` only as needed to allow internal/operator readiness wording after proof. +- [x] T071 Block Certified Exchange / Teams Core Compare Pack wording until Spec 427. +- [x] T072 Block Certified Exchange, Certified Teams, full Exchange coverage, full Teams coverage, certified Microsoft 365 coverage, Exchange restore-ready, Teams restore-ready, and customer-ready Exchange/Teams proof. +- [x] T073 Ensure claim state for the four types remains internal/operator-only, non-customer-claimable, and non-restorable. +- [x] T074 Ensure redaction removes access tokens, refresh tokens, ID tokens, client secrets, passwords, private keys, certificates, authorization headers, cookies, bearer tokens, raw payload markers, raw permission context, mail content, and Teams content from logs/render/readiness output. + +**Checkpoint**: Claim Guard and redaction tests pass. + +## Phase 9: Product Surface Decision + +**Purpose**: Keep UI scope bounded. + +- [x] T075 Determine whether implementation changed any runtime UI file, route, navigation, action, Filament page/resource/widget, Blade view, report, export, or customer output. +- [x] T076 If no UI changed, record `N/A - no rendered UI surface changed` and Product Surface exceptions `none` in the implementation report. +- [x] T077 If UI change is needed, stop before editing runtime UI files and amend `spec.md`, `plan.md`, and `tasks.md` with exact affected surfaces, Product Surface decisions, browser proof path, and Human Product Sanity criteria. +- [x] T078 If amended UI work proceeds, update only the existing Coverage v2 internal/operator surface; do not add new route, navigation, Exchange/Teams dashboard, customer page, certify action, restore action, report, export, Review Pack, or PDF. +- [x] T079 If UI changes proceed, add `apps/platform/tests/Browser/Spec426ExchangeTeamsCoreEvidenceReadinessSmokeTest.php` proving evidence readiness visible, identity readiness visible, certification absent, restore-ready absent, customer-ready absent, raw payload absent, mail/chat/content absent, secrets absent, and no console/Livewire/Filament errors. + +**Checkpoint**: Product Surface decision is explicit and not contradicted by changed files. + +## Phase 10: Architecture And Safety Guards + +**Purpose**: Prove no hidden scope expansion or ownership drift. + +- [x] T080 Ensure no migration creates Exchange-specific or Teams-specific table families. +- [x] T081 Ensure no code introduces `tenant_id` as Coverage v2 ownership truth, compatibility alias, fallback reader, dual-write target, or parallel scope key. +- [x] T082 Ensure no restore/apply, preview restore, assisted restore, or restore-readiness code path is introduced. +- [x] T083 Ensure no customer output, Review Pack, rendered report, management PDF, export/download, legal/regulatory attestation, or customer-ready proof path is introduced. +- [x] T084 Ensure no new Filament Resource/Page/Widget, route, navigation item, dashboard, or primary Exchange/Teams surface is introduced. +- [x] T085 Ensure no v1-to-v2 adapter, old gap taxonomy dependency, fallback reader, or dual truth path is introduced. +- [x] T086 Ensure no additional Exchange/Teams resource type is silently registered as in-scope readiness/certification work. + +**Checkpoint**: No-overreach feature/static tests pass. + +## Phase 11: Implementation Report And Validation + +**Purpose**: Close the evidence/identity readiness contract. + +- [x] T087 Create `specs/426-exchange-teams-core-evidence-identity-readiness/implementation-report.md`. +- [x] T088 Record candidate gate result, dirty state before/after, branch, HEAD, activated skills, files changed, and completed-spec rewrite assertion. +- [x] T089 Complete the source contract matrix for `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy`. +- [x] T090 Complete the evidence matrix, including capture outcome, raw payload, normalized payload, payload hash, source metadata, empty collection behavior, and OperationRun linkage. +- [x] T091 Complete the identity matrix, including strategy identifier, identity state, source identity field/key kind, conflict behavior, and blocker status. +- [x] T092 Complete the compare/render readiness matrix and prove `compare_render_ready = No` and `certified = No` for all four types while contracts are missing. +- [x] T093 Complete the Claim Guard matrix from `spec.md`. +- [x] T094 Record redaction proof, no certification proof, no restore proof, no customer claim proof, no `tenant_id` proof, no mini-platform proof, Product Surface decision, tests run, and deferred work. +- [x] T095 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T096 Run focused unit tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php`. +- [x] T097 Run focused unit tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsCanonicalIdentityTest.php`. +- [x] T098 Run focused unit tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsPayloadNormalizationFromSourceTest.php tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsEvidenceHashTest.php tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsClaimGuardReadinessTest.php tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsRedactionTest.php`. +- [x] T099 Run focused feature tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php`. +- [x] T100 Run focused feature tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec426ExchangeTeamsProviderScopeTest.php`. +- [x] T101 Run focused feature tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec426ExchangeTeamsStableIdentityReadinessTest.php`. +- [x] T102 Run focused feature/static tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoCertificationTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoRestoreTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsNoMiniPlatformTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsClaimGuardFeatureTest.php`. +- [x] T103 Run regression tests for superseded expectations: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php tests/Feature/TenantConfiguration/Spec420M365CaptureOperationRunTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php`. +- [x] T104 Record combined `--filter=Spec426` Signal 9 and equivalent direct-file Spec 426 passing after the post-review correction in the implementation report. +- [x] T105 Confirm browser proof is `N/A - no rendered UI surface changed`. +- [x] T106 Confirm no separate Spec 426 browser smoke file is required because no runtime UI changed. +- [x] T107 Confirm Product Surface exceptions are `none`. +- [x] T108 Confirm no Livewire/Filament provider registration, global search, destructive action, or asset strategy changed. +- [x] T109 Confirm no migration/env/queue/scheduler/storage/deployment action beyond normal config deploy is required. +- [x] T110 Confirm updated Spec 420/417 runtime tests pass against current semantics. +- [x] T111 Confirm active spec/plan/tasks/report state the no-browser/no-UI decision. +- [x] T112 Confirm all focused Spec 426 source, identity, capture, claim, redaction, no-certification, no-restore, no-tenant-id, and no-mini-platform checks pass in split runs. +- [x] T113 Browser validation: `N/A - no rendered UI surface changed`. +- [x] T114 Run `git diff --check`. +- [x] T115 Record any failed validation exactly in the implementation report; do not weaken source, evidence, identity, claim, redaction, ownership, no-certification, no-restore, or no-mini-platform criteria to make tests pass. + +**Checkpoint**: Focused validation passes or exact failures are documented. + +## Dependencies & Execution Order + +- Phase 1 blocks all runtime implementation. +- Phase 2 tests should be added before or alongside implementation phases. +- Phase 3 source contract blockers prevent Phase 4 captured evidence until verified contracts exist. +- Phase 4 captured evidence blocks Phase 5 typed hash/readiness proof. +- Phase 6 identity strategies block final readiness pass. +- Phase 7 compare/render readiness depends on evidence + identity. +- Phase 9 must complete before any runtime UI edits. +- Phase 11 completes after implementation and validation. + +## Parallel Opportunities + +- T009-T012 can run in parallel because they cover separate inline fake payload categories. +- T013-T020 can run in parallel after preflight because they touch separate unit test files. +- T021-T030 can run in parallel after preflight because they touch separate feature/static guard tests. +- T056-T059 should be coordinated because they share `CoverageIdentityStrategyRegistry.php`. +- T095-T114 validation commands should run after implementation stabilizes. + +## Stop Conditions + +- A mandatory resource type lacks production-safe source contract support and is treated as source-backed or compare/render-ready. +- A mandatory resource type lacks stable source-backed identity. +- A source is beta/experimental-only but is treated as certification-ready. +- Synthetic/fake evidence is counted as source-backed. +- Display name, order, payload hash, operation run ID, random UUID, or array index is used as stable identity. +- Capture bypasses provider gateway/GraphClientInterface or guesses endpoints. +- Certification, restore/apply, customer output, Review Pack/report/PDF/export, full workload/M365 claim, new route/navigation/dashboard, or mini-platform appears. +- `tenant_id` is introduced as platform-core ownership truth or compatibility/fallback path. +- Raw payload, secrets, mail content, Teams content, or raw permission context leak into logs/UI/output. +- Tests cannot prove evidence + identity readiness.