feat: complete spec 422 exchange teams comparable renderable pack
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 6m9s

This commit is contained in:
Ahmed Darrazi 2026-06-30 06:18:15 +02:00
parent 69d4ecbbd2
commit 4c1e14c6bc
30 changed files with 3901 additions and 44 deletions

View File

@ -50,11 +50,15 @@ public function table(Table $table): Table
->columns([
TextColumn::make('source_display_name')
->label('Resource')
->state(fn (TenantConfigurationResource $record): string => (string) (
$record->source_display_name
?: $record->resourceType?->display_name
?: 'Unnamed resource'
))
->searchable()
->sortable()
->description(fn (TenantConfigurationResource $record): string => (string) $record->canonical_resource_key)
->limit(48)
->tooltip(fn (TenantConfigurationResource $record): string => (string) ($record->source_display_name ?: $record->canonical_resource_key))
->tooltip(fn (TenantConfigurationResource $record): ?string => filled($record->source_display_name) ? (string) $record->source_display_name : null)
->action($this->inspectAction()),
TextColumn::make('resourceType.display_name')
->label('Resource type')

View File

@ -198,9 +198,24 @@ private function hasUnsafeBroadCoverageClaim(array $tokens, bool $registryScoped
return true;
}
if ($this->hasComparableRenderableTerm($tokens)
&& ($hasWorkloadReference || $hasCoverageSurface || $registryScoped)
&& ! $this->hasScopedInternalComparableRenderableClaim($tokens)
) {
return true;
}
if ($hasWorkloadReference
&& $hasCoverageSurface
&& ! $registryScoped
&& ! $this->hasScopedInternalComparableRenderableClaim($tokens)
) {
return true;
}
if ($this->hasAnyToken($tokens, ['full', 'complete', 'all'])
&& ($hasCoverageSurface || $this->hasToken($tokens, 'tenant'))
&& ($hasWorkloadReference || $this->hasToken($tokens, 'tenant'))) {
&& ($hasWorkloadReference || $registryScoped || $this->hasToken($tokens, 'tenant'))
) {
return true;
}
@ -213,7 +228,7 @@ private function hasUnsafeBroadCoverageClaim(array $tokens, bool $registryScoped
private function hasScopedInternalComparableRenderableClaim(array $tokens): bool
{
return $this->hasToken($tokens, 'selected')
&& $this->hasToken($tokens, 'entra')
&& $this->hasAnyToken($tokens, ['entra', 'exchange', 'teams'])
&& ($this->hasToken($tokens, 'comparable') || $this->hasToken($tokens, 'renderable'))
&& ($this->hasToken($tokens, 'internal') || $this->hasToken($tokens, 'operator'));
}
@ -268,6 +283,14 @@ private function hasCustomerReadyTerm(array $tokens): bool
&& $this->hasAnyToken($tokens, ['ready', 'readiness', 'proof']);
}
/**
* @param list<string> $tokens
*/
private function hasComparableRenderableTerm(array $tokens): bool
{
return $this->hasAnyToken($tokens, ['comparable', 'renderable']);
}
/**
* @param list<string> $tokens
*/

View File

@ -20,6 +20,7 @@ final class CoverageEvidenceWriter
{
public function __construct(
private readonly EntraRenderableSummaryBuilder $entraSummaryBuilder,
private readonly ExchangeTeamsRenderableSummaryBuilder $exchangeTeamsSummaryBuilder,
) {}
/**
@ -108,6 +109,10 @@ private function coverageLevelFor(TenantConfigurationResourceType $resourceType,
return CoverageLevel::Renderable;
}
if ($this->exchangeTeamsSummaryBuilder->canBuild($canonicalType, $normalizedPayload)) {
return CoverageLevel::Renderable;
}
return CoverageLevel::ContentBacked;
}

View File

@ -30,6 +30,8 @@ final class CoverageV2ReadinessReadModel
public function __construct(
private readonly EntraRenderableSummaryBuilder $entraSummaryBuilder,
private readonly EntraCoverageComparator $entraCoverageComparator,
private readonly ExchangeTeamsRenderableSummaryBuilder $exchangeTeamsSummaryBuilder,
private readonly ExchangeTeamsCoverageComparator $exchangeTeamsCoverageComparator,
) {}
/**
@ -321,6 +323,16 @@ private function typedRenderSummary(TenantConfigurationResource $resource): ?arr
'claim_state' => $resource->latest_claim_state,
'identity_state' => $resource->latest_identity_state,
'last_captured' => $resource->latest_captured_at?->toDayDateTimeString(),
'source_version' => $evidence->source_version,
'source_schema_hash' => $evidence->source_schema_hash,
]);
$summary ??= $this->exchangeTeamsSummaryBuilder->build($canonicalType, $evidence->normalized_payload, [
'claim_state' => $resource->latest_claim_state,
'identity_state' => $resource->latest_identity_state,
'last_captured' => $resource->latest_captured_at?->toDayDateTimeString(),
'source_version' => $evidence->source_version,
'source_schema_hash' => $evidence->source_schema_hash,
]);
if ($summary === null) {
@ -370,7 +382,7 @@ private function compareSummary(
];
}
$result = $this->entraCoverageComparator->compare(
$result = $this->compareEvidencePayloads(
$canonicalType,
$previousEvidence->normalized_payload,
$latestEvidence->normalized_payload,
@ -406,6 +418,30 @@ private function compareSummary(
];
}
/**
* @param array<string, mixed> $previousPayload
* @param array<string, mixed> $latestPayload
* @return array<string, mixed>
*/
private function compareEvidencePayloads(string $canonicalType, array $previousPayload, array $latestPayload): array
{
if ($this->entraSummaryBuilder->supports($canonicalType)) {
return $this->entraCoverageComparator->compare($canonicalType, $previousPayload, $latestPayload);
}
if ($this->exchangeTeamsSummaryBuilder->supports($canonicalType)) {
return $this->exchangeTeamsCoverageComparator->compare($canonicalType, $previousPayload, $latestPayload);
}
return [
'canonical_type' => $canonicalType,
'supported' => false,
'classification' => 'unsupported_field',
'changed' => false,
'changes' => [],
];
}
private function previousComparableEvidence(
TenantConfigurationResource $resource,
TenantConfigurationResourceEvidence $latestEvidence,

View File

@ -0,0 +1,832 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class ExchangeTeamsComparablePayloadNormalizer
{
/**
* @var list<string>
*/
private const SUPPORTED_TYPES = [
'transportRule',
'acceptedDomain',
'appPermissionPolicy',
'meetingPolicy',
];
/**
* @var list<string>
*/
private const VOLATILE_ROOT_FIELDS = [
'@odata.context',
'@odata.etag',
'createdAt',
'createdDateTime',
'created_at',
'graphContext',
'lastModifiedDateTime',
'modifiedDateTime',
'sourceMetadata',
'source_metadata',
'tcmContext',
'updatedAt',
'updatedDateTime',
'updated_at',
'version',
'whenChanged',
];
/**
* @var array<string, list<string>>
*/
private const SUPPORTED_ROOT_FIELDS = [
'transportRule' => [
'@odata.context',
'@odata.etag',
'Actions',
'ApplyHtmlDisclaimerFallbackAction',
'ApplyHtmlDisclaimerLocation',
'Conditions',
'DeleteMessage',
'DisplayName',
'Enabled',
'ExceptIfFrom',
'ExceptIfRecipientDomainIs',
'Exceptions',
'From',
'Identity',
'Mode',
'Name',
'Priority',
'RecipientDomainIs',
'RedirectMessageTo',
'SenderDomainIs',
'SentTo',
'State',
'SubjectContainsWords',
'actions',
'conditions',
'createdDateTime',
'displayName',
'enabled',
'exceptions',
'id',
'mode',
'modifiedDateTime',
'name',
'priority',
'ruleMode',
'state',
],
'acceptedDomain' => [
'@odata.context',
'@odata.etag',
'Default',
'DisplayName',
'DomainName',
'DomainType',
'Identity',
'IsDefault',
'Name',
'State',
'Status',
'createdDateTime',
'default',
'displayName',
'domainName',
'domainType',
'id',
'isDefault',
'modifiedDateTime',
'name',
'state',
'status',
'type',
],
'appPermissionPolicy' => [
'@odata.context',
'@odata.etag',
'AllowAllApps',
'AllowAppList',
'AssignedGroups',
'AssignedUsers',
'BlockAppList',
'Description',
'DisplayName',
'GlobalCatalogAppsType',
'Identity',
'Name',
'PrivateCatalogAppsType',
'allowedAppIds',
'allowedApps',
'allowAppList',
'assignments',
'assignedGroups',
'assignedUsers',
'blockedAppIds',
'blockedApps',
'blockAppList',
'createdDateTime',
'displayName',
'id',
'mode',
'modifiedDateTime',
'name',
'policyMode',
'targets',
],
'meetingPolicy' => [
'@odata.context',
'@odata.etag',
'AllowAnonymousUsersToJoinMeeting',
'AllowCloudRecording',
'AllowExternalParticipantGiveRequestControl',
'AllowMeetingReactions',
'AllowParticipantGiveRequestControl',
'AllowPowerPointSharing',
'AllowPSTNUsersToBypassLobby',
'AllowSharedNotes',
'AllowTranscription',
'AllowWhiteboard',
'AutoAdmittedUsers',
'ContentSharing',
'Description',
'DisplayName',
'ExternalAccess',
'Identity',
'LobbyBypassSettings',
'MeetingRecordingExpirationDays',
'Name',
'RecordingTranscription',
'ScreenSharingMode',
'State',
'allowAnonymousUsersToJoinMeeting',
'allowCloudRecording',
'allowExternalParticipantGiveRequestControl',
'allowMeetingReactions',
'allowParticipantGiveRequestControl',
'allowPowerPointSharing',
'allowPSTNUsersToBypassLobby',
'allowSharedNotes',
'allowTranscription',
'allowWhiteboard',
'autoAdmittedUsers',
'contentSharing',
'createdDateTime',
'displayName',
'externalAccess',
'id',
'lobbyBypassSettings',
'meetingRecordingExpirationDays',
'modifiedDateTime',
'name',
'recordingTranscription',
'screenSharingMode',
'state',
],
];
/**
* @var list<string>
*/
private const SENSITIVE_CONTENT_KEYS = [
'auditmetadata.rawpayload',
'body',
'chatcontent',
'chatmessage',
'filecontent',
'mailbody',
'mailcontent',
'messagebody',
'mimecontent',
'operationruncontext',
'providerresponse',
'rawpayload',
'recordingcontent',
'recordingtranscript',
'recordingurl',
'transcriptcontent',
'transcripttext',
];
/**
* @var list<string>
*/
private const SENSITIVE_CONTENT_KEY_PARTS = [
'applyhtmldisclaimertext',
'attachmentcontainswords',
'attachmentextensionmatcheswords',
'attachmentmatchespatterns',
'attachmentnamematchespatterns',
'attachmentpropertycontainswords',
'bodycontainswords',
'bodymatchespatterns',
'disclaimertext',
'headercontainsmessageheader',
'headercontainswords',
'headermatchespatterns',
'messageheadercontainswords',
'messageheadermatchespatterns',
'prependsubject',
'setheadername',
'setheadervalue',
'subjectcontainswords',
'subjectmatchespatterns',
'subjectorbodycontainswords',
'subjectorbodymatchespatterns',
];
public function __construct(
private readonly CoveragePayloadRedactor $redactor,
) {}
public function supports(string $canonicalType): bool
{
return in_array($canonicalType, self::SUPPORTED_TYPES, true);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function normalize(string $canonicalType, array $payload): array
{
if (! $this->supports($canonicalType)) {
return [
'canonical_type' => $canonicalType,
'supported' => false,
'diagnostics' => [
'unsupported_fields' => [],
'redacted_fields' => [],
'volatile_fields' => [],
],
];
}
$redacted = $this->redactedPayload($payload);
return match ($canonicalType) {
'transportRule' => $this->normalizeTransportRule($payload, $redacted),
'acceptedDomain' => $this->normalizeAcceptedDomain($payload, $redacted),
'appPermissionPolicy' => $this->normalizeAppPermissionPolicy($payload, $redacted),
'meetingPolicy' => $this->normalizeMeetingPolicy($payload, $redacted),
};
}
/**
* @return list<string>
*/
public function volatileRootFields(): array
{
return self::VOLATILE_ROOT_FIELDS;
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeTransportRule(array $rawPayload, array $payload): array
{
return $this->sortAssociative([
'canonical_type' => 'transportRule',
'supported' => true,
'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']),
'enabled_state' => $this->enabledState($this->firstScalar($payload, ['enabled', 'Enabled', 'state', 'State'])),
'priority_order' => $this->stringValue($this->firstScalar($payload, ['priority', 'Priority'])),
'mode' => $this->firstString($payload, ['mode', 'Mode', 'ruleMode', 'RuleMode']),
'conditions' => $this->settingGroup($payload, ['conditions', 'Conditions'], [
'From',
'RecipientDomainIs',
'SenderDomainIs',
'SentTo',
'SubjectContainsWords',
]),
'actions' => $this->settingGroup($payload, ['actions', 'Actions'], [
'ApplyHtmlDisclaimerFallbackAction',
'ApplyHtmlDisclaimerLocation',
'DeleteMessage',
'RedirectMessageTo',
]),
'exceptions' => $this->settingGroup($payload, ['exceptions', 'Exceptions'], [
'ExceptIfFrom',
'ExceptIfRecipientDomainIs',
]),
'source' => $this->sourceSummary($payload),
'diagnostics' => $this->diagnostics('transportRule', $rawPayload, $payload),
]);
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeAcceptedDomain(array $rawPayload, array $payload): array
{
return $this->sortAssociative([
'canonical_type' => 'acceptedDomain',
'supported' => true,
'domain_name' => $this->firstString($payload, ['domainName', 'DomainName', 'name', 'Name', 'displayName', 'DisplayName', 'Identity']),
'domain_type' => $this->firstString($payload, ['domainType', 'DomainType', 'type']),
'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),
]);
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeAppPermissionPolicy(array $rawPayload, array $payload): array
{
$globalCatalogMode = $this->firstString($payload, ['GlobalCatalogAppsType', 'globalCatalogAppsType']);
$privateCatalogMode = $this->firstString($payload, ['PrivateCatalogAppsType', 'privateCatalogAppsType']);
return $this->sortAssociative([
'canonical_type' => 'appPermissionPolicy',
'supported' => true,
'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']),
'policy_mode' => $this->policyMode([
$this->firstScalar($payload, ['mode', 'policyMode']),
$globalCatalogMode,
$privateCatalogMode,
$this->firstScalar($payload, ['AllowAllApps', 'allowAllApps']),
]),
'allowed_apps' => $this->appList($this->firstExisting($payload, ['allowedApps', 'AllowAppList', 'allowAppList', 'allowedAppIds'])),
'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),
]);
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function normalizeMeetingPolicy(array $rawPayload, array $payload): array
{
return $this->sortAssociative([
'canonical_type' => 'meetingPolicy',
'supported' => true,
'display_name' => $this->firstString($payload, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']),
'state' => $this->firstString($payload, ['state', 'State']),
'external_access' => $this->settingGroup($payload, ['externalAccess', 'ExternalAccess'], [
'allowAnonymousUsersToJoinMeeting',
'AllowAnonymousUsersToJoinMeeting',
'allowExternalParticipantGiveRequestControl',
'AllowExternalParticipantGiveRequestControl',
]),
'recording_transcription' => $this->settingGroup($payload, ['recordingTranscription', 'RecordingTranscription'], [
'allowCloudRecording',
'AllowCloudRecording',
'allowTranscription',
'AllowTranscription',
'meetingRecordingExpirationDays',
'MeetingRecordingExpirationDays',
]),
'lobby_admission' => $this->settingGroup($payload, ['lobbyBypassSettings', 'LobbyBypassSettings'], [
'autoAdmittedUsers',
'AutoAdmittedUsers',
'allowPSTNUsersToBypassLobby',
'AllowPSTNUsersToBypassLobby',
]),
'content_sharing' => $this->settingGroup($payload, ['contentSharing', 'ContentSharing'], [
'screenSharingMode',
'ScreenSharingMode',
'allowPowerPointSharing',
'AllowPowerPointSharing',
'allowWhiteboard',
'AllowWhiteboard',
'allowSharedNotes',
'AllowSharedNotes',
'allowMeetingReactions',
'AllowMeetingReactions',
'allowParticipantGiveRequestControl',
'AllowParticipantGiveRequestControl',
]),
'source' => $this->sourceSummary($payload),
'diagnostics' => $this->diagnostics('meetingPolicy', $rawPayload, $payload),
]);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function redactedPayload(array $payload): array
{
$redacted = $this->redactor->redact($payload);
$redacted = is_array($redacted) ? $redacted : [];
return $this->redactUnsafeContent($redacted);
}
private function redactUnsafeContent(mixed $value, string $path = ''): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map(fn (mixed $item): mixed => $this->redactUnsafeContent($item, $path), $value);
}
$redacted = [];
foreach ($value as $key => $nestedValue) {
$key = (string) $key;
$nestedPath = $path === '' ? $key : $path.'.'.$key;
$redacted[$key] = $this->isUnsafeContentKey($nestedPath)
? '[redacted]'
: $this->redactUnsafeContent($nestedValue, $nestedPath);
}
return $redacted;
}
private function isUnsafeContentKey(string $path): bool
{
$normalized = strtolower(str_replace(['_', '-', ' '], '', $path));
$segments = explode('.', $normalized);
$last = end($segments);
foreach (self::SENSITIVE_CONTENT_KEYS as $sensitiveKey) {
if ($normalized === $sensitiveKey || $last === $sensitiveKey) {
return true;
}
}
foreach (self::SENSITIVE_CONTENT_KEY_PARTS as $sensitiveKeyPart) {
if (str_contains($normalized, $sensitiveKeyPart) || str_contains((string) $last, $sensitiveKeyPart)) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $payload
* @param list<string> $groupKeys
* @param list<string> $rootKeys
* @return array<string, mixed>
*/
private function settingGroup(array $payload, array $groupKeys, array $rootKeys = []): array
{
$settings = [];
foreach ($groupKeys as $groupKey) {
$group = $payload[$groupKey] ?? null;
if (is_array($group)) {
foreach ($this->normalizeSettingMap($group) as $key => $value) {
$settings[$key] = $value;
}
}
}
foreach ($rootKeys as $rootKey) {
if (! array_key_exists($rootKey, $payload)) {
continue;
}
$settings[$this->settingKey($rootKey)] = $this->normalizeSettingValue($payload[$rootKey]);
}
ksort($settings, SORT_NATURAL | SORT_FLAG_CASE);
return $settings;
}
/**
* @param array<string, mixed> $settings
* @return array<string, mixed>
*/
private function normalizeSettingMap(array $settings): array
{
$normalized = [];
foreach ($settings as $key => $value) {
$key = $this->settingKey((string) $key);
if ($this->isUnsafeContentKey($key)) {
$normalized[$key] = '[redacted]';
continue;
}
$normalized[$key] = $this->normalizeSettingValue($value);
}
ksort($normalized, SORT_NATURAL | SORT_FLAG_CASE);
return $normalized;
}
private function normalizeSettingValue(mixed $value): mixed
{
if ($value === '[redacted]') {
return $value;
}
if (is_bool($value)) {
return $value;
}
if (is_scalar($value)) {
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
if (! is_array($value)) {
return null;
}
if (array_is_list($value)) {
$items = array_map(fn (mixed $item): mixed => $this->normalizeSettingValue($item), $value);
$items = array_values(array_filter($items, static fn (mixed $item): bool => $item !== null && $item !== ''));
usort($items, static fn (mixed $left, mixed $right): int => strcmp(
json_encode($left, JSON_THROW_ON_ERROR),
json_encode($right, JSON_THROW_ON_ERROR),
));
return $items;
}
return $this->normalizeSettingMap($value);
}
private function settingKey(string $key): string
{
return str($key)
->replaceMatches('/[^A-Za-z0-9]+/', '_')
->snake()
->trim('_')
->toString();
}
private function firstExisting(array $payload, array $fields): mixed
{
foreach ($fields as $field) {
if (array_key_exists($field, $payload)) {
return $payload[$field];
}
}
return null;
}
private function firstScalar(array $payload, array $fields): mixed
{
foreach ($fields as $field) {
$value = $payload[$field] ?? null;
if (is_scalar($value)) {
return $value;
}
}
return null;
}
private function firstString(array $payload, array $fields): ?string
{
return $this->stringValue($this->firstScalar($payload, $fields));
}
private function stringValue(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private function enabledState(mixed $value): ?string
{
if (is_bool($value)) {
return $value ? 'enabled' : 'disabled';
}
$value = $this->stringValue($value);
if ($value === null) {
return null;
}
return match (strtolower($value)) {
'1', 'enabled', 'true', 'yes' => 'enabled',
'0', 'disabled', 'false', 'no' => 'disabled',
default => $value,
};
}
private function booleanString(mixed $value): ?string
{
if (is_bool($value)) {
return $value ? 'yes' : 'no';
}
$value = $this->stringValue($value);
if ($value === null) {
return null;
}
return match (strtolower($value)) {
'1', 'true', 'yes' => 'yes',
'0', 'false', 'no' => 'no',
default => $value,
};
}
/**
* @param list<mixed> $candidates
*/
private function policyMode(array $candidates): ?string
{
$parts = [];
foreach ($candidates as $candidate) {
if (is_bool($candidate)) {
$parts[] = $candidate ? 'allow all apps' : 'selected apps only';
continue;
}
$value = $this->stringValue($candidate);
if ($value !== null) {
$parts[] = $value;
}
}
$parts = array_values(array_unique($parts));
return $parts === [] ? null : implode('; ', $parts);
}
/**
* @return list<array<string, mixed>>
*/
private function appList(mixed $value): array
{
if ($value === null || $value === '') {
return [];
}
$items = is_array($value) && array_is_list($value) ? $value : [$value];
$apps = [];
foreach ($items as $item) {
if (is_array($item)) {
$apps[] = array_filter([
'display_name' => $this->firstString($item, ['displayName', 'DisplayName', 'name', 'Name']),
'app_id' => $this->firstString($item, ['appId', 'AppId', 'id', 'Id', 'externalId']),
], static fn (mixed $nested): bool => $nested !== null && $nested !== '');
continue;
}
$value = $this->stringValue($item);
if ($value !== null) {
$apps[] = ['display_name' => $value];
}
}
$apps = array_values(array_filter($apps, static fn (array $item): bool => $item !== []));
usort($apps, static fn (array $left, array $right): int => strcmp(
json_encode($left, JSON_THROW_ON_ERROR),
json_encode($right, JSON_THROW_ON_ERROR),
));
return $apps;
}
/**
* @param array<string, mixed> $payload
* @return array<string, ?string>
*/
private function sourceSummary(array $payload): array
{
return array_filter([
'version' => $this->firstString($payload, ['sourceVersion', 'source_version']),
'schema_hash' => $this->firstString($payload, ['sourceSchemaHash', 'source_schema_hash']),
], static fn (?string $value): bool => $value !== null && $value !== '');
}
/**
* @param array<string, mixed> $rawPayload
* @param array<string, mixed> $payload
* @return array<string, list<string>>
*/
private function diagnostics(string $canonicalType, array $rawPayload, array $payload): array
{
return [
'unsupported_fields' => $this->unsupportedRootFields($canonicalType, $payload),
'redacted_fields' => $this->redactedPaths($payload),
'volatile_fields' => $this->presentVolatileFields($rawPayload),
];
}
/**
* @param array<string, mixed> $payload
* @return list<string>
*/
private function unsupportedRootFields(string $canonicalType, array $payload): array
{
$supported = self::SUPPORTED_ROOT_FIELDS[$canonicalType] ?? [];
$fields = array_values(array_filter(
array_map('strval', array_keys($payload)),
static fn (string $key): bool => ! in_array($key, $supported, true),
));
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
return $fields;
}
/**
* @param array<string, mixed> $payload
* @return list<string>
*/
private function presentVolatileFields(array $payload): array
{
$fields = array_values(array_filter(
self::VOLATILE_ROOT_FIELDS,
static fn (string $field): bool => array_key_exists($field, $payload),
));
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
return $fields;
}
/**
* @return list<string>
*/
private function redactedPaths(mixed $value, string $prefix = ''): array
{
if ($value === '[redacted]') {
return [$prefix];
}
if (! is_array($value)) {
return [];
}
$paths = [];
foreach ($value as $key => $nestedValue) {
$path = $prefix === '' ? (string) $key : $prefix.'.'.(string) $key;
foreach ($this->redactedPaths($nestedValue, $path) as $nestedPath) {
$paths[] = $nestedPath;
}
}
$paths = array_values(array_unique(array_filter($paths)));
sort($paths, SORT_NATURAL | SORT_FLAG_CASE);
return $paths;
}
/**
* @param array<string, mixed> $value
* @return array<string, mixed>
*/
private function sortAssociative(array $value): array
{
foreach ($value as $key => $nestedValue) {
if (is_array($nestedValue)) {
$value[$key] = array_is_list($nestedValue)
? $nestedValue
: $this->sortAssociative($nestedValue);
}
}
ksort($value, SORT_NATURAL | SORT_FLAG_CASE);
return $value;
}
}

View File

@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class ExchangeTeamsCoverageComparator
{
/**
* @var list<string>
*/
private const MATERIAL_CHANGE_TYPES = [
'added',
'removed',
'changed',
];
public function __construct(
private readonly ExchangeTeamsComparablePayloadNormalizer $normalizer,
) {}
/**
* @param array<string, mixed> $beforePayload
* @param array<string, mixed> $afterPayload
* @return array<string, mixed>
*/
public function compare(string $canonicalType, array $beforePayload, array $afterPayload): array
{
if (! $this->normalizer->supports($canonicalType)) {
return [
'canonical_type' => $canonicalType,
'supported' => false,
'classification' => 'unsupported_field',
'changed' => false,
'changes' => [[
'field' => 'canonical_type',
'classification' => 'unsupported_field',
'importance' => 'informational',
]],
];
}
$before = $this->normalizer->normalize($canonicalType, $beforePayload);
$after = $this->normalizer->normalize($canonicalType, $afterPayload);
$changes = [
...$this->volatileChanges($beforePayload, $afterPayload),
...$this->diagnosticChanges($before, $after),
...$this->materialChanges(
$this->materialPayload($before),
$this->materialPayload($after),
canonicalType: $canonicalType,
),
];
$hasMaterialChange = collect($changes)
->contains(fn (array $change): bool => in_array($change['classification'] ?? null, self::MATERIAL_CHANGE_TYPES, true));
return [
'canonical_type' => $canonicalType,
'supported' => true,
'classification' => $hasMaterialChange ? 'changed' : 'unchanged',
'changed' => $hasMaterialChange,
'changes' => $changes,
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function materialPayload(array $payload): array
{
unset($payload['diagnostics'], $payload['supported'], $payload['canonical_type'], $payload['source']);
return $payload;
}
/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
* @return list<array<string, mixed>>
*/
private function materialChanges(array $before, array $after, string $path = '', string $canonicalType = ''): array
{
$changes = [];
$keys = array_values(array_unique([...array_keys($before), ...array_keys($after)]));
sort($keys, SORT_NATURAL | SORT_FLAG_CASE);
foreach ($keys as $key) {
$field = $path === '' ? (string) $key : $path.'.'.(string) $key;
$beforeValue = $before[$key] ?? null;
$afterValue = $after[$key] ?? null;
if (is_array($beforeValue) && is_array($afterValue) && ! array_is_list($beforeValue) && ! array_is_list($afterValue)) {
foreach ($this->materialChanges($beforeValue, $afterValue, $field, $canonicalType) as $nestedChange) {
$changes[] = $nestedChange;
}
continue;
}
if ($this->containsRedacted($beforeValue) || $this->containsRedacted($afterValue)) {
continue;
}
if ($beforeValue === $afterValue) {
continue;
}
$changes[] = [
'field' => $field,
'classification' => $this->changeClassification($beforeValue, $afterValue),
'importance' => $this->importance($canonicalType, $field),
'before' => $beforeValue,
'after' => $afterValue,
];
}
return $changes;
}
private function changeClassification(mixed $beforeValue, mixed $afterValue): string
{
if ($this->isEmptyValue($beforeValue) && ! $this->isEmptyValue($afterValue)) {
return 'added';
}
if (! $this->isEmptyValue($beforeValue) && $this->isEmptyValue($afterValue)) {
return 'removed';
}
return 'changed';
}
private function isEmptyValue(mixed $value): bool
{
return $value === null || $value === '' || $value === [];
}
private function containsRedacted(mixed $value): bool
{
if ($value === '[redacted]') {
return true;
}
if (! is_array($value)) {
return false;
}
foreach ($value as $nestedValue) {
if ($this->containsRedacted($nestedValue)) {
return true;
}
}
return false;
}
private function importance(string $canonicalType, string $field): string
{
if ($canonicalType === 'transportRule' && in_array($field, ['enabled_state', 'actions', 'conditions'], true)) {
return 'critical';
}
if ($canonicalType === 'acceptedDomain' && in_array($field, ['domain_name', 'is_default'], true)) {
return 'critical';
}
if ($canonicalType === 'appPermissionPolicy' && (str_starts_with($field, 'allowed_apps') || str_starts_with($field, 'blocked_apps'))) {
return 'critical';
}
if ($canonicalType === 'meetingPolicy' && (
str_starts_with($field, 'external_access')
|| str_starts_with($field, 'recording_transcription')
)) {
return 'critical';
}
if (str_starts_with($field, 'actions')
|| str_starts_with($field, 'conditions')
|| str_starts_with($field, 'exceptions')
|| str_starts_with($field, 'targets')
|| str_starts_with($field, 'content_sharing')
|| str_starts_with($field, 'lobby_admission')
|| in_array($field, ['domain_type', 'policy_mode', 'priority_order', 'mode', 'state'], true)
) {
return 'important';
}
return 'informational';
}
/**
* @param array<string, mixed> $beforePayload
* @param array<string, mixed> $afterPayload
* @return list<array<string, mixed>>
*/
private function volatileChanges(array $beforePayload, array $afterPayload): array
{
$changes = [];
foreach ($this->normalizer->volatileRootFields() as $field) {
$before = $beforePayload[$field] ?? null;
$after = $afterPayload[$field] ?? null;
if ($before === $after) {
continue;
}
$changes[] = [
'field' => $field,
'classification' => 'ignored_volatile',
'importance' => 'informational',
];
}
return $changes;
}
/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
* @return list<array<string, mixed>>
*/
private function diagnosticChanges(array $before, array $after): array
{
$changes = [];
foreach ($this->diagnosticFieldUnion($before, $after, 'unsupported_fields') as $field) {
$changes[] = [
'field' => $field,
'classification' => 'unsupported_field',
'importance' => 'informational',
];
}
foreach ($this->diagnosticFieldUnion($before, $after, 'redacted_fields') as $field) {
$changes[] = [
'field' => $field,
'classification' => 'redacted',
'importance' => 'informational',
];
}
return $changes;
}
/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
* @return list<string>
*/
private function diagnosticFieldUnion(array $before, array $after, string $key): array
{
$fields = [
...$this->diagnosticFieldList($before, $key),
...$this->diagnosticFieldList($after, $key),
];
$fields = array_values(array_unique($fields));
sort($fields, SORT_NATURAL | SORT_FLAG_CASE);
return $fields;
}
/**
* @param array<string, mixed> $payload
* @return list<string>
*/
private function diagnosticFieldList(array $payload, string $key): array
{
$fields = data_get($payload, 'diagnostics.'.$key, []);
if (! is_array($fields)) {
return [];
}
return array_values(array_filter(
array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $fields),
static fn (string $field): bool => $field !== '',
));
}
}

View File

@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class ExchangeTeamsRenderableSummaryBuilder
{
public function __construct(
private readonly ExchangeTeamsComparablePayloadNormalizer $normalizer,
) {}
public function supports(string $canonicalType): bool
{
return $this->normalizer->supports($canonicalType);
}
/**
* @param array<string, mixed> $payload
*/
public function canBuild(string $canonicalType, array $payload): bool
{
if (! $this->supports($canonicalType)) {
return false;
}
$normalized = $this->normalizer->normalize($canonicalType, $payload);
return ($normalized['supported'] ?? false) === true;
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $context
* @return array<string, mixed>|null
*/
public function build(string $canonicalType, array $payload, array $context = []): ?array
{
if (! $this->supports($canonicalType)) {
return null;
}
$normalized = $this->normalizer->normalize($canonicalType, $payload);
if (($normalized['supported'] ?? false) !== true) {
return null;
}
return match ($canonicalType) {
'transportRule' => $this->transportRuleSummary($normalized, $context),
'acceptedDomain' => $this->acceptedDomainSummary($normalized, $context),
'appPermissionPolicy' => $this->appPermissionPolicySummary($normalized, $context),
'meetingPolicy' => $this->meetingPolicySummary($normalized, $context),
default => null,
};
}
/**
* @param array<string, mixed> $normalized
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function transportRuleSummary(array $normalized, array $context): array
{
return $this->baseSummary('Transport rule', $normalized, $context, [
['label' => 'Display name', 'value' => $normalized['display_name'] ?? 'Unnamed transport rule'],
['label' => 'Enabled/state', 'value' => $normalized['enabled_state'] ?? null],
['label' => 'Priority / order', 'value' => $normalized['priority_order'] ?? null],
['label' => 'Mode / enforcement', 'value' => $normalized['mode'] ?? null],
['label' => 'Conditions', 'value' => $this->settingSummary($normalized['conditions'] ?? [])],
['label' => 'Actions', 'value' => $this->settingSummary($normalized['actions'] ?? [])],
['label' => 'Exceptions', 'value' => $this->settingSummary($normalized['exceptions'] ?? [])],
]);
}
/**
* @param array<string, mixed> $normalized
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function acceptedDomainSummary(array $normalized, array $context): array
{
return $this->baseSummary('Accepted domain', $normalized, $context, [
['label' => 'Domain name', 'value' => $normalized['domain_name'] ?? 'Unnamed accepted domain'],
['label' => 'Domain type', 'value' => $normalized['domain_type'] ?? null],
['label' => 'Default domain', 'value' => $normalized['is_default'] ?? null],
['label' => 'State', 'value' => $normalized['state'] ?? null],
], displayName: $normalized['domain_name'] ?? null, state: $normalized['state'] ?? null);
}
/**
* @param array<string, mixed> $normalized
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function appPermissionPolicySummary(array $normalized, array $context): array
{
return $this->baseSummary('Teams app permission policy', $normalized, $context, [
['label' => 'Display name', 'value' => $normalized['display_name'] ?? 'Unnamed app permission policy'],
['label' => 'Policy mode', 'value' => $normalized['policy_mode'] ?? null],
['label' => 'Allowed apps', 'value' => $this->appSummary($normalized['allowed_apps'] ?? [])],
['label' => 'Blocked apps', 'value' => $this->appSummary($normalized['blocked_apps'] ?? [])],
['label' => 'Assignments / targets', 'value' => $this->settingSummary($normalized['targets'] ?? [])],
], state: $normalized['policy_mode'] ?? null);
}
/**
* @param array<string, mixed> $normalized
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function meetingPolicySummary(array $normalized, array $context): array
{
return $this->baseSummary('Teams meeting policy', $normalized, $context, [
['label' => 'Display name', 'value' => $normalized['display_name'] ?? 'Unnamed meeting policy'],
['label' => 'State', 'value' => $normalized['state'] ?? null],
['label' => 'External / anonymous access', 'value' => $this->settingSummary($normalized['external_access'] ?? [])],
['label' => 'Recording / transcription', 'value' => $this->settingSummary($normalized['recording_transcription'] ?? [])],
['label' => 'Lobby / admission', 'value' => $this->settingSummary($normalized['lobby_admission'] ?? [])],
['label' => 'Content sharing', 'value' => $this->settingSummary($normalized['content_sharing'] ?? [])],
], state: $normalized['state'] ?? null);
}
/**
* @param array<string, mixed> $normalized
* @param array<string, mixed> $context
* @param list<array{label: string, value: mixed}> $fields
* @return array<string, mixed>
*/
private function baseSummary(
string $resourceType,
array $normalized,
array $context,
array $fields,
?string $displayName = null,
?string $state = null,
): array {
return [
'resource_type' => $resourceType,
'display_name' => $displayName ?? $this->displayName($normalized, $resourceType),
'state' => $state,
'summary_fields' => array_values(array_filter(
array_map(fn (array $field): array => [
'label' => $field['label'],
'value' => $this->summaryValue($field['value']),
], $fields),
static fn (array $field): bool => filled($field['value'] ?? null),
)),
'targets' => [],
'conditions' => [],
'claim_state' => $this->stringContext($context, 'claim_state'),
'identity_state' => $this->stringContext($context, 'identity_state'),
'last_captured' => $this->stringContext($context, 'last_captured'),
'unsupported_fields' => data_get($normalized, 'diagnostics.unsupported_fields', []),
'redacted_fields' => data_get($normalized, 'diagnostics.redacted_fields', []),
];
}
/**
* @param array<string, mixed> $normalized
*/
private function displayName(array $normalized, string $fallback): string
{
foreach (['display_name', 'domain_name'] as $key) {
$value = $normalized[$key] ?? null;
if (is_scalar($value) && trim((string) $value) !== '') {
return trim((string) $value);
}
}
return 'Unnamed '.$fallback;
}
private function summaryValue(mixed $value): ?string
{
if ($value === null || $value === '' || $value === []) {
return null;
}
if (is_bool($value)) {
return $value ? 'yes' : 'no';
}
if (is_scalar($value)) {
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
if (is_array($value)) {
return $this->stringify($value);
}
return null;
}
/**
* @param array<string, mixed> $settings
*/
private function settingSummary(array $settings): ?string
{
if ($settings === []) {
return null;
}
$parts = [];
foreach ($settings as $key => $value) {
if ($this->containsRedacted($value)) {
continue;
}
$value = $this->summaryValue($value);
if ($value === null) {
continue;
}
$parts[] = str((string) $key)->replace('_', ' ')->headline()->toString().': '.$value;
}
return $parts === [] ? null : implode('; ', $parts);
}
private function containsRedacted(mixed $value): bool
{
if ($value === '[redacted]') {
return true;
}
if (! is_array($value)) {
return false;
}
foreach ($value as $nestedValue) {
if ($this->containsRedacted($nestedValue)) {
return true;
}
}
return false;
}
/**
* @param list<array<string, mixed>> $apps
*/
private function appSummary(array $apps): ?string
{
if ($apps === []) {
return null;
}
$parts = [];
foreach ($apps as $app) {
if (! is_array($app)) {
continue;
}
$display = $this->summaryValue($app['display_name'] ?? null);
if ($display !== null) {
$parts[] = $display;
}
}
$parts = array_values(array_filter($parts));
return $parts === [] ? null : implode(', ', $parts);
}
private function stringify(mixed $value): string
{
if (is_bool($value)) {
return $value ? 'yes' : 'no';
}
if (is_scalar($value)) {
return (string) $value;
}
return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
/**
* @param array<string, mixed> $context
*/
private function stringContext(array $context, string $key): ?string
{
$value = $context[$key] ?? null;
if ($value instanceof \BackedEnum) {
return (string) $value->value;
}
if (! is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
}

View File

@ -8,27 +8,36 @@
'Evidence' => $badge(\App\Support\Badges\BadgeDomain::CoverageV2EvidenceState, $details['evidence_state'] ?? null),
'Identity' => $badge(\App\Support\Badges\BadgeDomain::CoverageV2IdentityState, $details['identity_state'] ?? null),
'Claim' => $badge(\App\Support\Badges\BadgeDomain::CoverageV2ClaimState, $details['claim_state'] ?? null),
'Source' => $badge(\App\Support\Badges\BadgeDomain::CoverageV2SourceClass, $details['source_class'] ?? null),
];
$safeFields = [
$productFields = [
'Resource type' => $details['resource_type'] ?? null,
'Provider connection' => $details['provider_connection'] ?? null,
'Last captured' => $details['last_captured'] ?? null,
'Capture outcome' => $details['capture_outcome'] ?? null,
];
$sourceBadge = $badge(\App\Support\Badges\BadgeDomain::CoverageV2SourceClass, $details['source_class'] ?? null);
$technicalFields = [
'Canonical type' => $details['canonical_type'] ?? null,
'Canonical key' => $details['canonical_resource_key'] ?? null,
'Provider connection' => $details['provider_connection'] ?? null,
'Evidence hash' => $details['evidence_hash'] ?? null,
'Last captured' => $details['last_captured'] ?? null,
'Source contract' => $details['source_contract_key'] ?? null,
'Source version' => $details['source_version'] ?? null,
'Source schema hash' => $details['source_schema_hash'] ?? null,
'Capture outcome' => $details['capture_outcome'] ?? null,
'Identity reason' => $details['identity_reason_code'] ?? null,
];
$hasTechnicalDetails = collect($technicalFields)->contains(fn ($value): bool => filled($value))
|| $sourceBadge !== null
|| filled($details['operation_run_url'] ?? null);
$typedSummary = $details['typed_render_summary'] ?? null;
$compareSummary = is_array($typedSummary) && is_array($typedSummary['compare_summary'] ?? null)
? $typedSummary['compare_summary']
: null;
$summaryFields = is_array($typedSummary) && is_array($typedSummary['summary_fields'] ?? null)
? $typedSummary['summary_fields']
: [];
@endphp
<div class="space-y-5">
@ -64,6 +73,20 @@
</div>
<dl class="grid gap-3 sm:grid-cols-2">
@if ($summaryFields !== [])
@foreach ($summaryFields as $field)
@if (is_array($field) && filled($field['value'] ?? null))
<div class="min-w-0 border-l border-gray-200 pl-3 dark:border-white/10">
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ $field['label'] ?? 'Summary' }}
</dt>
<dd class="mt-1 break-words text-sm text-gray-950 dark:text-white">
{{ $field['value'] }}
</dd>
</div>
@endif
@endforeach
@else
@foreach ([
'Display name' => $typedSummary['display_name'] ?? null,
'State' => $typedSummary['state'] ?? null,
@ -84,6 +107,7 @@
</div>
@endif
@endforeach
@endif
</dl>
@foreach (['targets' => 'Targets', 'conditions' => 'Conditions'] as $summaryKey => $heading)
@ -173,7 +197,37 @@
@endif
<dl class="grid gap-3 sm:grid-cols-2">
@foreach ($safeFields as $label => $value)
@foreach ($productFields as $label => $value)
@if (filled($value))
<div class="min-w-0 border-l border-gray-200 pl-3 dark:border-white/10">
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ $label }}
</dt>
<dd class="mt-1 break-words text-sm text-gray-950 dark:text-white">
{{ $value }}
</dd>
</div>
@endif
@endforeach
</dl>
@if ($hasTechnicalDetails)
<details class="rounded-lg border border-gray-200 bg-gray-50/50 p-3 dark:border-white/10 dark:bg-white/5">
<summary class="cursor-pointer list-none rounded-md px-2 py-1.5 text-sm font-medium text-gray-700 transition hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:text-gray-200 dark:hover:bg-white/10">
View technical details
</summary>
<div class="mt-3 space-y-3">
@if ($sourceBadge)
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$sourceBadge->color" :icon="$sourceBadge->icon">
Source: {{ $sourceBadge->label }}
</x-filament::badge>
</div>
@endif
<dl class="grid gap-3 sm:grid-cols-2">
@foreach ($technicalFields as $label => $value)
@if (filled($value))
<div class="min-w-0 border-l border-gray-200 pl-3 dark:border-white/10">
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
@ -192,4 +246,7 @@
{{ $details['operation_run_label'] ?? 'Open operation' }}
</x-filament::link>
@endif
</div>
</details>
@endif
</div>

View File

@ -81,9 +81,11 @@
->assertSee('Evidence: Permission blocked')
->assertSee('Identity: Identity conflict')
->assertSee('Spec418 Browser Microsoft provider')
->assertSee('same_scope_derived_identity_collision')
->assertSee('spec418-browser-schema-hash')
->assertSee('Operation #')
->assertSee('View technical details')
->assertDontSee('same_scope_derived_identity_collision')
->assertDontSee('spec418-browser-schema-hash')
->assertDontSee('Operation #')
->assertScript('(() => document.querySelector("details")?.open === false)()', true)
->assertDontSee('raw-response-secret')
->assertDontSee('normalized-secret')
->assertDontSee('permission-secret')

View File

@ -77,11 +77,12 @@
->assertSee('Identity: Stable')
->assertSee('Claim: Internal only')
->assertSee('Spec420 Browser Microsoft provider')
->assertSee('conditionalAccessPolicy:graph_object_id:cap-browser-1')
->assertSee('conditionalAccessPolicy')
->assertSee('v1.0')
->assertSee('spec420-browser-schema-hash')
->assertSee('Operation #')
->assertSee('View technical details')
->assertDontSee('conditionalAccessPolicy:graph_object_id:cap-browser-1')
->assertDontSee('conditionalAccessPolicy')
->assertDontSee('spec420-browser-schema-hash')
->assertDontSee('Operation #')
->assertScript('(() => document.querySelector("details")?.open === false)()', true)
->assertDontSee('M365 covered')
->assertDontSee('certified')
->assertDontSee('restore-ready')

View File

@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Models\TenantConfigurationSupportedScope;
use App\Models\User;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\EvidenceState;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\SourceClass;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->browser()->timeout(60_000);
it('Spec422 smokes the Coverage v2 inspect surface for Exchange and Teams comparable renderable evidence', function (): void {
[$user, $environment] = spec422CoverageV2BrowserFixture();
spec422AuthenticateCoverageV2Browser($this, $user, $environment);
$page = visit(CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin'))
->resize(768, 1100)
->waitForText('Coverage v2 Readiness')
->waitForText('Spec422 Browser Meeting Policy')
->assertSee('Resource type registry')
->assertSee('Resource instances')
->assertSee('Transport rule')
->assertSee('Meeting policy')
->assertSee('Coverage level')
->assertSee('Renderable')
->assertSee('Internal only')
->assertDontSee('meetingPolicy:provider_external_id:spec422-browser')
->assertDontSee('transportRule:provider_external_id:spec422-browser')
->assertDontSee('Exchange covered')
->assertDontSee('Teams covered')
->assertDontSee('certified')
->assertDontSee('restore-ready')
->assertDontSee('customer-ready')
->assertDontSee('full Exchange coverage')
->assertDontSee('100% Teams')
->assertDontSee('spec422-browser-raw-secret')
->assertDontSee('spec422-browser-chat-secret')
->assertScript('typeof window.Livewire !== "undefined"', true)
->assertScript('(() => document.querySelectorAll("table tbody tr").length > 0)()', true)
->assertScript(<<<'JS'
(() => {
const row = Array.from(document.querySelectorAll('table tbody tr'))
.find((candidate) => candidate.textContent.includes('Spec422 Browser Meeting Policy'));
const resourceTypeCellText = row?.querySelectorAll('td')?.[1]?.innerText ?? '';
return resourceTypeCellText.includes('Meeting policy')
&& ! resourceTypeCellText.includes('meetingPolicy');
})()
JS, true)
->assertScript("(() => performance.getEntriesByType('resource').filter((entry) => /graph\\.microsoft\\.com|\\/tcm\\b|provider-remote/i.test(entry.name)).length)()", 0)
->assertScript("(() => Array.from(document.querySelectorAll('main button, main a')).map((element) => element.textContent.trim()).filter(Boolean).some((label) => /^(Capture|Restore|Certify|Export|Download)$/i.test(label)))()", false)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$page->script(<<<'JS'
(() => {
const rows = Array.from(document.querySelectorAll('table tbody tr'));
const row = rows.find((candidate) => candidate.textContent.includes('Spec422 Browser Meeting Policy'));
const inspect = Array.from(row?.querySelectorAll('button, a') ?? [])
.find((element) => element.textContent.includes('Spec422 Browser Meeting Policy'));
inspect?.click();
})()
JS);
$page
->waitForText('Coverage: Renderable')
->assertSee('Teams meeting policy')
->assertSee('Display name')
->assertSee('Spec422 Browser Meeting Policy')
->assertSee('Recording / transcription')
->assertSee('Allow Transcription: yes')
->assertSee('Content sharing')
->assertSee('Screen Sharing Mode: EntireScreen')
->assertSee('Compare summary')
->assertSee('Material changes detected')
->assertSee('Previous comparable evidence')
->assertSee('Recording Transcription Allow Transcription')
->assertSee('Redacted fields')
->assertSee('chatContent')
->assertSee('Evidence: Content backed')
->assertSee('Identity: Stable')
->assertSee('Claim: Internal only')
->assertSee('View technical details')
->assertDontSee('Source: TCM')
->assertDontSee('Evidence hash')
->assertDontSee('Source contract')
->assertDontSee('Source schema hash')
->assertDontSee('Operation #')
->assertDontSee('meetingPolicy:provider_external_id:spec422-browser')
->assertScript('(() => document.querySelector("details")?.open === false)()', true)
->assertScript(<<<'JS'
(() => {
const technicalDetails = document.querySelector('details')?.textContent ?? '';
return technicalDetails.includes('Source: TCM')
&& technicalDetails.includes('Evidence hash')
&& technicalDetails.includes('Source contract')
&& technicalDetails.includes('Source schema hash')
&& technicalDetails.includes('Operation #');
})()
JS, true)
->assertDontSee('Exchange covered')
->assertDontSee('Teams covered')
->assertDontSee('certified')
->assertDontSee('restore-ready')
->assertDontSee('customer-ready')
->assertDontSee('full Exchange coverage')
->assertDontSee('100% Teams')
->assertDontSee('spec422-browser-raw-secret')
->assertDontSee('spec422-browser-chat-secret')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->screenshot(true, 'spec422-exchange-teams-comparable-renderable-operator-surface');
});
/**
* @return array{0: User, 1: ManagedEnvironment}
*/
function spec422CoverageV2BrowserFixture(): array
{
app(ResourceTypeRegistry::class)->syncDefaults();
$environment = ManagedEnvironment::factory()->active()->create([
'name' => 'Spec422 Browser Environment',
'external_id' => 'spec422-browser-environment',
]);
[$user, $environment] = createUserWithTenant(
tenant: $environment,
role: 'owner',
workspaceRole: 'owner',
clearCapabilityCaches: true,
);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'display_name' => 'Spec422 Browser Microsoft provider',
]);
TenantConfigurationSupportedScope::factory()->create([
'scope_key' => 'spec422_browser_internal_exchange_teams_scope',
'display_name' => 'Spec422 Browser internal Exchange Teams scope',
'minimum_coverage_level' => CoverageLevel::ContentBacked->value,
'included_resource_types' => ['transportRule', 'meetingPolicy'],
'allow_graph_fallback' => false,
'allow_beta' => false,
'customer_claims_allowed' => false,
]);
spec422BrowserEvidenceResource(
environment: $environment,
user: $user,
connection: $connection,
canonicalType: 'transportRule',
displayName: 'Spec422 Browser Transport Rule',
previousPayload: [
'DisplayName' => 'Spec422 Browser Transport Rule',
'Enabled' => true,
'Actions' => ['RedirectMessageTo' => ['old-security@example.com']],
],
latestPayload: [
'DisplayName' => 'Spec422 Browser Transport Rule',
'Enabled' => true,
'Actions' => ['RedirectMessageTo' => ['security@example.com']],
],
hashSeed: 'a',
);
spec422BrowserEvidenceResource(
environment: $environment,
user: $user,
connection: $connection,
canonicalType: 'meetingPolicy',
displayName: 'Spec422 Browser Meeting Policy',
previousPayload: [
'DisplayName' => 'Spec422 Browser Meeting Policy',
'AllowTranscription' => false,
'ScreenSharingMode' => 'SingleApplication',
],
latestPayload: [
'DisplayName' => 'Spec422 Browser Meeting Policy',
'AllowTranscription' => true,
'ScreenSharingMode' => 'EntireScreen',
'chatContent' => 'spec422-browser-chat-secret',
],
hashSeed: 'b',
);
return [$user, $environment->refresh()];
}
function spec422BrowserEvidenceResource(
ManagedEnvironment $environment,
User $user,
ProviderConnection $connection,
string $canonicalType,
string $displayName,
array $previousPayload,
array $latestPayload,
string $hashSeed,
): TenantConfigurationResource {
$resourceType = TenantConfigurationResourceType::query()
->where('canonical_type', $canonicalType)
->where('source_class', SourceClass::Tcm->value)
->firstOrFail();
$previousRun = spec422BrowserRun($environment, $user, $connection, $canonicalType, minutesAgo: 5);
$run = spec422BrowserRun($environment, $user, $connection, $canonicalType);
$resource = TenantConfigurationResource::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'canonical_type' => $canonicalType,
'canonical_resource_key' => $canonicalType.':provider_external_id:spec422-browser',
'canonical_key_kind' => CanonicalKeyKind::ProviderExternalId->value,
'source_resource_id' => 'spec422-browser',
'source_display_name' => $displayName,
'source_class' => SourceClass::Tcm->value,
'source_metadata' => [
'source_contract_key' => 'spec422.synthetic.'.$canonicalType,
'source_endpoint' => '/spec422/synthetic/'.$canonicalType,
'source_version' => 'v1.0',
'registry_source_class' => SourceClass::Tcm->value,
'registry_support_state' => 'out_of_scope',
],
'identity_strategy' => 'provider.external.id.v1',
'source_identity' => [
'primary_field' => 'id',
'primary_value' => 'spec422-browser',
],
'identity_diagnostics' => [
'reason_code' => 'provider_external_id',
],
'identity_evaluated_at' => now(),
'latest_evidence_state' => EvidenceState::ContentBacked->value,
'latest_identity_state' => IdentityState::Stable->value,
'latest_claim_state' => ClaimState::InternalOnly->value,
'latest_captured_at' => now(),
]);
TenantConfigurationResourceEvidence::factory()->create([
'resource_id' => (int) $resource->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'operation_run_id' => (int) $previousRun->getKey(),
'source_contract_key' => 'spec422.synthetic.'.$canonicalType,
'source_endpoint' => '/spec422/synthetic/'.$canonicalType,
'source_version' => 'v1.0',
'source_schema_hash' => 'spec422-browser-previous-schema-hash',
'source_metadata' => [
'registry_source_class' => SourceClass::Tcm->value,
'registry_support_state' => 'out_of_scope',
],
'raw_payload' => ['id' => 'spec422-browser'],
'normalized_payload' => $previousPayload,
'payload_hash' => str_repeat($hashSeed, 64),
'permission_context' => ['scopes_granted' => []],
'evidence_state' => EvidenceState::ContentBacked->value,
'coverage_level' => CoverageLevel::Comparable->value,
'capture_outcome' => CaptureOutcome::Captured->value,
'captured_at' => now()->subMinutes(5),
]);
$evidence = TenantConfigurationResourceEvidence::factory()->create([
'resource_id' => (int) $resource->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'operation_run_id' => (int) $run->getKey(),
'source_contract_key' => 'spec422.synthetic.'.$canonicalType,
'source_endpoint' => '/spec422/synthetic/'.$canonicalType,
'source_version' => 'v1.0',
'source_schema_hash' => 'spec422-browser-schema-hash',
'source_metadata' => [
'registry_source_class' => SourceClass::Tcm->value,
'registry_support_state' => 'out_of_scope',
],
'raw_payload' => ['id' => 'spec422-browser', 'secret' => 'spec422-browser-raw-secret'],
'normalized_payload' => $latestPayload,
'payload_hash' => str_repeat(strtoupper($hashSeed), 64),
'permission_context' => ['scopes_granted' => []],
'evidence_state' => EvidenceState::ContentBacked->value,
'coverage_level' => CoverageLevel::Renderable->value,
'capture_outcome' => CaptureOutcome::Captured->value,
'captured_at' => now(),
]);
$resource->forceFill([
'latest_evidence_id' => (int) $evidence->getKey(),
'latest_payload_hash' => (string) $evidence->payload_hash,
])->save();
return $resource->refresh();
}
function spec422BrowserRun(
ManagedEnvironment $environment,
User $user,
ProviderConnection $connection,
string $canonicalType,
int $minutesAgo = 0,
): OperationRun {
$timestamp = $minutesAgo > 0 ? now()->subMinutes($minutesAgo) : now();
return OperationRun::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => (string) $user->name,
'type' => OperationRunType::TenantConfigurationCapture->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'summary_counts' => [
'total' => 1,
'processed' => 1,
'succeeded' => 1,
'skipped' => 0,
'failed' => 0,
'errors_recorded' => 0,
],
'context' => [
'requested_resource_types' => [$canonicalType],
'outcomes' => [
['canonical_type' => $canonicalType, 'outcome' => CaptureOutcome::Captured->value],
],
'target_scope' => [
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
],
],
'started_at' => $timestamp,
'completed_at' => $timestamp,
]);
}
function spec422AuthenticateCoverageV2Browser(
mixed $test,
User $user,
ManagedEnvironment $environment,
): void {
$workspaceId = (int) $environment->workspace_id;
$test->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
(string) $workspaceId => (int) $environment->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $environment->getKey(),
]);
}

View File

@ -357,6 +357,8 @@ function seedCoverageV2ReadinessScenario(ManagedEnvironment $environment): array
->and($outsiderDetails['operation_run_url'] ?? null)
->toBeNull()
->and($html)
->toContain('<details')
->toContain('View technical details')
->toContain('Identity conflict')
->toContain('same_scope_derived_identity_collision')
->toContain('spec418-schema-hash')

View File

@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\EvidenceState;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\SourceClass;
it('Spec422 exposes typed Exchange and Teams summaries with material compare details without provider calls', function (string $canonicalType, array $previousPayload, array $latestPayload, string $resourceType, string $expectedText, string $expectedChange): void {
[$user, $environment, $resource] = spec422FeatureEvidencePair($canonicalType, $previousPayload, $latestPayload);
app()->instance(GraphClientInterface::class, spec422FailingGraphClient());
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $environment, $user);
$summary = $details['typed_render_summary'] ?? null;
$encoded = json_encode($summary, JSON_THROW_ON_ERROR);
expect($summary)->toBeArray()
->and($summary['resource_type'])->toBe($resourceType)
->and($encoded)->toContain($expectedText)
->and($encoded)->not->toContain('raw_payload')
->and($encoded)->not->toContain('source_endpoint')
->and($encoded)->not->toContain('spec422-feature-secret')
->and($summary['compare_summary']['status'])->toBe('Material changes detected')
->and($summary['compare_summary']['changed'])->toBeTrue()
->and(collect($summary['compare_summary']['changes'])->pluck('label'))->toContain($expectedChange);
})->with([
'transport rule' => [
'transportRule',
['DisplayName' => 'Spec422 Feature Transport Rule', 'Enabled' => true, 'Actions' => ['RedirectMessageTo' => ['old-security@example.com']]],
['DisplayName' => 'Spec422 Feature Transport Rule', 'Enabled' => true, 'Actions' => ['RedirectMessageTo' => ['security@example.com']], 'clientSecret' => 'spec422-feature-secret'],
'Transport rule',
'security@example.com',
'Actions Redirect Message To',
],
'meeting policy' => [
'meetingPolicy',
['DisplayName' => 'Spec422 Feature Meeting Policy', 'AllowTranscription' => false],
['DisplayName' => 'Spec422 Feature Meeting Policy', 'AllowTranscription' => true, 'chatContent' => 'spec422-feature-secret'],
'Teams meeting policy',
'Allow Transcription: yes',
'Recording Transcription Allow Transcription',
],
]);
it('Spec422 does not render typed summaries for non-renderable latest evidence', function (): void {
[$user, $environment, $resource, $latestEvidence] = spec422FeatureEvidencePair(
'meetingPolicy',
['DisplayName' => 'Spec422 Non Renderable Meeting', 'AllowTranscription' => false],
['DisplayName' => 'Spec422 Non Renderable Meeting', 'AllowTranscription' => true],
);
$latestEvidence->forceFill(['coverage_level' => CoverageLevel::ContentBacked->value])->save();
$resource->unsetRelation('latestEvidence');
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource->fresh(), $environment, $user);
expect($details['typed_render_summary'] ?? null)->toBeNull();
});
it('Spec422 returns no inspect details when the managed environment scope does not match', function (): void {
[$user, $environment, $resource] = spec422FeatureEvidencePair(
'transportRule',
['DisplayName' => 'Spec422 Scoped Rule', 'Enabled' => true],
['DisplayName' => 'Spec422 Scoped Rule', 'Enabled' => false],
);
[, $foreignEnvironment] = createMinimalUserWithTenant(role: 'owner');
expect(app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource, $foreignEnvironment, $user))->toBe([]);
});
it('Spec422 requires latest evidence to belong to the same provider connection as the resource', function (): void {
[$user, $environment, $resource, $latestEvidence] = spec422FeatureEvidencePair(
'appPermissionPolicy',
['DisplayName' => 'Spec422 Provider Policy', 'BlockAppList' => []],
['DisplayName' => 'Spec422 Provider Policy', 'BlockAppList' => [['DisplayName' => 'Consumer App', 'AppId' => 'consumer-app']]],
);
$foreignConnection = ProviderConnection::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
]);
$latestEvidence->forceFill(['provider_connection_id' => (int) $foreignConnection->getKey()])->save();
$resource->unsetRelation('latestEvidence');
$details = app(CoverageV2ReadinessReadModel::class)->inspectDetails($resource->fresh(), $environment, $user);
expect($details['typed_render_summary'] ?? null)->toBeNull();
});
/**
* @return array{0: mixed, 1: mixed, 2: TenantConfigurationResource, 3: TenantConfigurationResourceEvidence}
*/
function spec422FeatureEvidencePair(string $canonicalType, array $previousPayload, array $latestPayload): array
{
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(),
]);
$resourceType = spec422FeatureResourceType($canonicalType);
$displayName = (string) ($latestPayload['DisplayName'] ?? $latestPayload['DomainName'] ?? 'Spec422 Feature Resource');
$resource = TenantConfigurationResource::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'canonical_type' => $canonicalType,
'canonical_resource_key' => $canonicalType.':provider_external_id:spec422-feature',
'canonical_key_kind' => CanonicalKeyKind::ProviderExternalId->value,
'source_resource_id' => 'spec422-feature',
'source_display_name' => $displayName,
'source_class' => SourceClass::Tcm->value,
'source_metadata' => [
'source_contract_key' => 'spec422.synthetic.'.$canonicalType,
'source_endpoint' => '/spec422/synthetic/'.$canonicalType,
'source_version' => 'v1.0',
'registry_source_class' => SourceClass::Tcm->value,
'registry_support_state' => 'out_of_scope',
],
'latest_evidence_state' => EvidenceState::ContentBacked->value,
'latest_identity_state' => IdentityState::Stable->value,
'latest_claim_state' => ClaimState::InternalOnly->value,
'latest_captured_at' => now(),
]);
$previousRun = spec422FeatureRun($user, $environment, $connection, $canonicalType, minutesAgo: 5);
$latestRun = spec422FeatureRun($user, $environment, $connection, $canonicalType);
TenantConfigurationResourceEvidence::factory()->create([
'resource_id' => (int) $resource->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'operation_run_id' => (int) $previousRun->getKey(),
'source_contract_key' => 'spec422.synthetic.'.$canonicalType,
'source_endpoint' => '/spec422/synthetic/'.$canonicalType,
'source_version' => 'v1.0',
'raw_payload' => ['id' => 'spec422-feature'],
'normalized_payload' => $previousPayload,
'payload_hash' => hash('sha256', json_encode($previousPayload, JSON_THROW_ON_ERROR)),
'evidence_state' => EvidenceState::ContentBacked->value,
'coverage_level' => CoverageLevel::Comparable->value,
'capture_outcome' => CaptureOutcome::Captured->value,
'captured_at' => now()->subMinutes(5),
]);
$latestEvidence = TenantConfigurationResourceEvidence::factory()->create([
'resource_id' => (int) $resource->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'operation_run_id' => (int) $latestRun->getKey(),
'source_contract_key' => 'spec422.synthetic.'.$canonicalType,
'source_endpoint' => '/spec422/synthetic/'.$canonicalType,
'source_version' => 'v1.0',
'raw_payload' => ['id' => 'spec422-feature', 'secret' => 'spec422-feature-secret'],
'normalized_payload' => $latestPayload,
'payload_hash' => hash('sha256', json_encode($latestPayload, JSON_THROW_ON_ERROR)),
'evidence_state' => EvidenceState::ContentBacked->value,
'coverage_level' => CoverageLevel::Renderable->value,
'capture_outcome' => CaptureOutcome::Captured->value,
'captured_at' => now(),
]);
$resource->forceFill([
'latest_evidence_id' => (int) $latestEvidence->getKey(),
'latest_payload_hash' => (string) $latestEvidence->payload_hash,
])->save();
return [$user, $environment, $resource->refresh(), $latestEvidence];
}
function spec422FeatureResourceType(string $canonicalType): TenantConfigurationResourceType
{
return TenantConfigurationResourceType::query()
->where('canonical_type', $canonicalType)
->where('source_class', SourceClass::Tcm->value)
->firstOrFail();
}
function spec422FeatureRun($user, $environment, ProviderConnection $connection, string $canonicalType, int $minutesAgo = 0): OperationRun
{
$timestamp = $minutesAgo > 0 ? now()->subMinutes($minutesAgo) : now();
return OperationRun::factory()->withUser($user)->forTenant($environment)->create([
'type' => OperationRunType::TenantConfigurationCapture->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'target_scope' => [
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
],
'resource_types' => [$canonicalType],
],
'started_at' => $timestamp,
'completed_at' => $timestamp,
]);
}
function spec422FailingGraphClient(): GraphClientInterface
{
return new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
throw new RuntimeException('Spec422 render path must not call provider clients.');
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
throw new RuntimeException('Spec422 render path must not call provider clients.');
}
public function getOrganization(array $options = []): GraphResponse
{
throw new RuntimeException('Spec422 render path must not call provider clients.');
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
throw new RuntimeException('Spec422 render path must not call provider clients.');
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
throw new RuntimeException('Spec422 render path must not call provider clients.');
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
throw new RuntimeException('Spec422 render path must not call provider clients.');
}
};
}

View File

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\CoverageEvidenceWriter;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\EvidenceState;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\SourceClass;
it('Spec422 promotes selected Exchange and Teams content-backed evidence rows to renderable coverage', function (string $canonicalType, array $payload): 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(),
]);
$resourceType = spec422PromotionResourceType($canonicalType);
$resource = TenantConfigurationResource::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'canonical_type' => $canonicalType,
'canonical_resource_key' => $canonicalType.':provider_external_id:spec422-promotion',
'canonical_key_kind' => CanonicalKeyKind::ProviderExternalId->value,
'source_resource_id' => 'spec422-promotion',
'source_display_name' => 'Spec422 promotion '.$canonicalType,
'source_class' => SourceClass::Tcm->value,
'latest_evidence_state' => EvidenceState::ContentBacked->value,
'latest_identity_state' => IdentityState::Stable->value,
'latest_claim_state' => ClaimState::InternalOnly->value,
]);
$run = OperationRun::factory()->withUser($user)->forTenant($environment)->create([
'type' => OperationRunType::TenantConfigurationCapture->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$evidence = app(CoverageEvidenceWriter::class)->append(
resource: $resource,
resourceType: $resourceType,
providerConnection: $connection,
operationRun: $run,
decision: new CoverageSourceContractDecision(
canonicalType: $canonicalType,
outcome: CaptureOutcome::Captured,
contractKey: 'spec422.synthetic.'.$canonicalType,
sourceEndpoint: '/spec422/synthetic/'.$canonicalType,
),
rawPayload: $payload,
normalizedPayload: $payload,
payloadHash: hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)),
);
expect($evidence)->toBeInstanceOf(TenantConfigurationResourceEvidence::class)
->and($evidence->coverage_level)->toBe(CoverageLevel::Renderable)
->and($resource->fresh()->latest_evidence_state)->toBe(EvidenceState::ContentBacked);
})->with([
'transportRule' => ['transportRule', ['DisplayName' => 'Rule', 'Enabled' => true, 'Actions' => ['RedirectMessageTo' => ['security@example.com']]]],
'acceptedDomain' => ['acceptedDomain', ['DomainName' => 'contoso.com', 'DomainType' => 'Authoritative', 'IsDefault' => true]],
'appPermissionPolicy' => ['appPermissionPolicy', ['DisplayName' => 'Policy', 'AllowAppList' => [['DisplayName' => 'Planner', 'AppId' => 'planner-app']]]],
'meetingPolicy' => ['meetingPolicy', ['DisplayName' => 'Meeting', 'AllowTranscription' => true]],
]);
it('Spec422 leaves deferred Exchange and Teams types unpromoted without content-backed evidence', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
$resourceTypes = TenantConfigurationResourceType::query()
->whereIn('canonical_type', [
'remoteDomain',
'organizationConfig',
'sharedMailbox',
'mailboxPlan',
'appSetupPolicy',
'messagingPolicy',
'teamsUpdateManagementPolicy',
'voiceRoute',
])
->get()
->keyBy('canonical_type');
expect($resourceTypes)->toHaveCount(8);
foreach ($resourceTypes as $resourceType) {
expect($resourceType->default_coverage_level)->toBe(CoverageLevel::Detected)
->and($resourceType->default_evidence_state)->toBe(EvidenceState::NotCaptured)
->and($resourceType->allows_certified_claims)->toBeFalse();
}
});
function spec422PromotionResourceType(string $canonicalType): TenantConfigurationResourceType
{
return TenantConfigurationResourceType::query()
->where('canonical_type', $canonicalType)
->where('source_class', SourceClass::Tcm->value)
->firstOrFail();
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
it('Spec422 does not add Exchange or Teams specific tables, models, routes, or Filament resources', function (): void {
$paths = [
'apps/platform/database/migrations',
'apps/platform/app/Models',
'apps/platform/app/Filament',
'apps/platform/routes',
];
$joined = collect($paths)
->flatMap(fn (string $path): array => glob(repo_path($path).'/**/*422*') ?: [])
->map(fn (string $path): string => str_replace(repo_path().DIRECTORY_SEPARATOR, '', $path))
->values()
->all();
expect($joined)->toBe([]);
});

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\RestoreTier;
it('Spec422 keeps selected Exchange and Teams typed support separate from restore or certification claims', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
$resourceTypes = TenantConfigurationResourceType::query()
->whereIn('canonical_type', ['transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy'])
->get()
->keyBy('canonical_type');
expect($resourceTypes)->toHaveCount(4);
foreach ($resourceTypes as $resourceType) {
expect($resourceType->restore_tier === RestoreTier::Restorable)->toBeFalse()
->and($resourceType->allows_certified_claims)->toBeFalse()
->and($resourceType->default_claim_state)->toBe(ClaimState::InternalOnly)
->and($resourceType->default_coverage_level)->toBe(CoverageLevel::Detected)
->and($resourceType->metadata['customer_claims_allowed'] ?? null)->toBeFalse();
}
});

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
it('Spec422 does not introduce tenant_id as Coverage v2 ownership truth', function (): void {
foreach (spec422ChangedRuntimeFiles() as $path) {
expect(file_get_contents(repo_path($path)))->not->toContain('tenant_id');
}
});
/**
* @return list<string>
*/
function spec422ChangedRuntimeFiles(): array
{
return [
'apps/platform/app/Services/TenantConfiguration/ExchangeTeamsComparablePayloadNormalizer.php',
'apps/platform/app/Services/TenantConfiguration/ExchangeTeamsCoverageComparator.php',
'apps/platform/app/Services/TenantConfiguration/ExchangeTeamsRenderableSummaryBuilder.php',
'apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php',
'apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php',
'apps/platform/resources/views/filament/modals/tenant-configuration/coverage-v2-resource-inspect.blade.php',
];
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangeTeamsComparablePayloadNormalizer;
it('Spec422 normalizes Exchange accepted domains into deterministic typed shape', function (): void {
$normalizer = app(ExchangeTeamsComparablePayloadNormalizer::class);
$normalized = $normalizer->normalize('acceptedDomain', [
'Identity' => 'contoso.com',
'DomainName' => 'contoso.com',
'DomainType' => 'Authoritative',
'IsDefault' => true,
'Status' => 'Valid',
'@odata.etag' => 'etag-422',
]);
expect($normalized['domain_name'])->toBe('contoso.com')
->and($normalized['domain_type'])->toBe('Authoritative')
->and($normalized['is_default'])->toBe('yes')
->and($normalized['state'])->toBe('Valid')
->and($normalized['diagnostics']['volatile_fields'])->toContain('@odata.etag');
});
it('Spec422 redacts unsafe accepted domain diagnostics without exposing values', function (): void {
$normalizer = app(ExchangeTeamsComparablePayloadNormalizer::class);
$normalized = $normalizer->normalize('acceptedDomain', [
'DomainName' => 'contoso.com',
'DomainType' => 'Authoritative',
'privateKey' => 'spec422-accepted-domain-key',
'auditMetadata' => ['raw_payload' => ['secret' => 'spec422-audit-payload']],
]);
$encoded = json_encode($normalized, JSON_THROW_ON_ERROR);
expect($normalized['diagnostics']['unsupported_fields'])->toContain('privateKey', 'auditMetadata')
->and($normalized['diagnostics']['redacted_fields'])->toContain('privateKey', 'auditMetadata.raw_payload')
->and($encoded)->not->toContain('spec422-accepted-domain-key')
->and($encoded)->not->toContain('spec422-audit-payload');
});

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ClaimGuard;
use App\Support\TenantConfiguration\ClaimState;
it('Spec422 allows scoped internal Exchange and Teams comparable/renderable wording only as internal operator truth', function (string $claim): void {
expect(app(ClaimGuard::class)->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('Spec422 blocks unsafe Exchange, Teams, and M365 overclaims', function (string $claim): void {
expect(app(ClaimGuard::class)->evaluateStatement($claim, internalOperatorOnly: true))
->toBe(ClaimState::ClaimBlocked);
})->with([
'Exchange resources are comparable for internal operator review',
'Exchange resources are renderable for internal review',
'Teams resources are comparable for internal operator review',
'Teams resources are renderable for internal review',
'Selected Exchange resources are comparable',
'Selected Teams resources are renderable',
'Exchange coverage',
'Teams coverage',
'M365 coverage',
'Microsoft 365 coverage',
'Exchange supported',
'Teams supported',
'M365 supported',
'Selected Exchange coverage',
'Selected Teams support',
'Full Exchange',
'Complete Teams',
'All M365',
'M365 resources are comparable for internal operator review',
'Microsoft 365 resources are renderable for internal review',
'Exchange certified coverage',
'Exchange restore-ready coverage',
'Full Exchange coverage',
'All Exchange resources are supported',
'100 percent Exchange coverage',
'Teams certified coverage',
'Teams restore-ready coverage',
'Full Teams coverage',
'All Teams resources are supported',
'100 percent Teams coverage',
'Microsoft 365 customer-ready evidence',
]);

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangeTeamsCoverageComparator;
it('Spec422 treats Exchange and Teams volatile-only differences as unchanged', function (string $canonicalType, array $payload): void {
$result = app(ExchangeTeamsCoverageComparator::class)->compare(
$canonicalType,
[...$payload, 'modifiedDateTime' => '2026-06-28T10:00:00Z'],
[...$payload, 'modifiedDateTime' => '2026-06-28T11:00:00Z'],
);
expect($result['changed'])->toBeFalse()
->and($result['classification'])->toBe('unchanged')
->and(collect($result['changes'])->pluck('classification'))->toContain('ignored_volatile');
})->with([
'transport rule' => ['transportRule', ['DisplayName' => 'Rule', 'Enabled' => true]],
'meeting policy' => ['meetingPolicy', ['DisplayName' => 'Meeting', 'AllowCloudRecording' => true]],
]);
it('Spec422 detects material Exchange and Teams changes with bounded importance', function (string $canonicalType, array $before, array $after, string $field, string $importance, string $classification = 'changed'): void {
$result = app(ExchangeTeamsCoverageComparator::class)->compare($canonicalType, $before, $after);
$change = collect($result['changes'])->firstWhere('field', $field);
expect($result['changed'])->toBeTrue()
->and($result['classification'])->toBe('changed')
->and($change)->not->toBeNull()
->and($change['classification'])->toBe($classification)
->and($change['importance'])->toBe($importance);
})->with([
'transport enabled' => [
'transportRule',
['DisplayName' => 'Rule', 'Enabled' => true],
['DisplayName' => 'Rule', 'Enabled' => false],
'enabled_state',
'critical',
],
'accepted domain default' => [
'acceptedDomain',
['DomainName' => 'contoso.com', 'IsDefault' => false],
['DomainName' => 'contoso.com', 'IsDefault' => true],
'is_default',
'critical',
],
'app blocked list' => [
'appPermissionPolicy',
['DisplayName' => 'Policy', 'BlockAppList' => []],
['DisplayName' => 'Policy', 'BlockAppList' => [['DisplayName' => 'Consumer App', 'AppId' => 'consumer-app']]],
'blocked_apps',
'critical',
'added',
],
'meeting transcription' => [
'meetingPolicy',
['DisplayName' => 'Meeting', 'AllowTranscription' => false],
['DisplayName' => 'Meeting', 'AllowTranscription' => true],
'recording_transcription.allow_transcription',
'critical',
],
]);
it('Spec422 keeps array ordering stable and handles null or empty values explicitly', function (): void {
$ordered = app(ExchangeTeamsCoverageComparator::class)->compare('appPermissionPolicy', [
'DisplayName' => 'Policy',
'AllowAppList' => [
['DisplayName' => 'Planner', 'AppId' => 'planner-app'],
['DisplayName' => 'Bookings', 'AppId' => 'bookings-app'],
],
], [
'DisplayName' => 'Policy',
'AllowAppList' => [
['DisplayName' => 'Bookings', 'AppId' => 'bookings-app'],
['DisplayName' => 'Planner', 'AppId' => 'planner-app'],
],
]);
$added = app(ExchangeTeamsCoverageComparator::class)->compare('meetingPolicy', [
'DisplayName' => 'Meeting',
'AllowCloudRecording' => null,
], [
'DisplayName' => 'Meeting',
'AllowCloudRecording' => true,
]);
expect($ordered['changed'])->toBeFalse()
->and($added['changed'])->toBeTrue()
->and(collect($added['changes'])->firstWhere('field', 'recording_transcription.allow_cloud_recording')['classification'])->toBe('added');
});
it('Spec422 records redacted and unsupported Exchange/Teams fields as non-material diagnostics', function (): void {
$result = app(ExchangeTeamsCoverageComparator::class)->compare(
'transportRule',
['DisplayName' => 'Rule', 'Enabled' => true],
['DisplayName' => 'Rule', 'Enabled' => true, 'clientSecret' => 'spec422-comparator-secret'],
);
expect($result['changed'])->toBeFalse()
->and(collect($result['changes'])->pluck('classification'))->toContain('redacted', 'unsupported_field')
->and(json_encode($result, JSON_THROW_ON_ERROR))->not->toContain('spec422-comparator-secret');
});

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangeTeamsCoverageComparator;
use App\Services\TenantConfiguration\ExchangeTeamsRenderableSummaryBuilder;
it('Spec422 keeps unsafe Exchange/Teams values out of render and compare output', function (): void {
$payload = [
'DisplayName' => 'Spec422 Redaction Meeting',
'AllowTranscription' => true,
'clientSecret' => 'spec422-client-secret',
'privateKey' => 'spec422-private-key',
'headers' => ['Authorization' => 'Bearer spec422-auth-token'],
'cookies' => ['session' => 'spec422-cookie'],
'rawPayload' => ['secret' => 'spec422-raw-payload'],
'providerResponse' => ['body' => 'spec422-provider-response-body'],
'messageBody' => 'spec422-mail-message-body',
'chatContent' => 'spec422-chat-content',
'fileContent' => 'spec422-file-content',
'recordingTranscript' => 'spec422-recording-transcript',
'operationRunContext' => ['access_token' => 'spec422-run-token'],
'auditMetadata' => ['raw_payload' => ['secret' => 'spec422-audit-secret']],
];
$summary = app(ExchangeTeamsRenderableSummaryBuilder::class)->build('meetingPolicy', $payload);
$compare = app(ExchangeTeamsCoverageComparator::class)->compare('meetingPolicy', $payload, [
...$payload,
'modifiedDateTime' => '2026-06-28T12:00:00Z',
]);
$encoded = json_encode([$summary, $compare], JSON_THROW_ON_ERROR);
expect($encoded)->not->toContain('spec422-client-secret')
->and($encoded)->not->toContain('spec422-private-key')
->and($encoded)->not->toContain('spec422-auth-token')
->and($encoded)->not->toContain('spec422-cookie')
->and($encoded)->not->toContain('spec422-raw-payload')
->and($encoded)->not->toContain('spec422-provider-response-body')
->and($encoded)->not->toContain('spec422-mail-message-body')
->and($encoded)->not->toContain('spec422-chat-content')
->and($encoded)->not->toContain('spec422-file-content')
->and($encoded)->not->toContain('spec422-recording-transcript')
->and($encoded)->not->toContain('spec422-run-token')
->and($encoded)->not->toContain('spec422-audit-secret')
->and($summary['redacted_fields'])->toContain(
'clientSecret',
'privateKey',
'headers.Authorization',
'cookies',
'rawPayload',
'providerResponse',
'messageBody',
'chatContent',
'fileContent',
'recordingTranscript',
'operationRunContext',
'auditMetadata.raw_payload',
);
});
it('Spec422 keeps content-bearing transport rule condition values out of render and compare output', function (): void {
$before = [
'DisplayName' => 'Spec422 Content Rule',
'Enabled' => true,
];
$after = [
'DisplayName' => 'Spec422 Content Rule',
'Enabled' => true,
'SubjectContainsWords' => ['spec422-root-subject-secret'],
'Conditions' => [
'SubjectOrBodyContainsWords' => ['spec422-mail-body-secret'],
'AttachmentContainsWords' => ['spec422-file-attachment-secret'],
'HeaderContainsWords' => ['spec422-header-secret'],
'HeaderMatchesPatterns' => ['spec422-header-pattern-secret'],
],
'Actions' => [
'ApplyHtmlDisclaimerText' => 'spec422-disclaimer-secret',
'PrependSubject' => 'spec422-prepend-subject-secret',
'SetHeaderValue' => 'spec422-set-header-secret',
],
];
$summary = app(ExchangeTeamsRenderableSummaryBuilder::class)->build('transportRule', $after);
$compare = app(ExchangeTeamsCoverageComparator::class)->compare('transportRule', $before, $after);
$encoded = json_encode([$summary, $compare], JSON_THROW_ON_ERROR);
$materialClassifications = collect($compare['changes'])
->filter(fn (array $change): bool => in_array($change['classification'] ?? null, ['added', 'removed', 'changed'], true))
->pluck('classification')
->values();
expect($encoded)->not->toContain('spec422-root-subject-secret')
->and($encoded)->not->toContain('spec422-mail-body-secret')
->and($encoded)->not->toContain('spec422-file-attachment-secret')
->and($encoded)->not->toContain('spec422-header-secret')
->and($encoded)->not->toContain('spec422-header-pattern-secret')
->and($encoded)->not->toContain('spec422-disclaimer-secret')
->and($encoded)->not->toContain('spec422-prepend-subject-secret')
->and($encoded)->not->toContain('spec422-set-header-secret')
->and($encoded)->not->toContain('[redacted]')
->and($summary['redacted_fields'])->toContain(
'SubjectContainsWords',
'Conditions.SubjectOrBodyContainsWords',
'Conditions.AttachmentContainsWords',
'Conditions.HeaderContainsWords',
'Conditions.HeaderMatchesPatterns',
'Actions.ApplyHtmlDisclaimerText',
'Actions.PrependSubject',
'Actions.SetHeaderValue',
)
->and(collect($compare['changes'])->pluck('classification'))->toContain('redacted')
->and($compare['changed'])->toBeFalse()
->and($materialClassifications)->toBeEmpty();
});

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangeTeamsRenderableSummaryBuilder;
it('Spec422 renders operator-safe Exchange and Teams summaries without raw payload dependency', function (string $canonicalType, array $payload, string $resourceType, string $expectedText): void {
$summary = app(ExchangeTeamsRenderableSummaryBuilder::class)->build($canonicalType, $payload, [
'claim_state' => 'internal_only',
'identity_state' => 'stable',
'last_captured' => 'Jun 28, 2026 10:00 AM',
]);
$encoded = json_encode($summary, JSON_THROW_ON_ERROR);
expect($summary)->not->toBeNull()
->and($summary['resource_type'])->toBe($resourceType)
->and($encoded)->toContain($expectedText)
->and($encoded)->toContain('internal_only')
->and($encoded)->not->toContain('raw_payload')
->and($encoded)->not->toContain('source_endpoint')
->and($encoded)->not->toContain('planner-app');
})->with([
'transport rule' => [
'transportRule',
['DisplayName' => 'Rule', 'Enabled' => true, 'Actions' => ['RedirectMessageTo' => ['security@example.com']]],
'Transport rule',
'security@example.com',
],
'accepted domain' => [
'acceptedDomain',
['DomainName' => 'contoso.com', 'DomainType' => 'Authoritative', 'IsDefault' => true],
'Accepted domain',
'contoso.com',
],
'app permission policy' => [
'appPermissionPolicy',
['DisplayName' => 'Policy', 'AllowAppList' => [['DisplayName' => 'Planner', 'AppId' => 'planner-app']]],
'Teams app permission policy',
'Planner',
],
'meeting policy' => [
'meetingPolicy',
['DisplayName' => 'Meeting', 'AllowTranscription' => true],
'Teams meeting policy',
'Allow Transcription',
],
]);

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangeTeamsComparablePayloadNormalizer;
it('Spec422 normalizes Exchange transport rules into deterministic typed shape', function (): void {
$normalizer = app(ExchangeTeamsComparablePayloadNormalizer::class);
$first = $normalizer->normalize('transportRule', spec422TransportRulePayload([
'modifiedDateTime' => '2026-06-28T10:00:00Z',
'Conditions' => [
'SentTo' => ['ops@example.com', 'admin@example.com'],
'RecipientDomainIs' => ['contoso.com'],
],
]));
$second = $normalizer->normalize('transportRule', spec422TransportRulePayload([
'modifiedDateTime' => '2026-06-28T10:05:00Z',
'Conditions' => [
'RecipientDomainIs' => ['contoso.com'],
'SentTo' => ['admin@example.com', 'ops@example.com'],
],
]));
expect($first['display_name'])->toBe('Spec422 Transport Rule')
->and($first['enabled_state'])->toBe('enabled')
->and($first['priority_order'])->toBe('1')
->and($first['conditions']['sent_to'])->toBe(['admin@example.com', 'ops@example.com'])
->and($first['actions']['redirect_message_to'])->toBe(['security@example.com'])
->and($first['diagnostics']['volatile_fields'])->toContain('modifiedDateTime')
->and($first)->toBe($second);
});
it('Spec422 records unsupported and redacted Exchange transport rule diagnostics', function (): void {
$normalizer = app(ExchangeTeamsComparablePayloadNormalizer::class);
$normalized = $normalizer->normalize('transportRule', spec422TransportRulePayload([
'clientSecret' => 'spec422-transport-secret',
'messageBody' => 'spec422-mail-content',
'providerResponse' => ['body' => 'spec422-provider-body'],
]));
$encoded = json_encode($normalized, JSON_THROW_ON_ERROR);
expect($normalized['diagnostics']['unsupported_fields'])->toContain('clientSecret', 'messageBody', 'providerResponse')
->and($normalized['diagnostics']['redacted_fields'])->toContain('clientSecret', 'messageBody', 'providerResponse')
->and($encoded)->not->toContain('spec422-transport-secret')
->and($encoded)->not->toContain('spec422-mail-content')
->and($encoded)->not->toContain('spec422-provider-body');
});
function spec422TransportRulePayload(array $overrides = []): array
{
return array_replace_recursive([
'Identity' => 'transport-rule-422',
'DisplayName' => 'Spec422 Transport Rule',
'Enabled' => true,
'Priority' => 1,
'Mode' => 'Enforce',
'Conditions' => [
'SentTo' => ['admin@example.com'],
'RecipientDomainIs' => ['contoso.com'],
],
'Actions' => [
'RedirectMessageTo' => ['security@example.com'],
],
'Exceptions' => [
'ExceptIfFrom' => ['breakglass@example.com'],
],
], $overrides);
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangeTeamsComparablePayloadNormalizer;
it('Spec422 normalizes Teams app permission policies into deterministic typed shape', function (): void {
$normalizer = app(ExchangeTeamsComparablePayloadNormalizer::class);
$first = $normalizer->normalize('appPermissionPolicy', spec422AppPermissionPolicyPayload([
'AllowAppList' => [
['DisplayName' => 'Planner', 'AppId' => 'planner-app'],
['DisplayName' => 'Bookings', 'AppId' => 'bookings-app'],
],
]));
$second = $normalizer->normalize('appPermissionPolicy', spec422AppPermissionPolicyPayload([
'AllowAppList' => [
['DisplayName' => 'Bookings', 'AppId' => 'bookings-app'],
['DisplayName' => 'Planner', 'AppId' => 'planner-app'],
],
]));
expect($first['display_name'])->toBe('Spec422 Teams App Permission Policy')
->and($first['policy_mode'])->toBe('BlockedAppList')
->and($first['allowed_apps'])->toBe([
['display_name' => 'Bookings', 'app_id' => 'bookings-app'],
['display_name' => 'Planner', 'app_id' => 'planner-app'],
])
->and($first['blocked_apps'])->toBe([
['display_name' => 'Consumer App', 'app_id' => 'consumer-app'],
])
->and($first['targets']['assigned_groups'])->toBe(['All Staff'])
->and($first)->toBe($second);
});
it('Spec422 redacts unsafe Teams app policy diagnostics without exposing values', function (): void {
$normalizer = app(ExchangeTeamsComparablePayloadNormalizer::class);
$normalized = $normalizer->normalize('appPermissionPolicy', spec422AppPermissionPolicyPayload([
'headers' => ['Authorization' => 'Bearer spec422-teams-token'],
'cookies' => ['session' => 'spec422-cookie'],
'operationRunContext' => ['access_token' => 'spec422-run-token'],
]));
$encoded = json_encode($normalized, JSON_THROW_ON_ERROR);
expect($normalized['diagnostics']['unsupported_fields'])->toContain('headers', 'cookies', 'operationRunContext')
->and($normalized['diagnostics']['redacted_fields'])->toContain('headers.Authorization', 'cookies', 'operationRunContext')
->and($encoded)->not->toContain('spec422-teams-token')
->and($encoded)->not->toContain('spec422-cookie')
->and($encoded)->not->toContain('spec422-run-token');
});
function spec422AppPermissionPolicyPayload(array $overrides = []): array
{
return array_replace_recursive([
'Identity' => 'teams-app-permission-policy-422',
'DisplayName' => 'Spec422 Teams App Permission Policy',
'GlobalCatalogAppsType' => 'BlockedAppList',
'AllowAppList' => [
['DisplayName' => 'Planner', 'AppId' => 'planner-app'],
],
'BlockAppList' => [
['DisplayName' => 'Consumer App', 'AppId' => 'consumer-app'],
],
'AssignedGroups' => ['All Staff'],
], $overrides);
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangeTeamsComparablePayloadNormalizer;
it('Spec422 normalizes Teams meeting policies into deterministic typed shape', function (): void {
$normalizer = app(ExchangeTeamsComparablePayloadNormalizer::class);
$normalized = $normalizer->normalize('meetingPolicy', [
'Identity' => 'meeting-policy-422',
'DisplayName' => 'Spec422 Teams Meeting Policy',
'State' => 'Enabled',
'AllowAnonymousUsersToJoinMeeting' => false,
'AllowCloudRecording' => true,
'AllowTranscription' => true,
'AutoAdmittedUsers' => 'EveryoneInCompany',
'AllowPSTNUsersToBypassLobby' => false,
'ScreenSharingMode' => 'EntireScreen',
'AllowPowerPointSharing' => true,
'AllowWhiteboard' => true,
'AllowSharedNotes' => false,
'AllowMeetingReactions' => true,
'AllowParticipantGiveRequestControl' => false,
]);
expect($normalized['display_name'])->toBe('Spec422 Teams Meeting Policy')
->and($normalized['state'])->toBe('Enabled')
->and($normalized['external_access']['allow_anonymous_users_to_join_meeting'])->toBeFalse()
->and($normalized['recording_transcription']['allow_cloud_recording'])->toBeTrue()
->and($normalized['recording_transcription']['allow_transcription'])->toBeTrue()
->and($normalized['lobby_admission']['auto_admitted_users'])->toBe('EveryoneInCompany')
->and($normalized['content_sharing']['screen_sharing_mode'])->toBe('EntireScreen');
});
it('Spec422 redacts unsafe Teams meeting policy content fields', function (): void {
$normalizer = app(ExchangeTeamsComparablePayloadNormalizer::class);
$normalized = $normalizer->normalize('meetingPolicy', [
'DisplayName' => 'Spec422 Teams Meeting Policy',
'chatContent' => 'spec422-chat-content',
'fileContent' => 'spec422-file-content',
'recordingTranscript' => 'spec422-recording-transcript',
]);
$encoded = json_encode($normalized, JSON_THROW_ON_ERROR);
expect($normalized['diagnostics']['unsupported_fields'])->toContain('chatContent', 'fileContent', 'recordingTranscript')
->and($normalized['diagnostics']['redacted_fields'])->toContain('chatContent', 'fileContent', 'recordingTranscript')
->and($encoded)->not->toContain('spec422-chat-content')
->and($encoded)->not->toContain('spec422-file-content')
->and($encoded)->not->toContain('spec422-recording-transcript');
});

View File

@ -0,0 +1,81 @@
# Requirements Checklist: Spec 422 - Exchange & Teams Comparable / Renderable Pack
**Purpose**: Validate preparation completeness and quality before implementation. This checklist validates `spec.md`, `plan.md`, and `tasks.md`; it does not mark implementation work complete.
**Created**: 2026-06-28
**Feature**: `specs/422-exchange-teams-comparable-renderable-pack/spec.md`
## Preparation Checklist
- [x] Candidate is user-provided, not auto-selected from the empty active candidate queue.
- [x] Spec 422 did not already exist in `specs/` before creation.
- [x] No existing local/remote `422-exchange-teams-comparable-renderable-pack` branch was found before creation.
- [x] Specs 414, 415, 417, 418, 419, 420, and 421 are read-only dependency context only.
- [x] Current repo truth for Coverage v2 registry, generic evidence, canonical identity, Claim Guard, redaction, OperationRun, and existing operator surface was checked.
- [x] Draft-to-repo deviations are documented.
- [x] No application implementation was performed during preparation.
## Candidate Scope Checklist
- [x] Scope is bounded to selected Exchange/Teams comparable/renderable support.
- [x] `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` are the mandatory first typed support set only when content-backed evidence exists.
- [x] Optional Exchange/Teams types are evidence-gated instead of assumed content-backed.
- [x] Missing source contracts or live capture are documented as blockers/deferred work, not fixed inside this spec.
- [x] No capture expansion, source contract creation, restore, certification, customer output, report/download, or new UI start action is in scope.
- [x] No Exchange/Teams-specific table family, persisted compare history, mini-platform, provider framework, or `tenant_id` is in scope.
## Product Surface Checklist
- [x] UI Surface Impact records existing Coverage v2 operator-surface rendering impact.
- [x] Product Surface Contract is referenced and applied.
- [x] Page archetype, primary question, primary action, surface budget, Technical Annex demotion, canonical vocabulary, visible complexity, and exceptions are recorded.
- [x] Browser proof is required if rendered output changes, or `N/A - no rendered UI surface changed` must be justified.
- [x] Human Product Sanity is required if rendered output changes, or N/A must be justified.
- [x] Product Surface exceptions are `none`.
- [x] Stop-and-amend rule exists for any new route, navigation, action, dashboard, customer output, report, download, restore/certify control, capture contract, or broader UI scope.
## OperationRun / RBAC / Scope Checklist
- [x] No new OperationRun type or start/completion/link UX is planned.
- [x] Existing OperationRun references remain diagnostic only if rendered.
- [x] Existing Coverage v2 read authorization applies.
- [x] Non-member or wrong workspace/environment scope denies as not found.
- [x] Established member without capability denies as forbidden.
- [x] Provider connection scope must match workspace and managed environment.
## Evidence / Compare / Render Checklist
- [x] Promotion requires content-backed evidence and focused tests.
- [x] Missing-evidence types remain unpromoted with blockers/deferred reasons.
- [x] Compare classifications are explicit and deterministic.
- [x] Derived importance labels are non-persisted compare output only.
- [x] Volatile fields, null/empty handling, stable ordering, redaction, and unsupported fields are addressed.
- [x] Render output hides raw payloads, secrets, mail content, Teams content, recordings, transcripts, and provider responses by default.
## Claim / Customer Output Checklist
- [x] Scoped internal comparable/renderable claims are allowed only when proven.
- [x] Certified, restore-ready, customer-ready, full, all-resource, and 100 percent Exchange/Teams/M365 claims are blocked.
- [x] No customer-facing route, Review Pack, management report, PDF, export, download, or customer-safe proof is in scope.
- [x] Customer output gate remains N/A/no output for this spec.
## Testing Checklist
- [x] Unit tests are planned for normalization, compare, render, redaction, and Claim Guard.
- [x] Feature tests are planned for evidence-gated promotion, RBAC/scope, no restore/certification, no tenant_id, no mini-platform, and no overclaim.
- [x] Browser proof is conditional on rendered output changes.
- [x] No live Graph/TCM/provider call is required for tests.
- [x] Validation commands are listed.
## Spec Readiness Checklist
- [x] Problem statement, product value, user stories, requirements, acceptance criteria, success criteria, assumptions, risks, and open questions are present.
- [x] Plan identifies likely affected repo surfaces and does not require application implementation during preparation.
- [x] Tasks are ordered, bounded, verifiable, unchecked for future implementation, and include validation and close-out tasks.
- [x] RBAC, workspace/managed-environment isolation, OperationRun semantics, evidence/result truth, Product Surface, provider boundary, test governance, and proportionality are addressed.
- [x] No open question blocks the narrowed implementation-ready slice.
## Review Outcome
- [x] Review outcome class: acceptable-special-case for preparation.
- [x] Workflow outcome: keep.
- [x] Remaining condition: implementation must document any non-promoted Exchange/Teams type as a blocker/deferred result instead of expanding capture scope.

View File

@ -0,0 +1,120 @@
# Implementation Report: Spec 422 - Exchange & Teams Comparable / Renderable Pack
## Preflight
- **Active spec**: `specs/422-exchange-teams-comparable-renderable-pack/`
- **Branch**: `422-exchange-teams-comparable-renderable-pack`
- **HEAD**: `69d4ecbb feat: complete spec 421 Entra comparable/renderable pack (#488)`
- **Initial dirty state**: untracked active spec directory only.
- **Activated skills**: `spec-kit-implementation-loop`, `pest-testing`, `.agent/workflows/spec-readiness-gate`, `.agent/repo-contracts/workspace-scope-safety`, `.agent/repo-contracts/rbac-action-safety`, `.agent/repo-contracts/evidence-anchor-contract`, `.agent/repo-contracts/product-surface-gate`, `.agent/workflows/filament-livewire-v5-change-loop`.
- **Hard-gate stop conditions checked**: no unrelated dirty files; no completed spec rewrite; no new capture/source contract; no restore/apply/certification/customer output; no new route/navigation/action/table; no OperationRun lifecycle change; no `tenant_id` ownership path; no raw payload/default customer proof; no render-time Graph/TCM/provider/HTTP call.
## Completed-Spec Guardrail
Specs 414, 415, 417, 418, 419, 420, and 421 were used as read-only dependency context. No files under their spec directories were edited.
## Exchange/Teams Evidence Matrix
| Canonical type | Workload | Current repo source truth | Spec 422 result |
| --- | --- | --- | --- |
| `transportRule` | Exchange | Registry-only M365 representative entry; no source contract mapping; capture stays blocked/deferred. | Typed compare/render support for content-backed rows only; live capture deferred. |
| `acceptedDomain` | Exchange | Explicit Spec 420 missing-contract blocker in `CoverageSourceContractResolver`. | Typed compare/render support for content-backed rows only; live capture deferred as missing contract. |
| `remoteDomain` | Exchange | Registry-only M365 representative entry; no source contract mapping. | Deferred. |
| `organizationConfig` | Exchange | Registry-only M365 representative entry; no source contract mapping. | Deferred. |
| `sharedMailbox` | Exchange | Registry-only M365 representative entry; no source contract mapping. | Deferred. |
| `mailboxPlan` | Exchange | Registry-only M365 representative entry; no source contract mapping. | Deferred. |
| `appPermissionPolicy` | Teams | Explicit Spec 420 missing-contract blocker in `CoverageSourceContractResolver`. | Typed compare/render support for content-backed rows only; live capture deferred as missing contract. |
| `appSetupPolicy` | Teams | Registry-only M365 representative entry; no source contract mapping. | Deferred. |
| `meetingPolicy` | Teams | Registry-only M365 representative entry; no source contract mapping; capture stays blocked/deferred. | Typed compare/render support for content-backed rows only; live capture deferred. |
| `messagingPolicy` | Teams | Registry-only M365 representative entry; no source contract mapping. | Deferred. |
| `teamsUpdateManagementPolicy` | Teams | Registry-only M365 representative entry; no source contract mapping. | Deferred. |
| `voiceRoute` | Teams | Registry-only M365 representative entry; no source contract mapping. | Deferred. |
## Implementation Summary
- Added bounded typed Exchange/Teams services under `apps/platform/app/Services/TenantConfiguration/`:
- `ExchangeTeamsComparablePayloadNormalizer`
- `ExchangeTeamsCoverageComparator`
- `ExchangeTeamsRenderableSummaryBuilder`
- Integrated renderable promotion in `CoverageEvidenceWriter` for content-backed typed rows only.
- Integrated typed Exchange/Teams summaries and compare summaries into `CoverageV2ReadinessReadModel`.
- Updated the existing Coverage v2 inspect modal to render generic `summary_fields` while preserving the Entra-specific fallback shape.
- Extended `ClaimGuard` so strictly scoped internal comparable/renderable wording is allowed for selected Exchange and Teams resources, while broad Exchange/Teams/M365 claims remain blocked.
- Hardened transport-rule redaction so content-bearing condition/action keys such as subject/body or attachment content matchers are diagnostics-only and never render values or material compare before/after data.
- Hardened Claim Guard so broad Exchange/Teams/M365 comparable/renderable wording is blocked unless it is explicitly scoped to selected internal/operator review.
- Hardened transport-rule redaction again for header matchers, HTML disclaimer text, subject/header mutation actions, and attachment filename/pattern matchers after review found additional content-bearing keys could still render.
- Hardened Claim Guard again so terse `full`, `complete`, or `all` workload-only Exchange/Teams/M365 claims are blocked instead of downgraded to limited.
- Hardened Claim Guard a third time so plain broad workload coverage/support wording such as `Exchange coverage`, `Teams supported`, or `M365 coverage` is blocked while selected internal comparable/renderable wording and registry-scoped internal denominator wording remain internal-only.
- Productized the shared Coverage v2 resource inspect surface after review: resource tables no longer show canonical keys as default row descriptions; the inspect slide-over shows operator-safe summary/context by default; source class, canonical type/key, evidence hash, source contract/schema, identity reason, and OperationRun links are hidden behind `View technical details`; duplicate claim/identity/capture rows were removed from typed Exchange/Teams summaries; Teams app permission summaries no longer display provider app IDs by default.
- Added focused Pest unit, feature, and browser coverage for normalization, compare, render, redaction, claims, promotion, no-restore/no-certification, scope/provider boundaries, no `tenant_id`, and no mini-platform drift.
## Promoted Types
| Canonical type | Normalizer | Compare | Render | Promotion path |
| --- | --- | --- | --- | --- |
| `transportRule` | display name, enabled/state, priority/order, mode, conditions, actions, exceptions, diagnostics | enabled critical; actions/conditions/exceptions/priority/mode material; volatile ignored | summary fields for display/enabled/priority/mode/conditions/actions/exceptions | Content-backed synthetic/existing evidence rows can become `renderable`. |
| `acceptedDomain` | domain name, domain type, default indicator, state, diagnostics | domain/default critical; type/state material | summary fields for domain/type/default/state | Content-backed synthetic/existing evidence rows can become `renderable`. |
| `appPermissionPolicy` | display name, policy mode, allowed/blocked apps, targets, diagnostics | allowed/blocked apps critical; policy mode/targets material | summary fields for policy mode, apps, targets | Content-backed synthetic/existing evidence rows can become `renderable`. |
| `meetingPolicy` | display name, state, external access, recording/transcription, lobby/admission, content sharing, diagnostics | external/recording critical; lobby/content sharing/state material | summary fields for external/recording/lobby/content sharing | Content-backed synthetic/existing evidence rows can become `renderable`. |
## Deferred Types
`remoteDomain`, `organizationConfig`, `sharedMailbox`, `mailboxPlan`, `appSetupPolicy`, `messagingPolicy`, `teamsUpdateManagementPolicy`, and `voiceRoute` remain deferred because this implementation did not add source contracts or content-backed evidence capture for those types.
## Product Surface Close-Out
- **Runtime UI files changed**: `apps/platform/resources/views/filament/modals/tenant-configuration/coverage-v2-resource-inspect.blade.php`.
- **UI impact decision**: Existing Coverage v2 Technical Annex inspect rendering only if typed summaries are present.
- **Product Surface exceptions**: none.
- **No-legacy posture**: no legacy UI, route, navigation, action, dashboard, or report surface added; existing Coverage v2 page/modal reused.
- **Product Surface Impact**: Technical Annex detail disclosure only. No customer-facing output, restore/certify/export/download flow, or management-report content changed.
- **UI Surface Impact**: Inspect modal can now show typed `summary_fields` for Exchange/Teams renderable evidence; existing Entra rendering remains compatible through fallback fields.
- **Page archetype**: existing read-only registry/report page with read-only inspect slide-over.
- **Surface budgets**: no new page/header/row actions, no new navigation, no new table, no new dashboard, no new asset bundle.
- **Technical Annex / deep-link demotion**: raw technical evidence remains demoted; default modal content exposes operator-safe summary fields, material compare context, resource type, provider connection, capture time, and capture outcome only. Source class, canonical keys, hashes, source contracts, identity reason codes, and OperationRun links require explicit `View technical details` disclosure.
- **Canonical status vocabulary**: reused `detected`, `content_backed`, `comparable`, `renderable`, claim/identity/evidence badge vocabulary; no new persisted status family.
- **Browser proof**: `./vendor/bin/sail artisan test tests/Browser/Spec422ExchangeTeamsComparableRenderableOperatorSurfaceSmokeTest.php` passed, including assertions that technical proof is hidden by default and still available in the disclosure.
- **Human Product Sanity**: passed after Productization fix loop. The 768px screenshot shows a focused operator summary, material changes, and a quiet technical disclosure instead of default-visible hashes, canonical keys, source contracts, or OperationRun links.
- **Visible complexity outcome**: decreased. One existing inspect section gained typed rows only when data exists, while technical proof moved out of the default visual hierarchy.
- **Livewire v4**: unchanged and compliant with current Filament v5 baseline.
- **Provider registration**: unchanged; Laravel panel providers remain in `bootstrap/providers.php`.
- **Global search**: unchanged; no Filament Resource was added or modified.
- **Destructive/high-impact actions**: none added or modified.
- **Asset strategy**: no new assets registered; no new `filament:assets` requirement beyond existing deployment practice.
## Validation
- `./vendor/bin/sail bin pint --dirty --format agent` - passed.
- `./vendor/bin/sail artisan test tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsRedactionTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsClaimGuardTest.php` - failed before the second hardening pass, then passed after the third Claim Guard hardening, 37 tests / 79 assertions.
- `./vendor/bin/sail artisan test tests/Unit/Support/TenantConfiguration/Spec421EntraClaimGuardTest.php tests/Unit/Support/TenantConfiguration/ClaimGuardTest.php tests/Unit/Support/TenantConfiguration/Spec419M365ClaimGuardTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureClaimGuardTest.php` - passed after the third Claim Guard hardening, 80 tests / 84 assertions.
- `./vendor/bin/sail artisan test tests/Unit/Support/TenantConfiguration/Spec422ExchangeTransportRuleNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeAcceptedDomainNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422TeamsAppPermissionPolicyNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422TeamsMeetingPolicyNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsComparableDiffTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsRenderableSummaryTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsRedactionTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsClaimGuardTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsCoverageLevelPromotionTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsComparableRenderableTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoRestoreNoCertificationTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoMiniPlatformTest.php` - passed after the third Claim Guard hardening, 70 tests / 280 assertions.
- `./vendor/bin/sail artisan test tests/Browser/Spec422ExchangeTeamsComparableRenderableOperatorSurfaceSmokeTest.php` - passed, 1 test / 53 assertions.
- `./vendor/bin/sail artisan test tests/Unit/Support/TenantConfiguration/Spec421EntraClaimGuardTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraComparableDiffTest.php tests/Unit/Support/TenantConfiguration/Spec421EntraRenderableSummaryTest.php tests/Feature/TenantConfiguration/Spec421EntraComparableRenderableTest.php tests/Feature/TenantConfiguration/Spec421EntraCoverageLevelPromotionTest.php tests/Feature/Filament/CoverageV2ReadinessPageTest.php` - passed, 28 tests / 193 assertions.
- `./vendor/bin/sail artisan test tests/Browser/Spec421EntraComparableRenderableOperatorSurfaceSmokeTest.php` - passed, 1 test / 54 assertions.
- Combined Spec 421/422 focused regression run passed after the third Claim Guard hardening, 98 tests / 473 assertions.
- Full smoke lane passed after the third Claim Guard hardening: Spec 422 focused unit/feature, Spec 421 comparable/renderable/readiness regression, and Spec 419/420/shared Claim Guard regression, 136 tests / 515 assertions.
- Manual Claim Guard probe passed: plain broad `Exchange coverage`, `Teams supported`, and `M365 coverage` evaluate to `claim_blocked`; selected internal comparable/renderable and registry-scoped internal denominator wording evaluate to `internal_only`.
- Combined Spec 421/422 browser smoke passed, 2 tests / 107 assertions.
- Productization fix loop validation:
- `./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsRenderableSummaryTest.php tests/Feature/Filament/CoverageV2ReadinessPageTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsComparableRenderableTest.php` - passed, 19 tests / 178 assertions.
- `./vendor/bin/sail artisan test --compact tests/Browser/Spec418CoverageV2OperatorSurfaceSmokeTest.php tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php tests/Browser/Spec421EntraComparableRenderableOperatorSurfaceSmokeTest.php tests/Browser/Spec422ExchangeTeamsComparableRenderableOperatorSurfaceSmokeTest.php` - passed, 4 tests / 206 assertions.
- Final focused rerun after duplicate-status pruning: `./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsRenderableSummaryTest.php tests/Feature/Filament/CoverageV2ReadinessPageTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsComparableRenderableTest.php` - passed, 19 tests / 178 assertions; `./vendor/bin/sail artisan test --compact tests/Browser/Spec422ExchangeTeamsComparableRenderableOperatorSurfaceSmokeTest.php` - passed, 1 test / 64 assertions.
- `./vendor/bin/sail bin pint --dirty --format agent` - passed.
- `git diff --check` - passed.
## Deployment Impact
- No migrations.
- No environment variables.
- No queues/cron changes.
- No storage/volume changes.
- No frontend asset registration or bundling changes.
- Existing deployment command posture remains sufficient; no additional `filament:assets` work is introduced by this spec.
## Residual Risks / Follow-Up Candidates
- Live capture/source contracts for Exchange/Teams remain intentionally deferred.
- Optional Exchange/Teams types remain registry-only until content-backed evidence and explicit tests exist.
- Restore/apply/certification/customer output remain out of scope and blocked from this implementation.
- Content-backed rows from future capture work must continue to pass through redaction before typed render/compare.
- Final dirty state after implementation: active Spec 422 runtime/test/spec changes only; no completed historical spec directories edited.

View File

@ -0,0 +1,208 @@
# Implementation Plan: Spec 422 - Exchange & Teams Comparable / Renderable Pack
**Branch**: `422-exchange-teams-comparable-renderable-pack` | **Date**: 2026-06-28 | **Spec**: `specs/422-exchange-teams-comparable-renderable-pack/spec.md`
**Input**: Feature specification from `/specs/422-exchange-teams-comparable-renderable-pack/spec.md`
## Summary
Promote selected Exchange and Teams Coverage v2 evidence rows to comparable/renderable support without adding capture scope, source contracts, restore, certification, customer output, or workload mini-platforms. The mandatory implementation-ready support set is `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy`, but promotion is strictly evidence-gated: types without content-backed evidence stay blocked/deferred and must not receive registry-default or customer-facing support claims.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
**Primary Dependencies**: Existing Tenant Configuration / Coverage v2 services, `ClaimGuard`, `BadgeCatalog`/`BadgeRenderer`, Pest 4; no new Graph/TCM dependency in this spec
**Storage**: Existing PostgreSQL tables for `tenant_configuration_resource_types`, `tenant_configuration_resources`, and `tenant_configuration_resource_evidence`; no new table by default
**Testing**: Pest 4 unit, feature, and focused browser if rendered UI changes
**Validation Lanes**: fast-feedback, confidence, browser if rendered output changes
**Target Platform**: Laravel Sail locally, Dokploy container deployment for staging/production
**Project Type**: web app under `apps/platform`
**Performance Goals**: render/compare from persisted evidence only; no remote/provider calls during UI render and no new per-row query or service loop outside the existing read model for typed summaries
**Constraints**: no source-contract/capture expansion, no restore, no certification, no customer-facing claims, no `tenant_id`, no Exchange/Teams mini-platform, no new OperationRun type
**Scale/Scope**: four mandatory first support types when content-backed evidence exists; optional listed types only if already content-backed and testable
## UI / Surface Guardrail Plan
- **Guardrail scope**: existing internal/operator Coverage v2 surface may change through rendered evidence/status summaries.
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: existing `CoverageV2Readiness` page, `CoverageV2ResourceTypesTable`, `CoverageV2ResourceInstancesTable`, inspect slide-over, and read model if rendered summaries are exposed. No new route, navigation, panel provider, action, report, download, or customer surface.
- **No-impact class, if applicable**: N/A - rendered data may change.
- **Native vs custom classification summary**: native Filament + existing widgets/read model.
- **Shared-family relevance**: evidence/status/read-only registry and inspect details.
- **State layers in scope**: page/detail evidence display; no shell/navigation state.
- **Audience modes in scope**: operator-MSP and support-platform; no customer/read-only output.
- **Decision/diagnostic/raw hierarchy plan**: typed summary first, diagnostics second, raw/support evidence hidden or secondary.
- **Raw/support gating plan**: raw payload not default-visible; secrets and mail/Teams content never shown.
- **One-primary-action / duplicate-truth control**: keep existing inspect link as the only action; no capture/restore/certify/export action.
- **Handling modes by drift class or surface**: Product Surface and browser proof are review-mandatory if rendered output changes; runtime UI expansion is hard-stop until spec/plan/tasks are amended.
- **Repository-signal treatment**: report-only for no new UI files; review-mandatory for existing rendered output changes; exception-required for any new surface/action.
- **Special surface test profiles**: shared-detail-family / standard-native-filament.
- **Required tests or manual smoke**: focused browser if rendered output changes; feature tests for no raw/default overclaim.
- **Exception path and spread control**: none.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
- **UI/Productization coverage decision**: existing internal surface only; no coverage artifact unless UI scope expands.
- **Coverage artifacts to update**: none by default.
- **No-impact rationale**: N/A.
- **Navigation / Filament provider-panel handling**: no provider/panel/navigation change.
- **Screenshot or page-report need**: no page report by default; browser proof is sufficient for existing internal surface rendering.
## 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**: Existing Technical Annex / internal operator evidence inspection surface; budgets pass because no new page/action family is added.
- **Technical Annex and deep-link demotion plan**: OperationRun links, evidence IDs, source keys, raw/normalized payloads, provider IDs, permission context, and unsupported fields remain secondary/diagnostic.
- **Canonical status vocabulary plan**: Use existing Coverage v2 labels and canonical status wording; no `Exchange covered`, `Teams covered`, `certified`, `restore-ready`, `customer-ready`, `full`, or `100%` labels.
- **Product Surface exceptions**: none.
- **Browser verification plan**: focused Coverage v2 route/inspect flow if rendered output changes; otherwise `N/A - no rendered UI surface changed` with proof.
- **Human Product Sanity plan**: required if rendered output changes; result in implementation report.
- **Visible complexity outcome target**: neutral or decreased.
- **Implementation report target**: `specs/422-exchange-teams-comparable-renderable-pack/implementation-report.md`.
## Filament / Livewire / Deployment Posture
- **Livewire v4 compliance**: Livewire v4.x confirmed; no Livewire v3 APIs.
- **Panel provider registration location**: no panel provider change; Laravel 12 provider registration remains `apps/platform/bootstrap/providers.php`.
- **Global search posture**: no Filament Resource global-search behavior changed; no new Resource.
- **Destructive/high-impact action posture**: none. No restore/apply/capture/export/certify action is introduced.
- **Asset strategy**: no assets by default; `filament:assets` is not newly required beyond existing deployment practice.
- **Testing plan**: unit tests for typed behavior; feature tests for evidence-gated promotion, claims, redaction, scope, no overclaim; browser proof if rendered output changes.
- **Deployment impact**: no env vars, migrations, queues, scheduler, storage, or assets expected.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes.
- **Systems touched**: `ResourceTypeRegistry`, `TenantConfigurationResourceType`, `TenantConfigurationResource`, `TenantConfigurationResourceEvidence`, `GenericPayloadNormalizer`, `CoveragePayloadRedactor`, `CoverageIdentityStrategyRegistry`, `CanonicalIdentityResolver`, `ClaimGuard`, `CoverageV2ReadinessReadModel`, existing Coverage v2 widgets if rendered.
- **Shared abstractions reused**: Existing Coverage v2 registry, evidence, identity, redaction, Claim Guard, badges, and read-only operator surface.
- **New abstraction introduced? why?**: Bounded Exchange/Teams typed normalizer/comparator/render summary helpers may be introduced because generic payload sorting/redaction is insufficient for operator-safe mail-flow/domain/app/meeting policy comparison.
- **Why the existing abstraction was sufficient or insufficient**: Existing structures are sufficient for ownership, evidence, identity, claims, redaction, and rendering host; insufficient for typed Exchange/Teams material-field semantics.
- **Bounded deviation / spread control**: Exchange/Teams-specific field semantics stay inside bounded helpers/config for evidence-backed types and must not become a provider framework.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no new start/completion/link UX.
- **Central contract reused**: N/A by default; existing OperationRun links remain diagnostics only if already present.
- **Delegated UX behaviors**: N/A.
- **Surface-owned behavior kept local**: read-only inspect only.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: N/A.
- **Exception path**: none. If compare/render becomes long-running or persisted as an operation, stop and amend the spec.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes.
- **Provider-owned seams**: Exchange and Teams resource names, Microsoft field names, transport rule behavior, accepted domain behavior, app permission policy behavior, meeting policy behavior.
- **Platform-core seams**: Coverage v2 evidence/resource state, coverage level, identity state, claim state, redaction boundary, workspace/managed-environment/provider connection ownership, Product Surface output.
- **Neutral platform terms / contracts preserved**: resource type, evidence, compare result, render summary, coverage level, claim state, identity state, provider connection, managed environment.
- **Retained provider-specific semantics and why**: Necessary to compare/render actual Exchange/Teams evidence; bounded to current selected resource types.
- **Bounded extraction or follow-up path**: document-in-feature for optional type blockers; follow-up-spec for missing Exchange/Teams capture/source contracts.
## Constitution Check
- Inventory/evidence truth: PASS. Render/compare derives from existing last-observed evidence; it does not create customer proof.
- Read/write separation: PASS. No provider write, restore, apply, or mutating UI action.
- Graph contract path: PASS by default. No new capture or render-time Graph/TCM calls; missing source contracts remain blockers.
- Deterministic capabilities: PASS. Coverage-level promotion and compare behavior are testable.
- Workspace isolation: PASS with workspace + managed environment + provider connection scope requirements.
- RBAC-UX: PASS with existing Evidence View authorization and no action surface.
- OperationRun: PASS by default. No new OperationRun type or lifecycle; existing links remain diagnostics only.
- Evidence anchor/currentness: PASS if evidence is explicit and no fallback-to-latest is added.
- Customer output: PASS. No customer-facing output, report, download, or customer-ready claim.
- Provider boundary: PASS if Exchange/Teams semantics stay in bounded typed helpers and provider-native IDs remain metadata.
- Product Surface: PASS with existing-surface proof and Product Surface exceptions `none`.
- Test governance: PASS with Unit/Feature/Browser-if-rendered lanes named.
- Proportionality: PASS. Typed helpers are justified by operator-safe compare/render and avoid new tables/frameworks.
- No premature abstraction: PASS if implementation avoids generic provider frameworks and separate Exchange/Teams engines.
- Persisted truth: PASS by default. Existing evidence remains truth; compare/render derived unless spec amended.
- Behavioral state: PASS using existing coverage levels and derived non-persisted importance labels.
- No legacy / lean doctrine: PASS. No adapters, dual reads/writes, fallback readers, legacy aliases, or `tenant_id`.
## Testing / Lane / Runtime Impact
- **Test purpose / classification by changed surface**: Unit for normalization/compare/render/redaction/Claim Guard; Feature for evidence-gated promotion, RBAC/scope/no-overclaim/no-restore/no-certification/no-tenant-id/no-mini-platform; Browser if rendered Coverage v2 output changes.
- **Validation lane(s)**: fast-feedback, confidence, browser-if-rendered.
- **Fixture/helper/factory cost**: minimal workspace/managed-environment/provider/evidence setup; fake payloads only; no live Graph/TCM calls.
- **Heavy-family visibility**: none expected.
- **Reviewer handoff**: confirm optional type blockers are documented, no runtime capture expansion occurred, and no hidden customer output or UI action was introduced.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec422ExchangeTransportRuleNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeAcceptedDomainNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422TeamsAppPermissionPolicyNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422TeamsMeetingPolicyNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsComparableDiffTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsRenderableSummaryTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsRedactionTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsClaimGuardTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec422ExchangeTeamsComparableRenderableTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsCoverageLevelPromotionTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoRestoreNoCertificationTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoMiniPlatformTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec422ExchangeTeamsComparableRenderableOperatorSurfaceSmokeTest.php` if rendered output changes
- `git diff --check`
## Likely Repo Surfaces
Runtime implementation should verify current names before editing, but likely surfaces are:
- `apps/platform/app/Services/TenantConfiguration/GenericPayloadNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php`
- `apps/platform/app/Services/TenantConfiguration/ClaimGuard.php`
- `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php`
- `apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php`
- `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php`
- `apps/platform/app/Filament/Pages/TenantConfiguration/CoverageV2Readiness.php`
- `apps/platform/app/Filament/Widgets/TenantConfiguration/CoverageV2ResourceTypesTable.php`
- `apps/platform/app/Filament/Widgets/TenantConfiguration/CoverageV2ResourceInstancesTable.php`
- New bounded helper files under `apps/platform/app/Services/TenantConfiguration/` if needed, such as `ExchangeTeamsComparablePayloadNormalizer.php`, `ExchangeTeamsCoverageComparator.php`, and `ExchangeTeamsRenderableSummaryBuilder.php`.
- Focused Spec 422 tests under `apps/platform/tests/Unit/Support/TenantConfiguration/`, `apps/platform/tests/Feature/TenantConfiguration/`, and `apps/platform/tests/Browser/` if rendered output changes.
## Domain / Model Implications
- No new database table, migration, persisted compare result table, or model is expected.
- `TenantConfigurationResourceEvidence.coverage_level` and existing CoverageLevel values can represent `comparable` / `renderable`.
- Registry defaults for Exchange/Teams types must not imply broad support or customer claims. Promotion should be evidence-gated and internal.
- Derived compare importance labels must not become a persisted taxonomy.
- Raw payload remains an internal evidence storage boundary; render summaries use redacted/normalized data.
## Implementation Phases
### Phase 0 - Preflight And Evidence Matrix
Capture branch, HEAD, dirty state, activated skills, related completed-spec guardrail, and stop conditions. Verify current Coverage v2 service names and evidence availability for all draft Exchange/Teams types. Record which types are content-backed, missing-contract, blocked, or deferred.
### Phase 1 - Tests First: Typed Semantics And Safety
Add focused Pest unit tests for mandatory first support types, deterministic compare, render summaries, volatile-field handling, redaction, and Claim Guard. Add evidence-gated tests for optional Exchange/Teams types so missing evidence remains an explicit blocker.
### Phase 2 - Evidence-Gated Promotion
Implement or extend the narrow promotion path so only content-backed, typed-tested resource evidence can report comparable/renderable support. Do not promote any missing-evidence type or add capture/source contracts.
### Phase 3 - Exchange/Teams Typed Normalization
Add bounded typed normalization for mandatory first support types and any evidence-backed optional type. Exclude volatile fields, preserve unsupported fields as diagnostics, and keep identity/source metadata separate.
### Phase 4 - Exchange/Teams Compare
Add deterministic compare semantics for selected typed payloads. Classify changes, ignore volatile fields, handle redacted values, use stable ordering, and attach derived bounded importance labels.
### Phase 5 - Exchange/Teams Render Summaries
Add operator-safe render summaries. Summaries must include state/behavior/scope/change/claim/identity/capture context without raw payload, mail/message content, Teams chat/file/recording/transcript content, or secrets.
### Phase 6 - Claim Guard, Product Surface, RBAC, And Evidence Boundaries
Extend Claim Guard tests for Exchange/Teams wording, ensure read model/surface output stays internal and authorized, and confirm no customer output, restore/certification, direct Graph calls, or raw default payload rendering.
### Phase 7 - Browser Proof If Rendered
If rendered output changes, add focused browser proof for the existing Coverage v2 surface and inspect slide-over. Verify no console errors, no Livewire/Filament errors, no secrets/raw payload, and no restore/certify/customer-ready claim.
### Phase 8 - Validation And Implementation Report
Run focused validation, `git diff --check`, and complete the implementation report with matrices, Product Surface close-out, tests, browser/no-browser, deployment impact, no completed-spec rewrite assertion, and deferred work.
## Stop Conditions
- Exchange/Teams source-contract or live capture work is needed to make selected types content-backed.
- Any implementation proposes restore/apply, certification, customer-facing output, Review Pack/report/PDF output, export/download, or broad coverage claims.
- A new OperationRun type, new capture start action, new route/navigation/dashboard, or new persisted compare table is proposed without amending the spec.
- Raw payloads, secrets, credentials, tokens, provider response bodies, provider IDs, mail content, Teams chat/file content, meeting recordings, or transcripts become default-visible.
- `tenant_id` appears as Coverage v2 ownership truth.
- A separate Exchange/Teams table family, engine, dashboard, or provider mini-platform appears.
- Render/compare performs Graph/TCM/provider/HTTP work at render time.
## Draft-To-Repo Deviation Handling
- The user draft's minimum promotion requirement is retained only for content-backed evidence rows. Current repo truth does not prove live Exchange/Teams capture/source contracts, and this spec forbids adding missing capture in the same pack.
- The user draft's optional initial resource list remains as preflight candidates, not mandatory implementation scope.
- The implementation report must include a matrix explaining promoted and deferred types.

View File

@ -0,0 +1,398 @@
# Feature Specification: Spec 422 - Exchange & Teams Comparable / Renderable Pack
**Feature Branch**: `422-exchange-teams-comparable-renderable-pack`
**Created**: 2026-06-28
**Status**: Draft
**Input**: User-provided draft "Spec 422 - Exchange & Teams Comparable / Renderable Pack", prepared through `spec-kit-next-best-prep`.
## Preparation Selection Summary
- **Selected candidate**: Spec 422 - Exchange & Teams Comparable / Renderable Pack.
- **Source location**: User-provided attachment `/Users/ahmeddarrazi/.codex/attachments/664830db-b955-4f77-9a26-ef75ccc067d7/pasted-text.txt`.
- **Why selected**: Specs 419, 420, and 421 establish the M365 Coverage v2 sequence: workload registry, generic evidence/blocker semantics, and Entra comparable/renderable precedent. The user-provided Spec 422 draft is the next explicit Exchange/Teams pack in that sequence.
- **Roadmap relationship**: Extends the Coverage v2 / M365 path after Specs 414, 415, 417, 418, 419, 420, and 421; aligns with the roadmap's anti-drift, Microsoft governance expansion, and operator-safe evidence interpretation direction.
- **Close alternatives deferred**: Security/Compliance readiness, certified packs, restore/apply, customer reports, Review Pack/PDF output, M365 dashboards, and source-contract/capture expansion are deferred because they require different claims, contracts, product surfaces, or risk controls.
- **Completed-spec guardrail result**: Specs 414, 415, 417, 418, 419, 420, and 421 are completed dependency context only. This package must not patch, normalize, reopen, or strip their implementation reports, completed tasks, validation results, or browser proof.
- **Smallest viable implementation slice**: Add evidence-gated typed compare/render support for selected Exchange and Teams Coverage v2 evidence rows, with `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` as the required first typed support set when content-backed evidence exists. Do not add capture/source contracts, restore, certification, customer output, or workload mini-platforms.
- **Candidate Selection Gate**: PASS for a directly provided candidate, with repo-truth scope reduction documented below.
## Draft-To-Repo Deviations
The attached draft assumes Exchange and Teams generic content-backed evidence may already exist for selected types. Current repo evidence shows:
- Specs 419 and 420 intentionally registered Exchange/Teams representative resource types and missing-contract blockers.
- `CoverageSourceContractResolver` currently maps explicit capture contracts for selected Intune/Entra-adjacent types and explicitly blocks at least `acceptedDomain` and `appPermissionPolicy` as missing source contracts from Spec 420.
- The current repo does not prove live content-backed capture for `transportRule`, `acceptedDomain`, `appPermissionPolicy`, or `meetingPolicy`.
- The draft also says not to fake typed support and not to implement Graph/TCM capture when missing from Spec 420.
Therefore this spec keeps the draft intent but narrows implementation truth:
- Implement typed compare/render semantics only for existing or test-seeded content-backed evidence rows.
- Do not add or guess Exchange/Teams capture contracts, remote calls, TCM fetches, Graph endpoints, or source mappings in this spec.
- Do not promote registry defaults or customer claims when live content-backed evidence is absent.
- The implementation report must include an Exchange/Teams evidence matrix that names promoted evidence-backed rows and deferred live-capture blockers.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot can recognize Exchange and Teams Coverage v2 resource types, but operators cannot safely understand or compare selected Exchange/Teams configuration evidence without raw payload reading when content-backed evidence is present.
- **Today's failure**: Exchange and Teams resource entries can exist as registry or blocked evidence context, but TenantPilot must not imply mail-flow, Teams policy, restore, certification, or customer-ready coverage before typed compare/render support and no-overclaim guardrails exist.
- **User-visible improvement**: Operators reviewing the existing Coverage v2 surface can understand selected Exchange/Teams configuration evidence through typed summaries and deterministic compare outcomes while broad Exchange, Teams, M365, restore, certification, and customer-ready claims remain blocked.
- **Smallest enterprise-capable version**: Support typed compare/render for `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` only when content-backed evidence rows exist; preflight the wider listed Exchange/Teams types and defer any missing-evidence or missing-contract type. No capture expansion, restore, certification, customer output, or new dashboard.
- **Explicit non-goals**: No Exchange restore/apply, no Teams restore/apply, no certification, no full Exchange/Teams catalog, no new Exchange/Teams-specific table family, no broad M365 coverage claims, no customer-facing report/review pack/PDF output, no new capture start action, no direct Graph/TCM calls during render/compare, no `tenant_id`, no v1 compatibility.
- **Permanent complexity imported**: Bounded typed normalization/compare/render helpers for selected Exchange/Teams resource types, derived compare importance labels, Claim Guard tests, redaction tests, and focused browser proof if rendered output changes. No new persisted entity, status family, runtime Product Surface framework, or mini-platform by default.
- **Why now**: Spec 421 proved the comparable/renderable pattern for selected Entra evidence. Exchange and Teams are the next high-value M365 governance domains, but they need strict no-overclaim boundaries before any future capture, certification, or customer-facing claim work.
- **Why not local**: A one-off Exchange/Teams UI parser would bypass Coverage v2 registry/evidence/identity/redaction/Claim Guard truth. The existing Coverage v2 path is the correct shared path.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: New typed compare/render helpers and derived importance labels. Defense: the helpers are bounded to selected evidence-backed resource types, use existing Coverage v2 truth, and directly prevent unsafe operator interpretation and overclaiming.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve as a narrowed, evidence-gated comparable/renderable pack.
## Spec Scope Fields *(mandatory)*
- **Scope**: Workspace + managed-environment scoped Coverage v2 evidence and read-model behavior for selected Exchange and Teams resource types.
- **Primary Routes**: Existing internal/operator Coverage v2 readiness route `workspaces/{workspace}/environments/{environment}/tenant-configuration/coverage-v2`; no new route, navigation entry, customer route, report, download, or dashboard.
- **Data Ownership**: Existing `TenantConfigurationResourceType`, `TenantConfigurationResource`, and `TenantConfigurationResourceEvidence` records. Environment-owned rows remain scoped by `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id` where provider-sourced.
- **RBAC**: Existing Coverage v2 read authorization applies. Non-member workspace or missing managed-environment entitlement returns 404. Established member without evidence view capability returns 403. No mutating action is introduced.
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**: Existing page, widgets, read model, and any new compare/render service must resolve records through workspace + managed environment scope and must not use provider-native tenant IDs as ownership.
## 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 Coverage v2 compare/render slice over existing evidence. No production data or external contract requires legacy Coverage v1 adapters, fallback readers, dual writes, or old customer-facing coverage vocabulary.
## UI Surface Impact *(mandatory - UI-COV-001)*
Does this spec add, remove, rename, or materially change any reachable UI surface?
- [ ] No UI surface impact
- [x] 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
- [x] Status/evidence/review presentation changed
- [ ] Workspace/environment context presentation changed
Impact is limited to data/rendering on the existing Coverage v2 internal/operator surface if implementation exposes comparable/renderable Exchange/Teams summaries or coverage-level rows. No new route, navigation, action, dashboard, customer output, report, download, restore, certify, or capture UI is allowed.
If implementation requires runtime UI edits beyond existing surface rendering, the implementation must keep this spec/plan/tasks aligned before editing runtime UI files.
## 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**: Existing Coverage v2 readiness page and inspect slide-over under `apps/platform/app/Filament/Pages/TenantConfiguration/CoverageV2Readiness.php` and `apps/platform/app/Filament/Widgets/TenantConfiguration/*`.
- **Current or new page archetype**: Technical Annex / internal operator registry and evidence inspection surface.
- **Design depth**: Internal/Hidden with Product Surface evidence/status guardrails.
- **Repo-truth level**: repo-verified existing surface.
- **Existing pattern reused**: Existing read-only Coverage v2 page, widgets, badge catalog, inspect slide-over, and read model.
- **New pattern required**: none by default.
- **Screenshot required**: focused browser proof required if rendered output changes.
- **Page audit required**: no new page audit unless a new route/navigation/surface is introduced, which is out of scope.
- **Customer-safe review required**: no customer-facing surface; customer-output gate must remain N/A/no output.
- **Dangerous-action review required**: no dangerous action.
- **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] Existing internal page only; no new coverage artifact unless runtime UI scope expands.
- **No-impact rationale when applicable**: N/A.
## 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?**: yes, because evidence/status presentation may change on an existing rendered operator surface.
- **Page archetype**: Technical Annex / internal operator evidence inspection surface.
- **Primary user question**: Which selected Exchange/Teams resources are safely comparable/renderable, and what material changes or blockers require operator attention?
- **Primary action**: Inspect selected existing Coverage v2 evidence rows. No start, restore, certify, publish, export, or customer-output action.
- **Surface budget result**: pass by reusing the existing internal surface and not adding new page/action families.
- **Technical Annex / deep-link demotion**: OperationRun links, raw evidence IDs, source keys, payloads, provider IDs, identity diagnostics, permission context, and unsupported fields remain hidden, diagnostic, or secondary.
- **Canonical status vocabulary**: Product-facing labels must map to existing canonical wording or internal Coverage v2 labels. Do not display "Exchange covered", "Teams covered", "certified", "restore-ready", "customer-ready", "100% Exchange coverage", "100% Teams coverage", or "full M365 coverage".
- **Visible complexity impact**: neutral or decreased. Typed summaries should reduce raw-payload interpretation burden without adding a new surface.
- **Product Surface exceptions**: none.
## Browser Verification Plan *(mandatory)*
- **Browser proof required?**: yes if rendered Coverage v2 output changes.
- **No-browser rationale**: `N/A - no rendered UI surface changed` is allowed only if implementation proves no rendered output changes.
- **Focused path when required**: Existing Coverage v2 readiness route for a seeded workspace/managed environment with selected content-backed Exchange/Teams evidence rows.
- **Primary interaction to execute**: Load the page, inspect selected Exchange/Teams rows, verify comparable/renderable state and operator summary, and verify no raw payload, secrets, restore/certify/customer-ready claim, or console/Livewire error.
- **Console, Livewire, Filament, network, and 500-error checks**: required for focused path when rendered data changes.
- **Full-suite failure triage**: unrelated failures may be documented only after focused proof is green.
## Human Product Sanity Check *(mandatory)*
- **Required?**: yes if rendered output changes.
- **No-human-sanity rationale**: N/A only when no rendered product surface changes.
- **Reviewer questions**: Is the summary understandable without raw payload? Is it clear this is selected Exchange/Teams comparable/renderable support, not certification or restore readiness? Are diagnostics demoted? Is there one obvious inspect path and no high-impact action?
- **Planned result location**: `specs/422-exchange-teams-comparable-renderable-pack/implementation-report.md`.
## Product Surface Merge Gate Checklist *(mandatory)*
- [x] No-legacy posture or approved exception recorded.
- [x] Product Surface Impact is completed for existing-surface evidence/status rendering.
- [x] Browser proof is required if rendered output changes, or `N/A - no rendered UI surface changed` must be justified.
- [x] Human Product Sanity is required if rendered output changes, or N/A must be justified.
- [x] Product Surface exceptions are documented as `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 completed-spec rewrite assertion.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes, evidence/status/rendering and Claim Guard behavior.
- **Interaction class(es)**: evidence inspection, coverage-level/status display, compare summaries, diagnostic detail demotion.
- **Systems touched**: Existing Coverage v2 registry/resource/evidence models, generic normalizer/redactor, identity resolver, Claim Guard, badge catalog/read model if rendered, and focused tests.
- **Existing pattern(s) to extend**: Coverage v2 resource/evidence/read model, `BadgeCatalog`/`BadgeRenderer`, `ClaimGuard`, and existing read-only Filament surface.
- **Shared contract / presenter / builder / renderer to reuse**: Existing Coverage v2 paths. Any Exchange/Teams typed helpers must remain bounded adapters under the existing Tenant Configuration service boundary.
- **Why the existing shared path is sufficient or insufficient**: It already owns evidence, source metadata, identity, claim state, redaction, read authorization, and the existing operator surface. It lacks typed compare/render behavior for selected Exchange/Teams payloads.
- **Allowed deviation and why**: Bounded Exchange/Teams typed helpers are allowed because raw generic payloads cannot safely support operator comparison. They must not become a generic provider framework or separate Exchange/Teams engine.
- **Consistency impact**: Compare/render state must align with existing coverage levels, evidence states, identity states, claim states, badge labels, RBAC behavior, and Product Surface demotion.
- **Review focus**: No raw-payload default display, no customer claims, no restore/certify action, no mini-platform, no new tables, no `tenant_id`, no endpoint guessing.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no new start/completion/link UX.
- **Shared OperationRun UX contract/layer reused**: N/A by default. Existing evidence rows may retain existing OperationRun references as diagnostics only.
- **Delegated start/completion UX behaviors**: N/A - no queued operation is introduced.
- **Local surface-owned behavior that remains**: inspect existing evidence only.
- **Queued DB-notification policy**: N/A - no new queued notification.
- **Terminal notification path**: N/A - no new OperationRun lifecycle.
- **Exception required?**: none. If compare/render becomes long-running or persisted as an operation, stop and amend this spec first.
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: yes.
- **Boundary classification**: mixed. Coverage v2 evidence/resource/claim/read-model truth is platform-core; Exchange/Teams resource semantics and Microsoft 365 field names are provider-owned typed adapters.
- **Seams affected**: compare strategy selection, typed normalization/render fields, redaction rules, coverage-level promotion, Claim Guard wording, and existing operator surface rendering.
- **Neutral platform terms preserved or introduced**: workspace, managed environment, provider connection, resource type, evidence state, coverage level, identity state, claim state, compare result, render summary.
- **Provider-specific semantics retained and why**: Exchange/Teams resource names and fields are necessary for current operator meaning, but must stay inside bounded typed mapping/helpers and source metadata.
- **Why this does not deepen provider coupling accidentally**: No provider-native tenant ID ownership, no Exchange/Teams table family, no Exchange/Teams dashboard, no provider framework, no customer claim activation, and no restore/certify support.
- **Follow-up path**: source-contract/capture enablement, additional resource types, Security/Compliance comparable packs, certification, restore, and customer reporting remain later specs.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Existing Coverage v2 readiness / inspect surface | yes, if rendered summaries or coverage levels change | Native Filament + existing widgets/read model | evidence/status/read-only registry | page/detail | no | Existing internal surface only; no new navigation/action. |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Existing Coverage v2 readiness / inspect surface | Tertiary Evidence / Diagnostics Surface | Verify selected Exchange/Teams evidence can be interpreted safely | Resource name, coverage level, evidence/identity/claim state, typed summary if rendered | raw/normalized payload, source metadata, unsupported fields, evidence hash, OperationRun link | Not primary; it supports evidence inspection and release review | Follows existing Coverage v2 internal review flow | Reduces raw-payload reading for selected Exchange/Teams evidence. |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Existing Coverage v2 readiness / inspect surface | operator-MSP, support-platform | selected resource summary, material changes, blockers, claim state | unsupported fields, source metadata, identity diagnostics | raw payload stays hidden or secondary/internal; secrets never shown | Inspect | raw payload, provider IDs, OperationRun details, source keys | Coverage level and summary state appear once and do not duplicate as broad Exchange/Teams readiness. |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Coverage v2 readiness | List / Table / Report | Read-only registry/evidence inspection | Inspect selected evidence | Primary link column | not required | none | none | existing route | inspect slide-over | workspace + managed environment | Coverage v2 resources | coverage level, evidence state, identity state, claim state | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Coverage v2 readiness / inspect | Tenant operator / release reviewer | Verify selected Exchange/Teams evidence interpretation without unsafe claims | Technical Annex / read-only evidence inspection | What selected Exchange/Teams evidence is comparable/renderable, and what changed materially? | typed summary, coverage/evidence/identity/claim state, last captured | raw payload, unsupported fields, source metadata, evidence hash, OperationRun | coverage level, evidence state, identity state, claim state, compare importance | read-only | Inspect | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no. Existing Coverage v2 resource/evidence rows remain truth.
- **New persisted entity/table/artifact?**: no new table by default. Compare/render should derive from existing evidence unless implementation proves persistence is required and this spec is amended.
- **New abstraction?**: yes, bounded typed compare/render helpers may be introduced or existing services extended.
- **New enum/state/reason family?**: no persisted enum/status family. Derived importance labels (`critical`, `important`, `informational`) may exist only inside compare output/tests.
- **New cross-domain UI framework/taxonomy?**: no.
- **Current operator problem**: Operators cannot safely understand material Exchange/Teams configuration changes from generic payloads and can over-read evidence as certification, restore readiness, or broad M365 coverage.
- **Existing structure is insufficient because**: Generic normalization sorts/redacts payloads but does not express Exchange mail-flow/domain semantics, Teams app/meeting policy semantics, volatile-field exclusions, or safe render summaries.
- **Narrowest correct implementation**: Bounded typed helpers for content-backed Exchange/Teams evidence rows, mandatory first support for `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy`, with other listed types evidence-gated.
- **Ownership cost**: Focused tests for each supported type, maintenance of field mappings as Microsoft payloads evolve, and Product Surface/browser proof if rendered output changes.
- **Alternative intentionally rejected**: Raw-payload display, source-contract guessing, and separate Exchange/Teams policy engines/dashboards were rejected because they increase operator risk and provider coupling.
- **Release truth**: Current-release truth over existing Coverage v2 evidence, not future capture, certification, restore, or customer reporting preparation.
### 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 typed normalization/compare/render/redaction/Claim Guard; Feature for evidence-gated promotion, RBAC/scope, no overclaim/no restore/no certification/no `tenant_id`; Browser if rendered Coverage v2 output changes.
- **Validation lane(s)**: fast-feedback, confidence, browser if rendered UI changes.
- **Why this classification and these lanes are sufficient**: The core behavior is deterministic pure transformation over existing evidence plus internal read-model rendering. No schema or remote provider path is added by default.
- **New or expanded test families**: Spec 422 focused unit/feature tests; one focused browser smoke only if rendered output changes.
- **Fixture / helper cost impact**: Use minimal workspace/managed-environment/provider/evidence factories and fake content-backed evidence payloads. No live Graph/TCM calls.
- **Heavy-family visibility / justification**: none by default.
- **Special surface test profile**: shared-detail-family / standard-native-filament if existing inspect slide-over changes.
- **Standard-native relief or required special coverage**: focused browser proof validates the existing read-only page and inspect summary when rendered.
- **Reviewer handoff**: Confirm lane fit, no hidden capture/OperationRun/customer-output scope, and no new heavy fixtures.
- **Budget / baseline / trend impact**: none expected.
- **Escalation needed**: document-in-feature if optional Exchange/Teams types remain blocked; follow-up-spec if source contracts/capture are needed.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec422ExchangeTransportRuleNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeAcceptedDomainNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422TeamsAppPermissionPolicyNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422TeamsMeetingPolicyNormalizerTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsComparableDiffTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsRenderableSummaryTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsRedactionTest.php tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsClaimGuardTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec422ExchangeTeamsComparableRenderableTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsCoverageLevelPromotionTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoRestoreNoCertificationTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoMiniPlatformTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec422ExchangeTeamsComparableRenderableOperatorSurfaceSmokeTest.php` if rendered output changes
- `git diff --check`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Understand Exchange mail-flow evidence safely (Priority: P1)
As a tenant operator or release reviewer, I need captured Exchange transport rule and accepted domain evidence to render as concise operator summaries and deterministic compare results so I can understand material mail-flow/domain changes without reading raw payloads.
**Independent Test**: Given content-backed Exchange evidence payloads, compare output identifies transport rule state/condition/action/priority changes and accepted domain name/type/default changes; render output summarizes the current resources without raw payload or secrets.
**Acceptance Scenarios**:
1. **Given** content-backed `transportRule` evidence, **When** the resource is rendered, **Then** the operator sees display name, enabled/state, priority/order, condition/action summaries, claim/identity state, and last captured time.
2. **Given** two `transportRule` payloads that differ only in volatile fields, **When** they are compared, **Then** the result is unchanged or marks volatile fields as ignored.
3. **Given** an accepted domain type or default-domain flag changes, **When** the payloads are compared, **Then** the result marks the change as material with bounded importance.
### User Story 2 - Understand Teams policy evidence safely (Priority: P1)
As a tenant operator or release reviewer, I need captured Teams app permission and meeting policy evidence to render as concise operator summaries and deterministic compare results so I can understand app allow/block and meeting behavior changes without reading raw payloads.
**Independent Test**: Given content-backed Teams evidence payloads, compare output identifies app permission allow/block changes and meeting policy external/recording/lobby/content-sharing changes; render output hides raw payloads and unsafe message/content data.
**Acceptance Scenarios**:
1. **Given** content-backed `appPermissionPolicy` evidence, **When** the resource is rendered, **Then** the operator sees display name, policy mode, allowed/blocked app summary, claim/identity state, and last captured time.
2. **Given** content-backed `meetingPolicy` evidence, **When** meeting access, recording, lobby, or sharing settings change, **Then** compare output identifies the material change with bounded importance.
3. **Given** payload fields that could contain chat, message, file, recording, transcript, token, or credential content, **When** render/compare output is produced, **Then** sensitive content is redacted or excluded.
### User Story 3 - Keep non-evidence-backed Exchange/Teams types honest (Priority: P1)
As a release reviewer, I need all selected Exchange/Teams types to remain unpromoted unless content-backed evidence exists so TenantPilot does not claim typed support for data it cannot prove.
**Independent Test**: Given registered Exchange/Teams resource types without content-backed evidence, the promotion path leaves them detected/content-backed-only as appropriate and records a blocker/deferred reason rather than comparable/renderable support.
**Acceptance Scenarios**:
1. **Given** a selected Exchange/Teams type has no content-backed evidence, **When** Spec 422 promotion is evaluated, **Then** it remains unpromoted and the implementation report records the blocker.
2. **Given** an optional listed type has content-backed evidence and typed tests, **When** it is promoted, **Then** it receives comparable/renderable support without restore/certification/customer claims.
### User Story 4 - Prevent Exchange/Teams overclaiming (Priority: P1)
As a product/release owner, I need Claim Guard and Product Surface output to block certification, restore readiness, full Exchange/Teams coverage, and customer-ready claims so internal compare/render support cannot become unsafe customer proof.
**Independent Test**: Claim Guard tests block forbidden wording while allowing scoped internal comparable/renderable wording, and rendered surfaces contain no restore/certified/customer-ready claims.
**Acceptance Scenarios**:
1. **Given** a claim says "Exchange certified" or "Teams restore-ready", **When** Claim Guard evaluates it, **Then** it is blocked.
2. **Given** a claim says selected Exchange or Teams resources are comparable/renderable for internal review, **When** Claim Guard evaluates it, **Then** it remains scoped/internal and does not become customer-facing proof.
## Functional Requirements
- **FR-422-001**: The implementation MUST use existing Coverage v2 resource type, resource, evidence, identity, redaction, read-model, and Claim Guard boundaries.
- **FR-422-002**: A resource type MUST be promoted to comparable/renderable only when content-backed evidence exists and typed normalization, compare, render, redaction, and tests exist.
- **FR-422-003**: `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` MUST have typed compare/render support for content-backed evidence rows.
- **FR-422-004**: `remoteDomain`, `organizationConfig`, `sharedMailbox`, `mailboxPlan`, `appSetupPolicy`, `messagingPolicy`, `teamsUpdateManagementPolicy`, and `voiceRoute` MAY be promoted only if implementation preflight proves content-backed evidence and scope stays bounded.
- **FR-422-005**: Missing source contracts or missing content-backed evidence MUST remain explicit blockers/deferred reasons and MUST NOT be papered over by default registry promotion.
- **FR-422-006**: Compare output MUST classify changes as `added`, `removed`, `changed`, `unchanged`, `ignored_volatile`, `redacted`, or `unsupported_field`.
- **FR-422-007**: Compare importance labels MUST remain derived compare-output labels only: `critical`, `important`, or `informational`.
- **FR-422-008**: Compare MUST ignore or label volatile fields including Graph/TCM context, etags, created/modified timestamps, and source metadata that is not business-relevant.
- **FR-422-009**: Compare MUST be deterministic, including stable array ordering where order is not semantically meaningful and explicit null/empty handling.
- **FR-422-010**: Render output MUST answer what the resource is, enabled/active state, behavior controlled, targets/scope where available, material change summary, blockers, redaction, unsupported fields, claim state, identity state, and capture time.
- **FR-422-011**: Render output MUST NOT show raw payloads, raw provider responses, tokens, secrets, credential values, private keys, certificate material, authorization headers, cookies, mail/message content, Teams chat content, Teams file content, meeting recordings, transcripts, or unneeded PII by default.
- **FR-422-012**: Claim Guard MUST allow only scoped internal comparable/renderable wording and MUST block certified, restore-ready, customer-ready, full, all-resource, 100 percent, or broad Exchange/Teams/M365 coverage claims.
- **FR-422-013**: No restore/apply action, certified state, customer-facing route, customer report, Review Pack output, management PDF output, export, or download is in scope.
- **FR-422-014**: Render/compare MUST be DB-only from existing evidence at render time and MUST NOT call Graph, TCM, provider clients, HTTP, remote APIs, or Microsoft documentation.
- **FR-422-015**: No new Exchange-specific or Teams-specific table family, persisted compare result table, provider mini-platform, or `tenant_id` ownership path may be introduced.
- **FR-422-016**: Existing Coverage v2 read authorization MUST apply: non-member/wrong scope deny as not found, established member missing capability forbidden, and provider connection scope must match workspace/managed environment.
- **FR-422-017**: Existing Coverage v2 operator surface MAY show comparable/renderable Exchange/Teams summaries; any rendered change requires focused browser proof and Human Product Sanity.
- **FR-422-018**: If implementation requires capture expansion, a new source contract, a new OperationRun type, new persisted truth, a new UI action, or customer output, it MUST stop and amend/split the spec first.
## Non-Functional Requirements
- **NFR-422-001**: Compare/render behavior must be deterministic across repeated runs for the same normalized evidence.
- **NFR-422-002**: Redaction must be applied before any operator render or compare summary can expose sensitive values.
- **NFR-422-003**: Existing read-only Coverage v2 surface performance must remain DB-only: no render-time remote/provider/HTTP calls and no new per-row query or service loop outside the existing read model for typed summaries.
- **NFR-422-004**: Tests must prove business truth rather than thin presentation internals: material change detection, redaction, claims, scope, and no overclaim.
- **NFR-422-005**: No UI label may imply restore, certification, full coverage, customer proof, or production readiness.
## Key Entities / Data
- **TenantConfigurationResourceType**: Existing Coverage v2 registry definition for selected Exchange/Teams resource types.
- **TenantConfigurationResource**: Existing scoped observed resource row tied to workspace, managed environment, provider connection, identity, and latest evidence.
- **TenantConfigurationResourceEvidence**: Existing append-only evidence row with raw payload boundary, normalized payload, coverage level, evidence state, capture outcome, permission context, and optional OperationRun link.
- **Compare result**: Derived in-memory output for selected evidence comparisons; not persisted by default.
- **Render summary**: Derived operator-safe view model; not persisted by default.
## Out Of Scope
- Exchange or Teams restore/apply.
- Exchange or Teams certification or certified compare.
- Full Exchange, Teams, or M365 catalog support.
- New source capture contracts, TCM fetchers, Graph endpoint mappings, or Microsoft documentation fetches.
- Customer-facing Exchange/Teams reports, Review Pack output, management PDF output, or public export.
- New Coverage v2 start/capture action, route, navigation item, dashboard, wizard, or customer workspace surface.
- New Exchange/Teams tables, mini-platform services, provider framework, or persisted compare history.
- Coverage v1 compatibility, fallback readers, dual writes, old gap taxonomy adapters, or `tenant_id`.
## Acceptance Criteria
- **AC-422-001**: Transport Rule, Accepted Domain, App Permission Policy, and Meeting Policy evidence can be normalized, compared, and rendered deterministically when content-backed evidence rows exist.
- **AC-422-002**: Optional Exchange/Teams types are either promoted with repo-real evidence and focused tests or remain explicitly blocked/deferred.
- **AC-422-003**: Compare output detects material Exchange mail-flow/domain and Teams app/meeting policy changes while ignoring volatile fields.
- **AC-422-004**: Render output hides secrets, raw payloads, mail/message/chat/file/recording/transcript content, and unsupported/redacted fields are diagnostics only.
- **AC-422-005**: Claim Guard blocks certified, restore-ready, full/100 percent Exchange/Teams or M365, customer-ready, and broad coverage claims.
- **AC-422-006**: No restore/apply/customer-output UI, action, route, report, download, capture contract, or certification state is introduced.
- **AC-422-007**: No `tenant_id`, Exchange/Teams-specific table family, persisted compare result table, or mini-platform appears.
- **AC-422-008**: Focused unit and feature tests pass; focused browser proof passes if rendered output changes.
## Success Criteria
- **SC-422-001**: A reviewer can inspect the implementation report and see an Exchange/Teams evidence matrix naming promoted and deferred types.
- **SC-422-002**: For each mandatory first support type, focused tests prove at least one volatile-only no-change comparison and at least one material-change comparison.
- **SC-422-003**: Redaction tests prove no secret/credential/token/raw payload or content-bearing mail/Teams value appears in render summaries, compare summaries, OperationRun context, audit metadata, or default UI.
- **SC-422-004**: Product Surface close-out confirms existing-surface impact only, Product Surface exceptions `none`, and no broad Exchange/Teams/M365 claims.
## Risks
| Risk | Severity | Mitigation |
|---|---:|---|
| Compare/render is mistaken for certification | High | Claim Guard, Product Surface wording, no certified coverage level. |
| Missing Exchange/Teams source contracts are silently treated as support | High | Evidence-gated requirement and blocker task. |
| Raw payload, mail content, Teams content, or secrets render by default | High | Redaction tests, browser proof, diagnostics demotion. |
| Exchange/Teams-specific mini-platform appears | High | Reuse existing Coverage v2 services; no new tables/dashboard. |
| Remote calls happen during render | High | DB-only requirement and fail-hard tests. |
| Scope leaks across workspace/environment/provider connection | High | Existing authorization plus feature tests. |
| Optional resource types expand scope too far | Medium | Conditional promotion only with evidence and tests; otherwise defer. |
## Assumptions
- Specs 419, 420, and 421 are complete enough to serve as read-only implementation precedent.
- Current Exchange/Teams live capture contracts may be missing; missing capture remains out of scope and is documented as a blocker/deferred result.
- Existing Coverage v2 surface is internal/operator only and read-only.
- Existing Coverage v2 models and coverage-level enum can represent comparable/renderable without schema changes.
- Any implementation report will be created during the later implementation loop, not during preparation.
## Open Questions
None blocking for the narrowed implementation-ready slice. Live Exchange/Teams capture/source-contract availability is an implementation preflight fact; missing evidence is an expected blocker/deferred result, not an open product decision for this pack.
## Follow-Up Spec Candidates
- Exchange/Teams source-contract and content-backed capture enablement.
- Exchange remote domain, organization config, shared mailbox, and mailbox plan comparable/renderable expansion.
- Teams app setup, messaging, update management, and voice route comparable/renderable expansion.
- Security and Compliance readiness/comparable pack.
- Exchange/Teams certified compare pack.
- M365 customer reporting Claim Guard pack.
- Exchange/Teams restore/apply feasibility and safety review.

View File

@ -0,0 +1,146 @@
# Tasks: Spec 422 - Exchange & Teams Comparable / Renderable Pack
**Input**: Design documents from `/specs/422-exchange-teams-comparable-renderable-pack/`
**Prerequisites**: `spec.md`, `plan.md`, `checklists/requirements.md`, completed Specs 414, 415, 417, 418, 419, 420, and 421 as read-only context
**Tests**: Required. Runtime compare/render behavior must be covered with focused Pest unit and feature tests. Browser proof is required if rendered Coverage v2 output changes.
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for typed normalization, compare/render, redaction, claims, scope, and Product Surface behavior.
- [x] New or changed tests stay in the smallest honest family, and any browser coverage is explicit.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default.
- [x] Planned validation commands cover the change without pulling unrelated lane cost.
- [x] Browser proof is explicitly `N/A - no rendered UI surface changed` when no UI output changes, and required when rendered output changes.
- [x] Human Product Sanity and Product Surface implementation-report close-out are planned where applicable.
- [x] Any optional Exchange/Teams type blocker is documented in the active spec or implementation report.
## Phase 1: Preflight And Repo Truth
**Purpose**: Confirm current repo truth before implementation and prevent completed-spec rewrite.
- [x] T001 Capture branch, HEAD, dirty state, activated skills, and hard-gate stop conditions in `specs/422-exchange-teams-comparable-renderable-pack/implementation-report.md`.
- [x] T002 Verify Specs 414, 415, 417, 418, 419, 420, and 421 are completed dependency context only and do not edit any files under their spec directories.
- [x] T003 Inspect `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php`, `CoverageSourceContractResolver.php`, `GenericContentEvidenceCaptureService.php`, `CoverageIdentityStrategyRegistry.php`, `CanonicalIdentityResolver.php`, `ClaimGuard.php`, `CoverageEvidenceWriter.php`, and `CoverageV2ReadinessReadModel.php` to confirm current Coverage v2 service names before editing.
- [x] T004 Build the Exchange/Teams evidence matrix in `specs/422-exchange-teams-comparable-renderable-pack/implementation-report.md` for `transportRule`, `acceptedDomain`, `remoteDomain`, `organizationConfig`, `sharedMailbox`, `mailboxPlan`, `appPermissionPolicy`, `appSetupPolicy`, `meetingPolicy`, `messagingPolicy`, `teamsUpdateManagementPolicy`, and `voiceRoute`, classifying each as content-backed, missing-contract, unsupported, identity-blocked, or deferred.
- [x] T005 Confirm no runtime task needs new capture/source contracts, restore/apply, certification, customer output, new OperationRun type, new route/navigation/action, new table, or `tenant_id`; stop and amend the spec if any is required.
## Phase 2: Tests First - Typed Semantics And Claim Safety
**Purpose**: Lock the business truth before implementation.
- [x] T006 [P] Add transport rule typed normalization tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec422ExchangeTransportRuleNormalizerTest.php`.
- [x] T007 [P] Add accepted domain typed normalization tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec422ExchangeAcceptedDomainNormalizerTest.php`.
- [x] T008 [P] Add Teams app permission policy typed normalization tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec422TeamsAppPermissionPolicyNormalizerTest.php`.
- [x] T009 [P] Add Teams meeting policy typed normalization tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec422TeamsMeetingPolicyNormalizerTest.php`.
- [x] T010 [P] Add deterministic compare tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsComparableDiffTest.php` covering volatile-only no-change, material Exchange changes, material Teams changes, stable ordering, null/empty handling, redacted values, and unsupported fields.
- [x] T011 [P] Add render summary tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsRenderableSummaryTest.php` covering operator-safe summaries and no raw payload dependency.
- [x] T012 [P] Add redaction tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsRedactionTest.php` proving secrets, credentials, tokens, authorization headers, cookies, raw payload, provider response bodies, mail content, Teams chat/file content, recordings, transcripts, unsafe OperationRun diagnostic context, and unsafe audit metadata do not appear in render/compare summaries or default-visible diagnostics.
- [x] T013 [P] Add Claim Guard tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec422ExchangeTeamsClaimGuardTest.php` allowing scoped internal comparable/renderable wording and blocking certified, restore-ready, customer-ready, full, all-resource, and 100 percent Exchange/Teams/M365 claims.
- [x] T014 [P] Add evidence-gated promotion tests in `apps/platform/tests/Feature/TenantConfiguration/Spec422ExchangeTeamsCoverageLevelPromotionTest.php` proving selected types can promote only with content-backed evidence and missing-evidence Exchange/Teams types remain unpromoted.
- [x] T015 [P] Add no-restore/no-certification tests in `apps/platform/tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoRestoreNoCertificationTest.php`.
- [x] T016 [P] Add no-tenant-id/no-mini-platform tests or static guards in `apps/platform/tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoTenantIdTest.php` and `apps/platform/tests/Feature/TenantConfiguration/Spec422ExchangeTeamsNoMiniPlatformTest.php`.
- [x] T017 [P] Add read authorization, provider-scope, and no-remote-render tests in `apps/platform/tests/Feature/TenantConfiguration/Spec422ExchangeTeamsComparableRenderableTest.php` proving non-member/wrong-scope denial, member-missing-capability denial, same-scope provider connection requirements, no Graph/TCM/provider calls during render/compare, and no new per-row query or service loop outside the existing read model for typed summaries.
## Phase 3: Evidence-Gated Promotion Path
**Purpose**: Promote only proven evidence-backed types.
- [x] T018 Update or extend `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php` only if needed to keep selected Exchange/Teams comparable/renderable support internal and claim-safe; do not set restore/certified/customer defaults.
- [x] T019 Update or extend the existing Coverage v2 promotion/read path in `apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php` or repo-equivalent service so comparable/renderable state is derived only from content-backed typed evidence.
- [x] T020 Update or extend `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php` only if needed so eligible content-backed Exchange/Teams evidence rows can receive renderable/comparable coverage level through bounded typed summary support.
- [x] T021 Ensure optional Exchange/Teams types stay unpromoted unless Phase 1 proves content-backed evidence and corresponding tests exist; record blockers in `specs/422-exchange-teams-comparable-renderable-pack/implementation-report.md`.
- [x] T022 Confirm `apps/platform/app/Support/TenantConfiguration/CoverageLevel.php` existing values are reused and no new persisted coverage/status/importance enum is added.
- [x] T023 Confirm `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` is not expanded with new Exchange/Teams capture/source mappings unless this spec is amended first.
## Phase 4: Typed Exchange/Teams Normalization
**Purpose**: Produce deterministic typed payloads for evidence-backed Exchange/Teams types.
- [x] T024 Add or extend a bounded typed normalizer in `apps/platform/app/Services/TenantConfiguration/ExchangeTeamsComparablePayloadNormalizer.php` or the repo-equivalent Tenant Configuration service path.
- [x] T025 [P] Implement `transportRule` normalization for display name, enabled/state, priority/order, condition summary, action summary, exception summary, mode/enforcement state, source version/schema, redacted diagnostics, and unsupported fields in the normalizer path from T024.
- [x] T026 [P] Implement `acceptedDomain` normalization for domain name, domain type, default-domain indicator, state, source version/schema, redacted diagnostics, and unsupported fields in the normalizer path from T024.
- [x] T027 [P] Implement `appPermissionPolicy` normalization for display name, allowed/blocked app summary, policy mode, assignment/target summary if available, source version/schema, redacted diagnostics, and unsupported fields in the normalizer path from T024.
- [x] T028 [P] Implement `meetingPolicy` normalization for display name, external/anonymous access, recording/transcription, lobby/admission, content sharing, source version/schema, redacted diagnostics, and unsupported fields in the normalizer path from T024.
- [x] T029 [P] Implement optional Exchange/Teams normalization only if Phase 1 proves content-backed evidence and the scope remains bounded; otherwise defer them in `specs/422-exchange-teams-comparable-renderable-pack/implementation-report.md`.
- [x] T030 Ensure typed normalization reuses `apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php` or its repo-equivalent redaction path before render/compare output.
## Phase 5: Deterministic Compare
**Purpose**: Compare selected Exchange/Teams evidence without volatile noise or unsafe claims.
- [x] T031 Add or extend a bounded comparator in `apps/platform/app/Services/TenantConfiguration/ExchangeTeamsCoverageComparator.php` or the repo-equivalent Tenant Configuration service path.
- [x] T032 Implement change classification `added`, `removed`, `changed`, `unchanged`, `ignored_volatile`, `redacted`, and `unsupported_field` in the comparator path from T031.
- [x] T033 Implement `transportRule` material change rules for enabled/state, conditions, actions, exceptions, priority/order, and mode/enforcement in the comparator path from T031.
- [x] T034 Implement `acceptedDomain` material change rules for domain added/removed, domain type, default indicator, and state in the comparator path from T031.
- [x] T035 Implement `appPermissionPolicy` material change rules for app allow/block changes, policy mode, and assignment/target summary if available in the comparator path from T031.
- [x] T036 Implement `meetingPolicy` material change rules for external/anonymous meeting access, recording/transcription, lobby/admission, and content sharing in the comparator path from T031.
- [x] T037 Implement derived importance labels `critical`, `important`, and `informational` only inside compare output; do not add a persisted enum/status family.
- [x] T038 Ensure compare ordering is deterministic for arrays where order is not semantically meaningful and null/empty handling is explicit.
- [x] T039 Implement optional type compare rules only when corresponding evidence-backed normalization exists; otherwise leave documented blockers in `specs/422-exchange-teams-comparable-renderable-pack/implementation-report.md`.
## Phase 6: Operator-Safe Render Summaries
**Purpose**: Let operators understand selected Exchange/Teams resources without raw payloads.
- [x] T040 Add or extend a render summary builder in `apps/platform/app/Services/TenantConfiguration/ExchangeTeamsRenderableSummaryBuilder.php` or the repo-equivalent Tenant Configuration service path.
- [x] T041 Implement `transportRule` render summary fields: display name, enabled/state, priority/order, condition/action/exception summaries, mode/enforcement, claim state, identity state, last captured, unsupported fields, and redaction markers.
- [x] T042 Implement `acceptedDomain` render summary fields: domain name, domain type, default-domain indicator, state, claim state, identity state, last captured, unsupported fields, and redaction markers.
- [x] T043 Implement `appPermissionPolicy` render summary fields: display name, allowed/blocked apps summary, policy mode, assignment/target summary if available, claim state, identity state, last captured, unsupported fields, and redaction markers.
- [x] T044 Implement `meetingPolicy` render summary fields: display name, external/anonymous meeting access, recording/transcription, lobby/admission, content sharing, claim state, identity state, last captured, unsupported fields, and redaction markers.
- [x] T045 Ensure render summaries never expose raw payload, raw provider response, tokens, credential values, private keys, certificate material, authorization headers, cookies, mail/message content, Teams chat/file content, recordings, transcripts, or unneeded PII.
- [x] T046 Implement optional type render summaries only when Phase 1 proves content-backed evidence and matching tests exist; otherwise keep blocker/deferred summaries in `specs/422-exchange-teams-comparable-renderable-pack/implementation-report.md`.
## Phase 7: Existing Surface Integration And Product Safety
**Purpose**: Reuse the existing read-only Coverage v2 surface without adding product-surface risk.
- [x] T047 If rendered output changes, update `apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php` to expose typed summaries through existing inspect details while keeping raw/technical evidence demoted.
- [x] T048 If rendered output changes, update existing inspect modal views under `apps/platform/resources/views/filament/modals/tenant-configuration/` only as needed to display typed summaries with native/shared Filament semantics.
- [x] T049 Confirm `apps/platform/app/Filament/Pages/TenantConfiguration/CoverageV2Readiness.php`, `CoverageV2ResourceTypesTable.php`, and `CoverageV2ResourceInstancesTable.php` expose no new action, route, navigation, start/capture, restore, certify, export, report, or customer output.
- [x] T050 Confirm global search posture is unchanged because no Filament Resource is added or changed for global search.
- [x] T051 Confirm no new assets are registered and no `filament:assets` requirement is introduced beyond existing deployment practice.
- [x] T052 Ensure rendered labels do not include `Exchange covered`, `Teams covered`, `certified`, `restore-ready`, `customer-ready`, `full Exchange coverage`, `100% Teams`, or broad M365 readiness wording.
## Phase 8: Browser Proof If Rendered Output Changes
**Purpose**: Prove the existing surface remains safe when summaries render.
- [x] T053 Add `apps/platform/tests/Browser/Spec422ExchangeTeamsComparableRenderableOperatorSurfaceSmokeTest.php` if rendered output changes.
- [x] T054 In the browser smoke, seed a workspace, managed environment, provider connection, and selected content-backed Exchange/Teams evidence rows with comparable/renderable summary data.
- [x] T055 In the browser smoke, load the existing Coverage v2 readiness route, open the inspect flow, and assert comparable/renderable state, operator-readable Exchange/Teams summary, no raw payload, no secrets, no mail/message/chat/file/recording/transcript content, no unsafe OperationRun/audit diagnostic metadata if diagnostics render, no restore/certified/customer-ready claim, no new high-impact action, no provider/network call during render, and no console/Livewire/Filament errors.
- [x] T056 If no rendered output changes, document `N/A - no rendered UI surface changed` proof in `specs/422-exchange-teams-comparable-renderable-pack/implementation-report.md` instead of adding a browser test.
## Phase 9: Validation And Close-Out
**Purpose**: Complete the implementation loop with explicit proof.
- [x] T057 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- [x] T058 Run focused Spec 422 unit tests for normalization, compare, render, redaction, and Claim Guard.
- [x] T059 Run focused Spec 422 feature tests for promotion, RBAC/scope, no restore/certification, no tenant_id, no mini-platform, and no overclaim.
- [x] T060 Run focused Spec 422 browser test if rendered output changed, or record no-browser proof if not.
- [x] T061 Run `git diff --check`.
- [x] T062 Complete `specs/422-exchange-teams-comparable-renderable-pack/implementation-report.md` with candidate gate, dirty state before/after, files changed, Exchange/Teams evidence matrix, promoted/deferred types, normalizer matrix, compare matrix, render matrix, Claim Guard proof, redaction proof including OperationRun diagnostic context and audit metadata posture, no restore/certification proof, no tenant_id proof, no mini-platform proof, Product Surface proof, Human Product Sanity result, visible complexity outcome, tests run, browser/no-browser, deployment impact, and deferred work.
- [x] T063 Confirm no completed historical spec was rewritten, normalized, reopened, or stripped of validation/task/browser/review history.
## Dependencies
- Phase 1 blocks all implementation.
- Phase 2 tests should be written before or alongside Phases 3-7.
- Phase 3 promotion path depends on Phase 1 evidence matrix.
- Phase 4 typed normalization blocks Phases 5 and 6.
- Phase 7 depends on Phases 3-6 only if rendered output changes.
- Phase 8 depends on Phase 7 rendered output changes.
- Phase 9 closes after all relevant implementation and validation tasks.
## Stop Conditions
- New capture/source contract work is needed for selected Exchange/Teams types.
- Restore/apply, certification, customer output, report/download/export, or broad Exchange/Teams/M365 claim is proposed.
- A new route, navigation entry, dashboard, action, OperationRun type, persisted compare table, or Exchange/Teams-specific table family is proposed without amending this spec.
- Raw payloads, secrets, credentials, tokens, provider response bodies, source keys, provider IDs, mail content, Teams chat/file content, recordings, or transcripts become default-visible.
- `tenant_id` appears as Coverage v2 ownership truth.
- Render/compare performs provider/Graph/TCM/HTTP work during page render.
## Implementation Strategy
Deliver the MVP first: typed compare/render support for content-backed `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` evidence rows, plus Claim Guard/redaction/no-overclaim proof. Treat every other Exchange/Teams type and live capture/source-contract work as evidence-gated follow-through, not required scope. Stop and split if the implementation needs new capture contracts or broader product-surface work.