From ff1a545c3175d07a631ff0fc603d79731b3bf4d6 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 9 Jul 2026 20:32:52 +0000 Subject: [PATCH] Spec 437: Exchange comparable promotion slice 1 (#504) ## Summary - add Spec 437 package for Exchange comparable promotion slice 1 - add ExchangePowerShellComparablePayloadBuilder for normalized-payload-only comparable artifacts - add focused Pest unit/feature coverage for comparable safety, determinism, redaction, and no product-surface promotion ## Validation - cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent: PASS - cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec437 --compact: failed with Symfony Process signal 9 - cd apps/platform && php artisan test --filter=Spec437 --compact: PASS, 34 tests / 225 assertions - git diff --check: PASS ## Product Surface / Deployment - UI impact: N/A - no rendered UI surface changed - Browser proof: N/A - no rendered UI surface changed - Destructive/high-impact actions: none - Assets: none; no filament:assets requirement introduced - Deployment impact: no env vars, migrations, queues, scheduler, storage, assets, routes, jobs, listeners, commands, or Dokploy runtime behavior changes Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/504 --- ...angePowerShellComparablePayloadBuilder.php | 674 +++++++++++ ...ExchangeComparablePromotionFeatureTest.php | 273 +++++ ...37ExchangeComparablePayloadBuilderTest.php | 601 ++++++++++ .../checklists/requirements.md | 170 +++ .../implementation-report.md | 273 +++++ .../plan.md | 283 +++++ .../spec.md | 1024 +++++++++++++++++ .../tasks.md | 194 ++++ 8 files changed, 3492 insertions(+) create mode 100644 apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php create mode 100644 apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php create mode 100644 apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php create mode 100644 specs/437-exchange-comparable-promotion-slice-1/checklists/requirements.md create mode 100644 specs/437-exchange-comparable-promotion-slice-1/implementation-report.md create mode 100644 specs/437-exchange-comparable-promotion-slice-1/plan.md create mode 100644 specs/437-exchange-comparable-promotion-slice-1/spec.md create mode 100644 specs/437-exchange-comparable-promotion-slice-1/tasks.md diff --git a/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php new file mode 100644 index 00000000..400d55c1 --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php @@ -0,0 +1,674 @@ + + */ + private const SUPPORTED_RESOURCE_TYPES = [ + 'transportRule', + 'remoteDomain', + 'inboundConnector', + ]; + + /** + * @var list + */ + private const UNSAFE_REMOTE_DOMAIN_IDENTITY_FIELDS = [ + 'domainname', + 'displayname', + 'name', + ]; + + /** + * Fields listed here may keep exact normalized values in target sections + * only when the value matches the expected safe scalar shape. All other + * values in those sections must already be redacted markers. + * + * @var array}>>> + */ + private const SAFE_EXACT_VALUE_POLICIES = [ + 'transportRule' => [ + 'material' => [ + 'enabled' => 'bool', + 'mode' => ['enum' => ['enforce', 'audit', 'auditandnotify']], + 'state' => ['enum' => ['enabled', 'disabled']], + ], + 'rule_order' => [ + 'priority' => 'non_negative_integer', + ], + 'conditions' => [], + 'actions' => [ + 'delete_message' => 'bool', + 'apply_html_disclaimer_fallback_action' => ['enum' => ['wrap', 'ignore', 'reject']], + 'apply_html_disclaimer_location' => ['enum' => ['append', 'prepend']], + ], + 'exceptions' => [], + 'protected_configuration' => [], + 'sensitive_content' => [], + ], + 'remoteDomain' => [ + 'remote_domain_class' => [ + 'is_default' => 'bool', + 'domain_name_is_identity' => 'bool', + ], + 'settings' => [ + 'auto_reply_enabled' => 'bool', + ], + 'protected_configuration' => [], + 'sensitive_content' => [], + ], + 'inboundConnector' => [ + 'material' => [ + 'connector_type' => ['enum' => ['partner', 'onpremises']], + 'enabled' => 'bool', + 'require_tls' => 'bool', + ], + 'routing' => [ + 'connector_type' => ['enum' => ['partner', 'onpremises']], + 'enabled' => 'bool', + 'require_tls' => 'bool', + ], + 'protected_configuration' => [], + 'sensitive_content' => [], + ], + ]; + + /** + * These sections are only read through explicit fields in target payload builders. + * Unknown keys in these sections are ignored by output and therefore do not need + * leakage blocking. + * + * @var array> + */ + private const PARTIAL_EXACT_VALUE_SECTIONS = [ + 'transportRule' => [ + 'material', + 'rule_order', + ], + 'inboundConnector' => [ + 'material', + ], + ]; + + /** + * @param array $normalizedPayload + * @param array $metadata + * @return array{comparable: bool, blocker: string|null, resource_type: string, payload: array|null, comparable_hash?: string} + */ + public function build( + string $resourceType, + array $normalizedPayload, + ?string $payloadHash = null, + array $metadata = [], + ): array { + $resourceType = trim($resourceType); + + if (! in_array($resourceType, self::SUPPORTED_RESOURCE_TYPES, true)) { + return $this->blocked($resourceType, 'unsupported_resource_type'); + } + + if ($normalizedPayload === []) { + return $this->blocked($resourceType, 'missing_normalized_payload'); + } + + if (($normalizedPayload['canonical_type'] ?? null) !== $resourceType) { + return $this->blocked($resourceType, 'normalized_payload_type_mismatch'); + } + + if (($normalizedPayload['payload_shape_version'] ?? null) !== ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION) { + return $this->blocked($resourceType, 'unsupported_normalized_payload_shape'); + } + + $contentBackedBlocker = $this->contentBackedBlocker($payloadHash, $metadata); + + if ($contentBackedBlocker !== null) { + return $this->blocked($resourceType, $contentBackedBlocker); + } + + $identity = $this->sourceIdentity($normalizedPayload); + + if ($identity === null) { + return $this->blocked($resourceType, 'missing_stable_identity'); + } + + if ($resourceType === 'remoteDomain' && in_array($this->canonicalFieldKey($identity['field']), self::UNSAFE_REMOTE_DOMAIN_IDENTITY_FIELDS, true)) { + return $this->blocked($resourceType, 'unsafe_identity'); + } + + if ($this->identityHmacKey() === null) { + return $this->blocked($resourceType, 'missing_identity_hash_key'); + } + + if ($this->firstUnsafeExactValuePath($resourceType, $normalizedPayload) !== null) { + return $this->blocked($resourceType, 'unsafe_exact_value'); + } + + $payload = $this->sortAssociative([ + 'workload' => 'exchange', + 'resource_type' => $resourceType, + 'comparable_payload_shape_version' => self::PAYLOAD_SHAPE_VERSION, + 'compare_input' => 'normalized_payload_only', + 'identity' => $this->comparableIdentity($resourceType, $identity), + ...$this->targetPayload($resourceType, $normalizedPayload), + 'redaction' => $this->redactionSummary($normalizedPayload), + 'source' => $this->sourceMetadata($normalizedPayload), + 'provenance' => $this->provenance($payloadHash, $metadata), + ]); + + return [ + 'comparable' => true, + 'blocker' => null, + 'resource_type' => $resourceType, + 'payload' => $payload, + 'comparable_hash' => $this->hashComparablePayload($payload), + ]; + } + + /** + * @return array{field: string, value: string} + */ + private function sourceIdentity(array $normalizedPayload): ?array + { + $identity = $normalizedPayload['source_identity'] ?? null; + + if (! is_array($identity)) { + return null; + } + + $field = $this->stringValue($identity['field'] ?? null); + $value = $this->stringValue($identity['value'] ?? null); + + if ($field === null || $value === null) { + return null; + } + + return [ + 'field' => $field, + 'value' => $value, + ]; + } + + /** + * @param array{field: string, value: string} $identity + * @return array + */ + private function comparableIdentity(string $resourceType, array $identity): array + { + $valueHmac = $this->identityHmac($resourceType, $identity); + + return [ + 'field' => $identity['field'], + 'value_hmac' => $valueHmac, + 'source_key' => $resourceType.':'.$identity['field'].':'.$valueHmac, + ]; + } + + /** + * @param array $metadata + */ + private function contentBackedBlocker(?string $payloadHash, array $metadata): ?string + { + $payloadHash = $this->stringValue($payloadHash); + + if ($payloadHash === null) { + return 'missing_payload_hash'; + } + + if (! preg_match('/^[a-f0-9]{64}$/', $payloadHash)) { + return 'invalid_payload_hash'; + } + + if ($this->stringValue($metadata['evidence_state'] ?? null) !== EvidenceState::ContentBacked->value) { + return 'evidence_not_content_backed'; + } + + if ($this->stringValue($metadata['coverage_level'] ?? null) !== CoverageLevel::ContentBacked->value) { + return 'coverage_not_content_backed'; + } + + if ($this->stringValue($metadata['capture_outcome'] ?? null) !== CaptureOutcome::Captured->value) { + return 'capture_not_captured'; + } + + return null; + } + + /** + * @param array{field: string, value: string} $identity + */ + private function identityHmac(string $resourceType, array $identity): string + { + return hash_hmac( + 'sha256', + $resourceType."\0".$identity['field']."\0".$identity['value'], + (string) $this->identityHmacKey(), + ); + } + + private function identityHmacKey(): ?string + { + $key = $this->stringValue(config('app.key')); + + if ($key === null) { + return null; + } + + if (str_starts_with($key, 'base64:')) { + $decoded = base64_decode(substr($key, 7), true); + + return $decoded !== false && $decoded !== '' ? $decoded : null; + } + + return $key; + } + + /** + * @param array $normalizedPayload + */ + private function firstUnsafeExactValuePath(string $resourceType, array $normalizedPayload): ?string + { + foreach (self::SAFE_EXACT_VALUE_POLICIES[$resourceType] as $section => $safeFields) { + $sectionValue = $this->arrayValue($normalizedPayload[$section] ?? null); + + if (in_array($section, self::PARTIAL_EXACT_VALUE_SECTIONS[$resourceType] ?? [], true)) { + foreach ($safeFields as $field => $policy) { + $value = $this->sectionValueByCanonicalField($sectionValue, (string) $field); + + if ($value === null || $this->isRedactedMarker($value)) { + continue; + } + + if (! $this->safeExactValue($policy, $value)) { + return $section.'.'.(string) $field; + } + } + + continue; + } + + foreach ($sectionValue as $field => $value) { + if ($value === null || $this->isRedactedMarker($value)) { + continue; + } + + $policy = $this->exactValuePolicy($safeFields, (string) $field); + + if ($policy === null || ! $this->safeExactValue($policy, $value)) { + return $section.'.'.(string) $field; + } + } + } + + return null; + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function targetPayload(string $resourceType, array $normalizedPayload): array + { + return match ($resourceType) { + 'transportRule' => $this->transportRulePayload($normalizedPayload), + 'remoteDomain' => $this->remoteDomainPayload($normalizedPayload), + 'inboundConnector' => $this->inboundConnectorPayload($normalizedPayload), + }; + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function transportRulePayload(array $normalizedPayload): array + { + $material = $this->arrayValue($normalizedPayload['material'] ?? null); + + return [ + 'material' => $this->sortAssociative([ + 'enabled' => $this->sanitizeComparableValue($material['Enabled'] ?? $material['enabled'] ?? null), + 'mode' => $this->sanitizeComparableValue($material['Mode'] ?? $material['mode'] ?? null), + 'state' => $this->sanitizeComparableValue($material['State'] ?? $material['state'] ?? null), + 'priority' => $this->sanitizeComparableValue(data_get($normalizedPayload, 'rule_order.priority')), + ]), + 'conditions' => $this->sanitizeComparableMap($normalizedPayload['conditions'] ?? []), + 'actions' => $this->sanitizeComparableMap($normalizedPayload['actions'] ?? []), + 'exceptions' => $this->sanitizeComparableMap($normalizedPayload['exceptions'] ?? []), + 'protected_values' => $this->sanitizeComparableMap($normalizedPayload['protected_configuration'] ?? []), + 'sensitive_values' => $this->sanitizeComparableMap($normalizedPayload['sensitive_content'] ?? []), + ]; + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function remoteDomainPayload(array $normalizedPayload): array + { + return [ + 'remote_domain_class' => $this->sanitizeComparableMap($normalizedPayload['remote_domain_class'] ?? []), + 'settings' => $this->sanitizeComparableMap($normalizedPayload['settings'] ?? []), + 'protected_values' => $this->sanitizeComparableMap($normalizedPayload['protected_configuration'] ?? []), + 'sensitive_values' => $this->sanitizeComparableMap($normalizedPayload['sensitive_content'] ?? []), + ]; + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function inboundConnectorPayload(array $normalizedPayload): array + { + $material = $this->arrayValue($normalizedPayload['material'] ?? null); + + return [ + 'material' => $this->sortAssociative([ + 'connector_type' => $this->sanitizeComparableValue($material['ConnectorType'] ?? $material['connector_type'] ?? null), + 'enabled' => $this->sanitizeComparableValue($material['Enabled'] ?? $material['enabled'] ?? null), + 'require_tls' => $this->sanitizeComparableValue($material['RequireTls'] ?? $material['require_tls'] ?? null), + ]), + 'routing' => $this->sanitizeComparableMap($normalizedPayload['routing'] ?? []), + 'protected_values' => $this->sanitizeComparableMap($normalizedPayload['protected_configuration'] ?? []), + 'sensitive_values' => $this->sanitizeComparableMap($normalizedPayload['sensitive_content'] ?? []), + ]; + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function redactionSummary(array $normalizedPayload): array + { + return $this->sortAssociative([ + 'protected_fields' => $this->stringList(data_get($normalizedPayload, 'diagnostics.protected_fields', [])), + 'sensitive_fields' => $this->stringList(data_get($normalizedPayload, 'diagnostics.sensitive_fields', [])), + 'volatile_fields_ignored' => $this->stringList(data_get($normalizedPayload, 'diagnostics.volatile_fields', [])), + 'protected_values_use_safe_markers' => true, + ]); + } + + /** + * @param array $normalizedPayload + * @return array + */ + private function sourceMetadata(array $normalizedPayload): array + { + $source = $this->arrayValue($normalizedPayload['source'] ?? null); + + return $this->sortAssociative([ + 'source_surface' => $this->stringValue($source['source_surface'] ?? $normalizedPayload['source_surface'] ?? null), + 'source_contract_key' => $this->stringValue($source['source_contract_key'] ?? null), + 'command_contract_name' => $this->stringValue($source['command_contract_name'] ?? null), + 'command_contract_version' => $this->stringValue($source['command_contract_version'] ?? null), + 'payload_shape_version' => $this->stringValue($source['payload_shape_version'] ?? $normalizedPayload['payload_shape_version'] ?? null), + 'normalizer_version' => $this->stringValue($source['normalizer_version'] ?? $normalizedPayload['normalizer_version'] ?? null), + ]); + } + + /** + * @param array $metadata + * @return array + */ + private function provenance(?string $payloadHash, array $metadata): array + { + return $this->sortAssociative([ + 'payload_hash' => $this->stringValue($payloadHash), + 'source_contract_key' => $this->stringValue($metadata['source_contract_key'] ?? null), + 'source_endpoint' => $this->stringValue($metadata['source_endpoint'] ?? null), + 'source_version' => $this->stringValue($metadata['source_version'] ?? null), + 'source_schema_hash' => $this->stringValue($metadata['source_schema_hash'] ?? null), + 'evidence_state' => $this->stringValue($metadata['evidence_state'] ?? null), + 'coverage_level' => $this->stringValue($metadata['coverage_level'] ?? null), + 'capture_outcome' => $this->stringValue($metadata['capture_outcome'] ?? null), + ]); + } + + /** + * @param array $payload + */ + private function hashComparablePayload(array $payload): string + { + unset($payload['provenance']); + + return hash('sha256', json_encode($this->sortAssociative($payload), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); + } + + private function sanitizeComparableValue(mixed $value): mixed + { + if ($this->isRedactedMarker($value)) { + $present = (bool) ($value['present'] ?? false); + $count = is_numeric($value['count'] ?? null) ? (int) $value['count'] : ($present ? 1 : 0); + + return [ + 'redacted' => true, + 'present' => $present, + 'count' => max(0, $count), + 'state' => $present ? ($count > 1 ? 'present_multiple' : 'present') : 'absent', + ]; + } + + if (is_bool($value) || is_int($value) || is_float($value)) { + return $value; + } + + if (is_string($value)) { + $value = trim($value); + + return $value !== '' ? $value : null; + } + + if (! is_array($value)) { + return null; + } + + if (array_is_list($value)) { + $items = array_values(array_filter( + array_map(fn (mixed $item): mixed => $this->sanitizeComparableValue($item), $value), + static fn (mixed $item): bool => $item !== null && $item !== '', + )); + + usort($items, static fn (mixed $left, mixed $right): int => strcmp( + json_encode($left, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + json_encode($right, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), + )); + + return $items; + } + + return $this->sanitizeComparableMap($value); + } + + /** + * @param mixed $value + * @return array + */ + private function sanitizeComparableMap(mixed $value): array + { + $value = $this->arrayValue($value); + $sanitized = []; + + foreach ($value as $key => $item) { + $sanitized[(string) $key] = $this->sanitizeComparableValue($item); + } + + return $this->sortAssociative($sanitized); + } + + private function isRedactedMarker(mixed $value): bool + { + if (! is_array($value) || array_is_list($value)) { + return false; + } + + return ($value['value'] ?? null) === '[redacted]' || ($value['redacted'] ?? null) === true; + } + + /** + * @param array $payload + * @return array + */ + private function sortAssociative(array $payload): array + { + $payload = array_filter($payload, static fn (mixed $value): bool => $value !== null); + + ksort($payload, SORT_NATURAL | SORT_FLAG_CASE); + + foreach ($payload as $key => $value) { + if (is_array($value) && ! array_is_list($value)) { + $payload[$key] = $this->sortAssociative($value); + } + } + + return $payload; + } + + /** + * @return array + */ + private function arrayValue(mixed $value): array + { + if (! is_array($value) || array_is_list($value)) { + return []; + } + + return $value; + } + + private function stringValue(mixed $value): ?string + { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if (! is_scalar($value)) { + return null; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + $value = trim((string) $value); + + return $value !== '' ? $value : null; + } + + /** + * @param array $payload + */ + private function sectionValueByCanonicalField(array $payload, string $field): mixed + { + $field = $this->canonicalFieldKey($field); + + foreach ($payload as $candidateField => $value) { + if ($this->canonicalFieldKey((string) $candidateField) === $field) { + return $value; + } + } + + return null; + } + + /** + * @param array}> $policies + * @return string|array{enum: list}|null + */ + private function exactValuePolicy(array $policies, string $field): string|array|null + { + $field = $this->canonicalFieldKey($field); + + foreach ($policies as $policyField => $policy) { + if ($this->canonicalFieldKey((string) $policyField) === $field) { + return $policy; + } + } + + return null; + } + + /** + * @param string|array{enum: list} $policy + */ + private function safeExactValue(string|array $policy, mixed $value): bool + { + if ($policy === 'bool') { + return is_bool($value); + } + + if ($policy === 'non_negative_integer') { + if (is_int($value)) { + return $value >= 0; + } + + if (! is_string($value)) { + return false; + } + + $value = trim($value); + + return $value !== '' && ctype_digit($value); + } + + if (is_array($policy) && array_key_exists('enum', $policy)) { + $value = $this->stringValue($value); + + if ($value === null) { + return false; + } + + $allowed = array_map(fn (string $item): string => $this->canonicalFieldKey($item), $policy['enum']); + + return in_array($this->canonicalFieldKey($value), $allowed, true); + } + + return false; + } + + private function canonicalFieldKey(string $field): string + { + return strtolower(preg_replace('/[^A-Za-z0-9]+/', '', $field) ?? ''); + } + + /** + * @return list + */ + private function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $values = array_values(array_filter( + array_map(static fn (mixed $item): string => is_string($item) ? trim($item) : '', $value), + static fn (string $item): bool => $item !== '', + )); + + sort($values, SORT_NATURAL | SORT_FLAG_CASE); + + return $values; + } + + /** + * @return array{comparable: false, blocker: string, resource_type: string, payload: null} + */ + private function blocked(string $resourceType, string $blocker): array + { + return [ + 'comparable' => false, + 'blocker' => $blocker, + 'resource_type' => $resourceType, + 'payload' => null, + ]; + } +} diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php new file mode 100644 index 00000000..83d9c321 --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php @@ -0,0 +1,273 @@ +syncDefaults(); +}); + +it('Spec437 derives comparable payloads without mutating evidence resource resource-type or OperationRun truth', function (): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + ]); + $resourceType = spec437FeatureResourceType('transportRule'); + $operationRun = 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' => ['transportRule'], + ], + ]); + $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' => 'transportRule', + 'latest_evidence_state' => EvidenceState::ContentBacked->value, + 'latest_identity_state' => IdentityState::Stable->value, + 'latest_claim_state' => ClaimState::InternalOnly->value, + 'latest_payload_hash' => spec437FeaturePayloadHash(), + ]); + $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) $operationRun->getKey(), + 'source_contract_key' => 'exchange_powershell.transportRule', + 'source_endpoint' => 'exchange_online_powershell_rest:Get-TransportRule', + 'source_metadata' => ['spec' => 437, 'source_identity' => 'must-not-override'], + 'raw_payload' => ['Guid' => 'spec437-rule-guid-1', 'SubjectContainsWords' => ['secret-subject']], + 'normalized_payload' => spec437FeatureTransportRuleNormalizedPayload(), + 'payload_hash' => spec437FeaturePayloadHash(), + 'permission_context' => ['provider_connection_id' => (int) $connection->getKey()], + 'evidence_state' => EvidenceState::ContentBacked->value, + 'coverage_level' => CoverageLevel::ContentBacked->value, + 'capture_outcome' => CaptureOutcome::Captured->value, + ]); + $resource->forceFill([ + 'latest_evidence_id' => (int) $evidence->getKey(), + 'latest_payload_hash' => spec437FeaturePayloadHash(), + ])->save(); + + $beforeEvidence = $evidence->fresh(); + $beforeResource = $resource->fresh(); + $beforeResourceType = $resourceType->fresh(); + $beforeOperationRun = $operationRun->fresh(); + + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + resourceType: 'transportRule', + normalizedPayload: $beforeEvidence->normalized_payload, + payloadHash: $beforeEvidence->payload_hash, + metadata: [ + 'source_contract_key' => $beforeEvidence->source_contract_key, + 'source_endpoint' => $beforeEvidence->source_endpoint, + 'evidence_state' => $beforeEvidence->evidence_state->value, + 'coverage_level' => $beforeEvidence->coverage_level->value, + 'capture_outcome' => $beforeEvidence->capture_outcome->value, + 'source_identity' => ['field' => 'metadata', 'value' => 'unsafe-override'], + ], + ); + + $afterEvidence = $evidence->fresh(); + $afterResource = $resource->fresh(); + $afterResourceType = $resourceType->fresh(); + $afterOperationRun = $operationRun->fresh(); + + expect($result['comparable'])->toBeTrue() + ->and($result['payload']['provenance']['coverage_level'])->toBe(CoverageLevel::ContentBacked->value) + ->and($result['payload']['identity']['field'])->toBe('Guid') + ->and($afterEvidence->raw_payload)->toBe($beforeEvidence->raw_payload) + ->and($afterEvidence->normalized_payload)->toBe($beforeEvidence->normalized_payload) + ->and($afterEvidence->payload_hash)->toBe($beforeEvidence->payload_hash) + ->and($afterEvidence->operation_run_id)->toBe($beforeEvidence->operation_run_id) + ->and($afterEvidence->source_metadata)->toBe($beforeEvidence->source_metadata) + ->and($afterEvidence->coverage_level)->toBe(CoverageLevel::ContentBacked) + ->and($afterResource->latest_evidence_id)->toBe($beforeResource->latest_evidence_id) + ->and($afterResource->latest_evidence_state)->toBe($beforeResource->latest_evidence_state) + ->and($afterResource->latest_claim_state)->toBe($beforeResource->latest_claim_state) + ->and($afterResource->latest_payload_hash)->toBe($beforeResource->latest_payload_hash) + ->and($afterResourceType->default_coverage_level)->toBe($beforeResourceType->default_coverage_level) + ->and($afterOperationRun->type)->toBe($beforeOperationRun->type) + ->and($afterOperationRun->context)->toBe($beforeOperationRun->context); +}); + +it('Spec437 returns no comparable artifact for missing unsafe or unsupported evidence input', function (): void { + $builder = app(ExchangePowerShellComparablePayloadBuilder::class); + $missing = $builder->build('transportRule', []); + $unsafe = $builder->build('remoteDomain', array_replace_recursive(spec437FeatureRemoteDomainNormalizedPayload(), [ + 'source_identity' => ['field' => 'DomainName', 'value' => 'contoso.example'], + ]), payloadHash: spec437FeaturePayloadHash(), metadata: spec437FeatureEvidenceMetadata()); + $unsupported = $builder->build('outboundConnector', spec437FeatureRemoteDomainNormalizedPayload()); + + expect($missing['comparable'])->toBeFalse() + ->and($missing['payload'])->toBeNull() + ->and($unsafe['comparable'])->toBeFalse() + ->and($unsafe['payload'])->toBeNull() + ->and($unsupported['comparable'])->toBeFalse() + ->and($unsupported['payload'])->toBeNull() + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec437 introduces no product surface trigger persisted compare table tenant-id ownership or OperationRun type', function (): void { + $builderSource = file_get_contents(app_path('Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php')) ?: ''; + $operationTypes = OperationRunType::values(); + + expect($builderSource) + ->not->toContain('GenericPayloadNormalizer') + ->not->toContain('ExchangeTeamsComparablePayloadNormalizer') + ->not->toContain('CoverageEvidenceWriter') + ->not->toContain('TenantConfigurationResourceEvidence::') + ->and(Schema::hasColumn('tenant_configuration_resources', 'tenant_id'))->toBeFalse() + ->and(Schema::hasColumn('tenant_configuration_resource_evidence', 'tenant_id'))->toBeFalse() + ->and(glob(app_path('Filament/**/*ExchangePowerShellComparable*')) ?: [])->toBe([]) + ->and(glob(app_path('Livewire/**/*ExchangePowerShellComparable*')) ?: [])->toBe([]) + ->and(glob(base_path('routes/*exchange*comparable*')) ?: [])->toBe([]) + ->and(glob(resource_path('views/**/*ExchangePowerShellComparable*')) ?: [])->toBe([]) + ->and(glob(database_path('migrations/*exchange*comparable*')) ?: [])->toBe([]) + ->and(glob(database_path('migrations/*tenant*configuration*compare*')) ?: [])->toBe([]) + ->and(File::exists(app_path('Jobs/TenantConfiguration/BuildExchangePowerShellComparablePayloadJob.php')))->toBeFalse() + ->and(File::exists(app_path('Console/Commands/BuildExchangePowerShellComparablePayload.php')))->toBeFalse() + ->and($operationTypes) + ->not->toContain('tenant_configuration.exchange_comparable') + ->not->toContain('tenant_configuration.exchange_powershell_compare') + ->not->toContain('tenant_configuration.compare'); +}); + +function spec437FeatureResourceType(string $canonicalType): TenantConfigurationResourceType +{ + return TenantConfigurationResourceType::query() + ->where('canonical_type', $canonicalType) + ->firstOrFail(); +} + +function spec437FeatureTransportRuleNormalizedPayload(): array +{ + return [ + 'canonical_type' => 'transportRule', + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-transport-rule-readiness-v1', + 'source_identity' => ['field' => 'Guid', 'value' => 'spec437-rule-guid-1'], + 'material' => [ + 'Enabled' => true, + 'Mode' => 'Enforce', + 'State' => 'Enabled', + ], + 'rule_order' => ['priority' => '0'], + 'conditions' => [ + 'sender_domain_is' => spec437FeatureRedactedMarker(count: 1), + 'subject_contains_words' => spec437FeatureRedactedMarker(count: 1), + ], + 'actions' => [ + 'delete_message' => true, + 'set_header_value' => spec437FeatureRedactedMarker(count: 1), + ], + 'exceptions' => [], + 'protected_configuration' => [ + 'sender_domain_is' => spec437FeatureRedactedMarker(count: 1), + ], + 'sensitive_content' => [ + 'subject_contains_words' => spec437FeatureRedactedMarker(count: 1), + ], + 'diagnostics' => [ + 'volatile_fields' => ['WhenChanged', 'ExchangeVersion', 'RunspaceId'], + 'protected_fields' => ['sender_domain_is'], + 'sensitive_fields' => ['subject_contains_words'], + ], + 'source' => [ + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'source_contract_key' => 'exchange_powershell.transportRule', + 'command_contract_name' => 'Get-TransportRule', + 'command_contract_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-transport-rule-readiness-v1', + ], + ]; +} + +function spec437FeatureRemoteDomainNormalizedPayload(): array +{ + return [ + 'canonical_type' => 'remoteDomain', + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-remote-domain-readiness-v1', + 'source_identity' => ['field' => 'Identity', 'value' => 'spec437-remote-domain-1'], + 'material' => ['IsDefault' => false], + 'remote_domain_class' => [ + 'domain_name_is_identity' => false, + 'is_default' => false, + ], + 'settings' => [ + 'auto_reply_enabled' => true, + 'auto_forward_enabled' => spec437FeatureRedactedMarker(count: 1), + ], + 'protected_configuration' => [ + 'domain_name' => spec437FeatureRedactedMarker(count: 1), + ], + 'sensitive_content' => [], + 'source' => [ + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'source_contract_key' => 'exchange_powershell.remoteDomain', + 'command_contract_name' => 'Get-RemoteDomain', + 'command_contract_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-remote-domain-readiness-v1', + ], + ]; +} + +function spec437FeatureRedactedMarker(int $count = 1, bool $present = true): array +{ + return [ + 'value' => '[redacted]', + 'present' => $present, + 'count' => $count, + ]; +} + +function spec437FeatureEvidenceMetadata(): array +{ + return [ + 'evidence_state' => EvidenceState::ContentBacked->value, + 'coverage_level' => CoverageLevel::ContentBacked->value, + 'capture_outcome' => CaptureOutcome::Captured->value, + ]; +} + +function spec437FeaturePayloadHash(): string +{ + return str_repeat('b', 64); +} diff --git a/apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php new file mode 100644 index 00000000..d16bab62 --- /dev/null +++ b/apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php @@ -0,0 +1,601 @@ + 'spec437-raw-provider-secret']; + $normalizedPayload['raw_stdout'] = 'spec437-raw-stdout-secret'; + $normalizedPayload['raw_stderr'] = 'spec437-raw-stderr-secret'; + $normalizedPayload['transcript'] = 'spec437-transcript-secret'; + $normalizedPayload['operation_run_context'] = ['token' => 'spec437-run-context-token']; + + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + resourceType: $canonicalType, + normalizedPayload: $normalizedPayload, + payloadHash: spec437UnitPayloadHash(), + metadata: [ + ...spec437UnitEvidenceMetadata(), + 'source_identity' => ['field' => 'metadata', 'value' => 'spec437-metadata-override'], + 'source_endpoint' => 'exchange_online_powershell_rest:'.$canonicalType, + ], + ); + + expect($result['comparable'])->toBeTrue() + ->and($result['blocker'])->toBeNull() + ->and($result['payload']['workload'])->toBe('exchange') + ->and($result['payload']['resource_type'])->toBe($canonicalType) + ->and($result['payload']['compare_input'])->toBe('normalized_payload_only') + ->and($result['payload']['identity']['source_key'])->toBe( + $canonicalType.':'.data_get($normalizedPayload, 'source_identity.field').':'.spec437UnitIdentityHmac( + $canonicalType, + (string) data_get($normalizedPayload, 'source_identity.field'), + (string) data_get($normalizedPayload, 'source_identity.value'), + ), + ) + ->and($result['payload']['identity']['value_hmac'])->toBe(spec437UnitIdentityHmac( + $canonicalType, + (string) data_get($normalizedPayload, 'source_identity.field'), + (string) data_get($normalizedPayload, 'source_identity.value'), + )) + ->and($result['payload']['provenance']['payload_hash'])->toBe(spec437UnitPayloadHash()) + ->and($result['payload']['source']['source_surface'])->toBe(ExchangePowerShellCommandContracts::SOURCE_SURFACE) + ->and($result['payload']['source']['payload_shape_version'])->toBe(ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION); + + foreach ($expectedPaths as $path => $value) { + expect(data_get($result['payload'], $path))->toBe($value); + } + + $encoded = json_encode($result, JSON_THROW_ON_ERROR); + + foreach ($forbiddenValues as $forbiddenValue) { + expect($encoded)->not->toContain($forbiddenValue); + } + + expect($encoded) + ->not->toContain('spec437-raw-provider-secret') + ->not->toContain('spec437-raw-stdout-secret') + ->not->toContain('spec437-raw-stderr-secret') + ->not->toContain('spec437-transcript-secret') + ->not->toContain('spec437-run-context-token') + ->not->toContain('spec437-metadata-override') + ->not->toContain((string) data_get($normalizedPayload, 'source_identity.value')); +})->with([ + 'transportRule' => [ + 'transportRule', + spec437UnitTransportRuleNormalizedPayload(), + [ + 'material.enabled' => true, + 'material.mode' => 'Enforce', + 'material.state' => 'Enabled', + 'material.priority' => '0', + 'conditions.sender_domain_is.redacted' => true, + 'conditions.sender_domain_is.count' => 2, + 'actions.delete_message' => true, + 'actions.set_header_value.redacted' => true, + 'exceptions.except_if_from.redacted' => true, + ], + [ + 'contoso.example', + 'fabrikam.example', + 'subject-secret', + 'header-secret', + 'except-secret@example.com', + 'spec437-rule-guid-1', + ], + ], + 'remoteDomain' => [ + 'remoteDomain', + spec437UnitRemoteDomainNormalizedPayload(), + [ + 'remote_domain_class.is_default' => false, + 'settings.auto_reply_enabled' => true, + 'settings.auto_forward_enabled.redacted' => true, + 'settings.mail_tips_access_scope.redacted' => true, + 'protected_values.domain_name.redacted' => true, + ], + [ + 'contoso.example', + 'mailtips-secret', + 'spec437-remote-domain-1', + ], + ], + 'inboundConnector' => [ + 'inboundConnector', + spec437UnitInboundConnectorNormalizedPayload(), + [ + 'material.connector_type' => 'Partner', + 'material.enabled' => true, + 'material.require_tls' => true, + 'routing.sender_ip_addresses.redacted' => true, + 'routing.sender_ip_addresses.count' => 1, + 'routing.smart_hosts.redacted' => true, + 'routing.tls_sender_certificate_name.redacted' => true, + 'routing.comment.redacted' => true, + ], + [ + '203.0.113.10', + 'mail.contoso.example', + 'CN=secret-cert', + 'sensitive routing note', + 'spec437-inbound-connector-1', + ], + ], +]); + +it('Spec437 blocks unsupported targets missing normalized identity and unsafe remote-domain identity', function ( + string $resourceType, + array $normalizedPayload, + string $expectedBlocker, +): void { + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + $resourceType, + $normalizedPayload, + payloadHash: spec437UnitPayloadHash(), + metadata: spec437UnitEvidenceMetadata(), + ); + + expect($result['comparable'])->toBeFalse() + ->and($result['blocker'])->toBe($expectedBlocker) + ->and($result['payload'])->toBeNull(); +})->with([ + 'unsupported target' => ['acceptedDomain', spec437UnitTransportRuleNormalizedPayload(), 'unsupported_resource_type'], + 'missing normalized payload' => ['transportRule', [], 'missing_normalized_payload'], + 'wrong canonical type' => ['transportRule', spec437UnitRemoteDomainNormalizedPayload(), 'normalized_payload_type_mismatch'], + 'missing source identity' => ['transportRule', array_diff_key(spec437UnitTransportRuleNormalizedPayload(), ['source_identity' => true]), 'missing_stable_identity'], + 'remote domain domain-only identity' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'source_identity' => ['field' => 'DomainName', 'value' => 'contoso.example'], + ]), + 'unsafe_identity', + ], + 'remote domain display-only identity' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'source_identity' => ['field' => 'DisplayName', 'value' => 'Contoso remote domain'], + ]), + 'unsafe_identity', + ], + 'remote domain snake-case domain identity' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'source_identity' => ['field' => 'domain_name', 'value' => 'contoso.example'], + ]), + 'unsafe_identity', + ], + 'remote domain uppercase display identity' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'source_identity' => ['field' => 'DISPLAY_NAME', 'value' => 'Contoso remote domain'], + ]), + 'unsafe_identity', + ], +]); + +it('Spec437 blocks non content-backed or uncaptured evidence provenance', function ( + ?string $payloadHash, + array $metadata, + string $expectedBlocker, +): void { + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + 'transportRule', + spec437UnitTransportRuleNormalizedPayload(), + payloadHash: $payloadHash, + metadata: $metadata, + ); + + expect($result['comparable'])->toBeFalse() + ->and($result['blocker'])->toBe($expectedBlocker) + ->and($result['payload'])->toBeNull(); +})->with([ + 'missing payload hash' => [null, spec437UnitEvidenceMetadata(), 'missing_payload_hash'], + 'invalid payload hash' => ['spec437-source-payload-hash', spec437UnitEvidenceMetadata(), 'invalid_payload_hash'], + 'wrong evidence state' => [ + spec437UnitPayloadHash(), + [...spec437UnitEvidenceMetadata(), 'evidence_state' => EvidenceState::NotCaptured->value], + 'evidence_not_content_backed', + ], + 'wrong coverage level' => [ + spec437UnitPayloadHash(), + [...spec437UnitEvidenceMetadata(), 'coverage_level' => CoverageLevel::Detected->value], + 'coverage_not_content_backed', + ], + 'wrong capture outcome' => [ + spec437UnitPayloadHash(), + [...spec437UnitEvidenceMetadata(), 'capture_outcome' => CaptureOutcome::BlockedUnsupported->value], + 'capture_not_captured', + ], +]); + +it('Spec437 blocks unsafe exact values in protected comparable sections', function ( + string $resourceType, + array $normalizedPayload, + string $forbiddenValue, +): void { + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + $resourceType, + $normalizedPayload, + payloadHash: spec437UnitPayloadHash(), + metadata: spec437UnitEvidenceMetadata(), + ); + $encoded = json_encode($result, JSON_THROW_ON_ERROR); + + expect($result['comparable'])->toBeFalse() + ->and($result['blocker'])->toBe('unsafe_exact_value') + ->and($result['payload'])->toBeNull() + ->and($encoded)->not->toContain($forbiddenValue); +})->with([ + 'transportRule raw condition' => [ + 'transportRule', + array_replace_recursive(spec437UnitTransportRuleNormalizedPayload(), [ + 'conditions' => ['sender_domain_is' => 'contoso.example'], + ]), + 'contoso.example', + ], + 'transportRule raw action' => [ + 'transportRule', + array_replace_recursive(spec437UnitTransportRuleNormalizedPayload(), [ + 'actions' => ['set_header_value' => 'header-secret'], + ]), + 'header-secret', + ], + 'remoteDomain raw setting' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'settings' => ['mail_tips_access_scope' => 'mailtips-secret'], + ]), + 'mailtips-secret', + ], + 'remoteDomain raw protected value' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'protected_configuration' => ['domain_name' => 'contoso.example'], + ]), + 'contoso.example', + ], + 'inboundConnector raw routing' => [ + 'inboundConnector', + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'routing' => ['sender_ip_addresses' => '203.0.113.10'], + ]), + '203.0.113.10', + ], + 'inboundConnector raw protected value' => [ + 'inboundConnector', + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'protected_configuration' => ['sender_ip_addresses' => '203.0.113.10'], + ]), + '203.0.113.10', + ], +]); + +it('Spec437 blocks malformed exact values on allowlisted comparable fields', function ( + string $resourceType, + array $normalizedPayload, + string $forbiddenValue, +): void { + $result = app(ExchangePowerShellComparablePayloadBuilder::class)->build( + $resourceType, + $normalizedPayload, + payloadHash: spec437UnitPayloadHash(), + metadata: spec437UnitEvidenceMetadata(), + ); + $encoded = json_encode($result, JSON_THROW_ON_ERROR); + + expect($result['comparable'])->toBeFalse() + ->and($result['blocker'])->toBe('unsafe_exact_value') + ->and($result['payload'])->toBeNull() + ->and($encoded)->not->toContain($forbiddenValue); +})->with([ + 'transportRule malformed bool action' => [ + 'transportRule', + array_replace_recursive(spec437UnitTransportRuleNormalizedPayload(), [ + 'actions' => ['delete_message' => 'header-secret'], + ]), + 'header-secret', + ], + 'transportRule malformed enum action' => [ + 'transportRule', + array_replace_recursive(spec437UnitTransportRuleNormalizedPayload(), [ + 'actions' => ['apply_html_disclaimer_fallback_action' => 'secret-fallback'], + ]), + 'secret-fallback', + ], + 'transportRule malformed priority' => [ + 'transportRule', + array_replace_recursive(spec437UnitTransportRuleNormalizedPayload(), [ + 'rule_order' => ['priority' => 'priority-secret'], + ]), + 'priority-secret', + ], + 'remoteDomain malformed bool setting' => [ + 'remoteDomain', + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'settings' => ['auto_reply_enabled' => 'mailtips-secret'], + ]), + 'mailtips-secret', + ], + 'inboundConnector malformed enum routing' => [ + 'inboundConnector', + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'routing' => ['connector_type' => 'mail.contoso.example'], + ]), + 'mail.contoso.example', + ], + 'inboundConnector malformed bool material' => [ + 'inboundConnector', + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'material' => ['RequireTls' => 'tls-secret'], + ]), + 'tls-secret', + ], +]); + +it('Spec437 comparable hash changes only for material or safe redacted-state changes', function (): void { + $builder = app(ExchangePowerShellComparablePayloadBuilder::class); + $base = spec437UnitTransportRuleNormalizedPayload(); + $volatileOnly = array_replace_recursive($base, [ + 'diagnostics' => [ + 'volatile_fields' => ['RunspaceId', 'WhenChanged', 'ExchangeVersion'], + ], + 'runtime_context' => [ + 'WhenChanged' => '2026-07-09T10:00:00Z', + ], + ]); + $materialChanged = array_replace_recursive($base, [ + 'material' => [ + 'Enabled' => false, + ], + ]); + $redactedExactChangedSameCount = array_replace_recursive($base, [ + 'conditions' => [ + 'sender_domain_is' => spec437UnitRedactedMarker(count: 2), + ], + ]); + $redactedCountChanged = array_replace_recursive($base, [ + 'conditions' => [ + 'sender_domain_is' => spec437UnitRedactedMarker(count: 3), + ], + ]); + + $baseResult = spec437UnitBuild($builder, 'transportRule', $base); + + expect($baseResult['comparable_hash'])->toBe(spec437UnitBuild($builder, 'transportRule', $volatileOnly)['comparable_hash']) + ->and($baseResult['comparable_hash'])->not->toBe(spec437UnitBuild($builder, 'transportRule', $materialChanged)['comparable_hash']) + ->and($baseResult['comparable_hash'])->toBe(spec437UnitBuild($builder, 'transportRule', $redactedExactChangedSameCount)['comparable_hash']) + ->and($baseResult['comparable_hash'])->not->toBe(spec437UnitBuild($builder, 'transportRule', $redactedCountChanged)['comparable_hash']); +}); + +it('Spec437 comparable hash behavior is deterministic for remoteDomain and inboundConnector targets', function ( + string $resourceType, + array $base, + array $volatileOnly, + array $materialChanged, + array $redactedCountChanged, +): void { + $builder = app(ExchangePowerShellComparablePayloadBuilder::class); + $baseResult = spec437UnitBuild($builder, $resourceType, $base); + + expect($baseResult['comparable_hash'])->toBe(spec437UnitBuild($builder, $resourceType, $volatileOnly)['comparable_hash']) + ->and($baseResult['comparable_hash'])->not->toBe(spec437UnitBuild($builder, $resourceType, $materialChanged)['comparable_hash']) + ->and($baseResult['comparable_hash'])->not->toBe(spec437UnitBuild($builder, $resourceType, $redactedCountChanged)['comparable_hash']); +})->with([ + 'remoteDomain' => [ + 'remoteDomain', + spec437UnitRemoteDomainNormalizedPayload(), + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'diagnostics' => ['volatile_fields' => ['RunspaceId', 'WhenChanged', 'ExchangeVersion']], + 'runtime_context' => ['WhenChanged' => '2026-07-09T10:00:00Z'], + ]), + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'settings' => ['auto_reply_enabled' => false], + ]), + array_replace_recursive(spec437UnitRemoteDomainNormalizedPayload(), [ + 'protected_configuration' => ['domain_name' => spec437UnitRedactedMarker(count: 2)], + ]), + ], + 'inboundConnector' => [ + 'inboundConnector', + spec437UnitInboundConnectorNormalizedPayload(), + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'diagnostics' => ['volatile_fields' => ['RunspaceId', 'WhenChanged', 'ExchangeVersion']], + 'runtime_context' => ['WhenChanged' => '2026-07-09T10:00:00Z'], + ]), + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'material' => ['RequireTls' => false], + 'routing' => ['require_tls' => false], + ]), + array_replace_recursive(spec437UnitInboundConnectorNormalizedPayload(), [ + 'routing' => ['sender_ip_addresses' => spec437UnitRedactedMarker(count: 2)], + 'protected_configuration' => ['sender_ip_addresses' => spec437UnitRedactedMarker(count: 2)], + ]), + ], +]); + +function spec437UnitTransportRuleNormalizedPayload(): array +{ + return [ + 'canonical_type' => 'transportRule', + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-transport-rule-readiness-v1', + 'source_identity' => ['field' => 'Guid', 'value' => 'spec437-rule-guid-1'], + 'material' => [ + 'DisplayName' => 'Spec 437 transport rule', + 'Enabled' => true, + 'Mode' => 'Enforce', + 'State' => 'Enabled', + ], + 'rule_order' => [ + 'priority' => '0', + 'order_is_identity' => false, + ], + 'conditions' => [ + 'sender_domain_is' => spec437UnitRedactedMarker(count: 2), + 'subject_contains_words' => spec437UnitRedactedMarker(count: 1), + ], + 'actions' => [ + 'delete_message' => true, + 'set_header_value' => spec437UnitRedactedMarker(count: 1), + ], + 'exceptions' => [ + 'except_if_from' => spec437UnitRedactedMarker(count: 1), + ], + 'protected_configuration' => [ + 'sender_domain_is' => spec437UnitRedactedMarker(count: 2), + ], + 'sensitive_content' => [ + 'subject_contains_words' => spec437UnitRedactedMarker(count: 1), + ], + 'diagnostics' => [ + 'volatile_fields' => ['WhenChanged', 'ExchangeVersion', 'RunspaceId'], + 'protected_fields' => ['sender_domain_is'], + 'sensitive_fields' => ['subject_contains_words'], + ], + 'source' => spec437UnitSourceMetadata('transportRule', 'Get-TransportRule', 'exchange-transport-rule-readiness-v1'), + ]; +} + +function spec437UnitRemoteDomainNormalizedPayload(): array +{ + return [ + 'canonical_type' => 'remoteDomain', + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-remote-domain-readiness-v1', + 'source_identity' => ['field' => 'Identity', 'value' => 'spec437-remote-domain-1'], + 'material' => [ + 'Identity' => 'spec437-remote-domain-1', + 'IsDefault' => false, + ], + 'remote_domain_class' => [ + 'domain_name_is_identity' => false, + 'is_default' => false, + ], + 'settings' => [ + 'auto_reply_enabled' => true, + 'auto_forward_enabled' => spec437UnitRedactedMarker(count: 1), + 'mail_tips_access_scope' => spec437UnitRedactedMarker(count: 1), + ], + 'protected_configuration' => [ + 'domain_name' => spec437UnitRedactedMarker(count: 1), + ], + 'sensitive_content' => [ + 'mail_tips_access_scope' => spec437UnitRedactedMarker(count: 1), + ], + 'diagnostics' => [ + 'volatile_fields' => ['WhenChanged', 'ExchangeVersion', 'RunspaceId'], + 'protected_fields' => ['domain_name'], + 'sensitive_fields' => ['mail_tips_access_scope'], + ], + 'source' => spec437UnitSourceMetadata('remoteDomain', 'Get-RemoteDomain', 'exchange-remote-domain-readiness-v1'), + ]; +} + +function spec437UnitInboundConnectorNormalizedPayload(): array +{ + return [ + 'canonical_type' => 'inboundConnector', + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => 'exchange-inbound-connector-readiness-v1', + 'source_identity' => ['field' => 'ConnectorId', 'value' => 'spec437-inbound-connector-1'], + 'material' => [ + 'ConnectorType' => 'Partner', + 'Enabled' => true, + 'RequireTls' => true, + ], + 'routing' => [ + 'connector_type' => 'Partner', + 'enabled' => true, + 'require_tls' => true, + 'sender_ip_addresses' => spec437UnitRedactedMarker(count: 1), + 'smart_hosts' => spec437UnitRedactedMarker(count: 1), + 'tls_sender_certificate_name' => spec437UnitRedactedMarker(count: 1), + 'comment' => spec437UnitRedactedMarker(count: 1), + ], + 'protected_configuration' => [ + 'sender_ip_addresses' => spec437UnitRedactedMarker(count: 1), + ], + 'sensitive_content' => [ + 'smart_hosts' => spec437UnitRedactedMarker(count: 1), + 'tls_sender_certificate_name' => spec437UnitRedactedMarker(count: 1), + 'comment' => spec437UnitRedactedMarker(count: 1), + ], + 'diagnostics' => [ + 'volatile_fields' => ['WhenChanged', 'ExchangeVersion', 'RunspaceId'], + 'protected_fields' => ['sender_ip_addresses'], + 'sensitive_fields' => ['smart_hosts', 'tls_sender_certificate_name', 'comment'], + ], + 'source' => spec437UnitSourceMetadata('inboundConnector', 'Get-InboundConnector', 'exchange-inbound-connector-readiness-v1'), + ]; +} + +function spec437UnitRedactedMarker(int $count = 1, bool $present = true): array +{ + return [ + 'value' => '[redacted]', + 'present' => $present, + 'count' => $count, + ]; +} + +function spec437UnitSourceMetadata(string $canonicalType, string $commandName, string $normalizerVersion): array +{ + return [ + 'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE, + 'source_contract_key' => 'exchange_powershell.'.$canonicalType, + 'command_contract_name' => $commandName, + 'command_contract_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, + 'payload_shape_version' => ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION, + 'normalizer_version' => $normalizerVersion, + ]; +} + +function spec437UnitEvidenceMetadata(): array +{ + return [ + 'evidence_state' => EvidenceState::ContentBacked->value, + 'coverage_level' => CoverageLevel::ContentBacked->value, + 'capture_outcome' => CaptureOutcome::Captured->value, + ]; +} + +function spec437UnitBuild(ExchangePowerShellComparablePayloadBuilder $builder, string $resourceType, array $normalizedPayload): array +{ + return $builder->build( + $resourceType, + $normalizedPayload, + payloadHash: spec437UnitPayloadHash(), + metadata: spec437UnitEvidenceMetadata(), + ); +} + +function spec437UnitPayloadHash(): string +{ + return str_repeat('a', 64); +} + +function spec437UnitIdentityHmac(string $resourceType, string $field, string $value): string +{ + $key = (string) config('app.key'); + + if (str_starts_with($key, 'base64:')) { + $decoded = base64_decode(substr($key, 7), true); + $key = $decoded !== false ? $decoded : ''; + } + + return hash_hmac('sha256', $resourceType."\0".$field."\0".$value, $key); +} diff --git a/specs/437-exchange-comparable-promotion-slice-1/checklists/requirements.md b/specs/437-exchange-comparable-promotion-slice-1/checklists/requirements.md new file mode 100644 index 00000000..d55909cc --- /dev/null +++ b/specs/437-exchange-comparable-promotion-slice-1/checklists/requirements.md @@ -0,0 +1,170 @@ +# Requirements Checklist: Spec 437 - Exchange Comparable Promotion Slice 1 + +**Purpose**: Validate Spec 437 scope, prerequisites, comparable-only safety gates, no-promotion posture, and preparation completeness before implementation. +**Created**: 2026-07-09 +**Feature**: [spec.md](../spec.md) + +Unchecked runtime proof items below are implementation gates, not preparation failures. They must be checked only after Spec 437 code and tests provide evidence. + +## Prerequisites + +- [x] Spec 436 PASS/merge-ready or merged state is required before implementation. +- [x] `transportRule` content-backed evidence prerequisite is required. +- [x] `remoteDomain` content-backed evidence prerequisite is required. +- [x] `inboundConnector` content-backed evidence prerequisite is required. +- [x] Spec 436 normalized payloads must be deterministic. +- [x] Spec 436 evidence must remain append-only. + +## Scope + +- [x] Comparable-only. +- [x] Only `transportRule`. +- [x] Only `remoteDomain`. +- [x] Only `inboundConnector`. +- [x] No renderable. +- [x] No certification. +- [x] No restore. +- [x] No customer claim. +- [x] No UI. +- [x] No jobs/schedules/listeners. +- [x] No migration. + +## Comparable Input + +- [x] Uses `normalized_payload` only. +- [x] Does not use `raw_payload`. +- [x] Does not use raw stdout/stderr. +- [x] Does not use PowerShell transcript. +- [x] Does not use OperationRun context. +- [x] Does not use logs. +- [x] Does not use report/review-pack output. +- [x] Stable material identity comes from `normalized_payload.source_identity`. +- [x] `payload_hash` is required before comparable output is derived. +- [x] `payload_hash` must be a valid SHA-256 hash before comparable output is derived. +- [x] `evidence_state = content_backed` is required before comparable output is derived. +- [x] `coverage_level = content_backed` is required before comparable output is derived. +- [x] `capture_outcome = captured` is required before comparable output is derived. +- [x] Safe evidence metadata does not override identity or provide material compare fields. + +## Comparable Output + +- [x] Deterministic. +- [x] Machine-readable. +- [x] Stable identity field included. +- [x] Stable identity exact value is not emitted. +- [x] Scoped HMAC identity anchor is emitted instead of an exact value or unsalted hash. +- [x] Material safe fields included. +- [x] Material safe fields pass target-specific value policies before exact output. +- [x] Volatile fields excluded. +- [x] Provider ordering deterministic. +- [x] Protected values redacted/omitted. +- [x] Unexpected exact protected/sensitive values block comparable output. +- [x] Malformed exact values on otherwise safe fields block comparable output. +- [x] No customer-facing prose. +- [x] No risk verdict. +- [x] No certification label. + +## Target Comparable + +- [x] `transportRule` comparable safe. +- [x] `remoteDomain` comparable safe. +- [x] `inboundConnector` comparable safe. +- [x] `remoteDomain` domain-only identity blocked. +- [x] `remoteDomain` display-only identity blocked. +- [x] `remoteDomain` unsafe identity variants are blocked case/separator-insensitively. +- [x] `inboundConnector` protected config not exposed. +- [x] `transportRule` sensitive values not exposed. + +## Evidence Immutability + +- [x] `raw_payload` unchanged. +- [x] `normalized_payload` unchanged. +- [x] `payload_hash` unchanged. +- [x] `operation_run_id` unchanged. +- [x] source metadata unchanged. +- [x] persisted `coverage_level` unchanged. +- [x] `TenantConfigurationResource.latest_*` state unchanged. +- [x] `TenantConfigurationResourceType.default_coverage_level` unchanged. +- [x] prior evidence rows not overwritten. +- [x] evidence remains append-only. + +## Empty / Missing / Unsafe + +- [x] Empty collection creates no comparable artifact. +- [x] Missing evidence creates no comparable artifact. +- [x] Unsafe identity creates no comparable artifact. +- [x] Blocked target creates no comparable artifact. + +## No Promotion Beyond Comparable + +- [x] No persisted comparable coverage-level promotion. +- [x] No renderable state. +- [x] No certified state. +- [x] No restore-ready state. +- [x] No customer-ready state. +- [x] No customer-claimable state. +- [x] No report output. +- [x] No Review Pack output. +- [x] No PDF output. + +## Product Surface / Trigger + +- [x] No routes. +- [x] No Filament pages. +- [x] No Livewire components. +- [x] No views. +- [x] No navigation. +- [x] No asset changes. +- [x] No global search changes. +- [x] No `CoverageV2ReadinessReadModel` render output. +- [x] No job trigger. +- [x] No schedule trigger. +- [x] No listener trigger. +- [x] Browser N/A. + +## Architecture + +- [x] Uses Coverage v2 architecture. +- [x] Uses Spec 436 normalized payloads. +- [x] Does not use `GenericPayloadNormalizer`. +- [x] Does not use `ExchangeTeamsComparablePayloadNormalizer` for Spec 436 Exchange PowerShell payloads. +- [x] No Exchange-specific compare table. +- [x] No `tenant_id` ownership truth. +- [x] No Exchange mini-platform. +- [x] No legacy shim. +- [x] No fallback reader. +- [x] No migration. +- [x] No new OperationRun type. + +## Validation + +- [x] Focused unit tests pass. +- [x] Focused feature tests pass. +- [x] Regressions pass. +- [x] Pint passes. +- [x] `git diff --check` passes. +- [x] `git status --short` documented. + +## Product Surface Contract + +- [x] No-legacy posture is explicit. +- [x] Product Surface Impact is `N/A - no rendered product surface changed`. +- [x] Browser proof is `N/A - no rendered UI surface changed`. +- [x] Human Product Sanity is `N/A - no product surface changed`. +- [x] Product Surface exceptions are `none`. +- [x] Implementation report fields are required. +- [x] Completed historical specs must not be rewritten. + +## Review Outcome + +- [x] Review outcome class: acceptable-special-case for preparation because scope is backend comparable-only and all product-surface paths are explicitly N/A. +- [x] Workflow outcome: keep. +- [x] Final note location: `specs/437-exchange-comparable-promotion-slice-1/implementation-report.md`. + +## Final Candidate Gate + +Preparation result: PASS. + +Implementation PASS requires all runtime validation items above to be checked with evidence. PASS WITH CONDITIONS is allowed only for bounded follow-ups that do not weaken comparable determinism, redaction, evidence immutability, no-renderable posture, no-customer-output posture, no-product-surface posture, ownership, or no-mini-platform posture. + +FAIL if renderable output is added; raw payload/stdout/stderr/transcript/OperationRun context is used as compare input; protected config leaks; evidence payload/provenance is mutated; certification/restore/customer claims appear; UI/routes/jobs/schedules/listeners are added; migration is added without amendment; `tenant_id` ownership truth is introduced; Exchange-specific compare table appears without approval; or tests do not prove comparable safety. diff --git a/specs/437-exchange-comparable-promotion-slice-1/implementation-report.md b/specs/437-exchange-comparable-promotion-slice-1/implementation-report.md new file mode 100644 index 00000000..bdce3f2c --- /dev/null +++ b/specs/437-exchange-comparable-promotion-slice-1/implementation-report.md @@ -0,0 +1,273 @@ +# Implementation Report: Spec 437 - Exchange Comparable Promotion Slice 1 + +Date: 2026-07-09 + +## Result + +- **Status**: PASS. +- **Active spec**: `specs/437-exchange-comparable-promotion-slice-1/`. +- **Branch**: `feat/437-exchange-comparable-promotion-slice-1`. +- **HEAD at implementation start/end**: `30a6733f Spec 436: Exchange content-backed evidence promotion (#503)`. +- **Implementation/fix iterations**: 1 implementation pass, 2 review-fix passes. + +## Candidate Gate Result + +PASS. Spec 437 is a directly provided, prepared comparable-only backend slice for exactly `transportRule`, `remoteDomain`, and `inboundConnector`. Spec 436 is present at current base and records PASS for the required content-backed evidence prerequisite. The implementation now fails closed unless explicit content-backed/captured provenance and a valid SHA-256 payload hash are supplied. + +## Branch And Dirty State + +- Branch at implementation start: `feat/437-exchange-comparable-promotion-slice-1`. +- HEAD at implementation start: `30a6733f Spec 436: Exchange content-backed evidence promotion (#503)`. +- Initial dirty state: untracked active Spec 437 package only. +- Final dirty state: intended Spec 437 runtime/test/spec files. + +## Files Changed + +- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php` +- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php` +- `apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php` +- `specs/437-exchange-comparable-promotion-slice-1/checklists/requirements.md` +- `specs/437-exchange-comparable-promotion-slice-1/implementation-report.md` +- `specs/437-exchange-comparable-promotion-slice-1/plan.md` +- `specs/437-exchange-comparable-promotion-slice-1/spec.md` +- `specs/437-exchange-comparable-promotion-slice-1/tasks.md` + +Completed historical specs were used as read-only context and were not rewritten. + +## Spec 436 Prerequisite Proof + +- Current base HEAD is `30a6733f Spec 436: Exchange content-backed evidence promotion (#503)`. +- `specs/436-exchange-content-backed-evidence-promotion-slice-1/implementation-report.md` records PASS and merge-ready. +- Spec 436 records append-only content-backed evidence outcomes for `transportRule`, `remoteDomain`, and `inboundConnector`. +- Spec 436 records no compare/render/certification/restore/customer/UI/job/schedule/listener/migration promotion. +- Spec 436 records `tenant_configuration.capture` as evidence provenance and Exchange PowerShell invocation as runner truth only. + +## Target Comparable Outcome Matrix + +| Type | Content-backed prerequisite | Comparable? | Renderable? | Blocker | +|---|---|---|---|---| +| `transportRule` | Yes | Yes | No | none | +| `remoteDomain` | Yes | Yes when source identity is stable and not domain/display-only | No | `unsafe_identity` for domain-only/display-only identity | +| `inboundConnector` | Yes | Yes | No | none | + +When evidence, content-backed/captured provenance, or normalized identity is missing, unsafe, or unsupported, the builder returns no comparable artifact and an explicit blocker. + +## Safety Matrix + +| Area | State | +|---|---| +| Raw payload as input | No | +| Normalized payload as input | Yes | +| OperationRun context as input | No | +| Protected exact values exposed | No | +| Persisted coverage-level promotion | No | +| Renderable | No | +| Certification | No | +| Restore | No | +| Customer output | No | +| UI | No | +| Jobs/Schedules/Listeners | No | +| Migration | No | + +## Comparable Input Proof + +`ExchangePowerShellComparablePayloadBuilder::build()` accepts: + +- `resourceType` +- Spec 436 `normalizedPayload` +- required `payloadHash` as valid SHA-256 non-material provenance +- required content-backed/captured provenance metadata: `evidence_state`, `coverage_level`, and `capture_outcome` + +It does not accept raw provider payload, stdout, stderr, transcript, OperationRun context, logs, UI read models, report output, or Review Pack output as material input. Stable material identity is derived only from `normalized_payload.source_identity`; external metadata is ignored for material identity and compare fields. Missing or malformed `payloadHash`, non-content-backed evidence state, non-content-backed coverage level, or non-captured outcome returns a blocker and no comparable payload. + +## Comparable Output Proof + +Builder output is a machine-readable derived result: + +- `comparable` +- optional `blocker` +- `payload` +- `comparable_hash` + +Comparable payloads include: + +- workload `exchange` +- resource type +- comparable payload shape version +- `compare_input = normalized_payload_only` +- stable identity field plus scoped app-keyed HMAC-derived source key +- target-safe material fields after value-policy validation +- redacted presence/count/state structures +- safe source/provenance metadata + +Comparable payloads exclude raw provider objects, raw policy text, raw protected values, exact identity values, unsalted identity hashes, customer prose, risk verdicts, restore instructions, and certification labels. + +## Evidence Immutability Proof + +`Spec437ExchangeComparablePromotionFeatureTest` asserts derived comparable output does not mutate: + +- `raw_payload` +- `normalized_payload` +- `payload_hash` +- `operation_run_id` +- `source_metadata` +- persisted `coverage_level` +- `TenantConfigurationResource.latest_*` +- `TenantConfigurationResourceType.default_coverage_level` +- OperationRun type/context + +No resource, evidence, resource type, or OperationRun write path is used by the builder. + +## Redaction Proof + +Focused unit tests prove: + +- `transportRule` conditions/actions/exceptions use redacted markers/counts and do not expose raw sender domains, subject words, header values, or exception email values. +- `remoteDomain` protected domain/settings values use redacted markers/counts and domain-only/display-only identity is blocked case/separator-insensitively. +- `inboundConnector` routing/TLS/protected config uses redacted markers/counts and does not expose raw IPs, hosts, smart hosts, certificate names, comments, or routing metadata. +- Credential material, tokens, authorization headers, cookies, raw stdout/stderr, transcript, and OperationRun context do not appear in comparable output. +- Unexpected exact protected/sensitive values return `unsafe_exact_value` and no comparable payload. +- Malformed exact values on otherwise allowlisted safe fields, such as string secrets in boolean fields or unknown enum values, return `unsafe_exact_value` and no comparable payload. + +## Determinism Proof + +Focused unit tests prove: + +- same normalized material produces the same comparable hash +- material safe changes change the comparable hash +- volatile-only input changes do not change the comparable hash +- redacted exact-value changes with unchanged safe marker/count do not change the comparable hash +- redacted count changes change the comparable hash +- hash behavior is covered for `transportRule`, `remoteDomain`, and `inboundConnector` +- non-material Evidence/OperationRun provenance is excluded from `comparable_hash` +- compare-shape/source contract metadata that changes normalization semantics remains hash-relevant, following Spec 435 hash-input precedent +- identity HMAC values are deterministic for a deployment key without exposing exact identity values + +Provider ordering stability is inherited from Spec 436 normalized payload ordering and preserved by builder canonical sorting. + +## Delta Proof + +N/A. No comparator was added because repository architecture did not require comparison output for this comparable-payload-only slice. Tasks T035-T037 are completed as N/A with explicit notes in `tasks.md`. + +## No Raw Payload Proof + +Tests inject raw-provider, raw stdout, raw stderr, transcript, OperationRun context, credential/token-like, exact protected/sensitive, and metadata override values into non-material positions and assert none are emitted in comparable output. Static feature tests also assert the builder does not use `GenericPayloadNormalizer`, `ExchangeTeamsComparablePayloadNormalizer`, `CoverageEvidenceWriter`, or direct evidence model writes. + +## No OperationRun Context Input Proof + +- No new OperationRun type was added. +- `OperationRunType::TenantConfigurationCapture` remains the evidence provenance type. +- `OperationRunType::TenantConfigurationExchangePowerShellInvocation` remains runner truth only. +- OperationRun context is not builder input and is not stored with comparable payloads or deltas. + +## No Persisted Coverage-Level Promotion Proof + +Comparable output is derived service output only. Feature tests assert evidence remains `CoverageLevel::ContentBacked`, resource latest state remains unchanged, and resource type defaults remain unchanged. No Exchange-specific compare table or migration was added. + +## No Renderable / Certification / Restore / Customer Proof + +No render model, certification state, restore/apply state, customer-ready/customer-claimable state, customer-safe summary, report, Review Pack, PDF, customer route, or customer badge was added. + +## No UI / Route / Trigger Proof + +Static tests assert no new Exchange comparable Filament, Livewire, route, Blade view, job, console command, migration, or OperationRun type exists. Browser proof is `N/A - no rendered UI surface changed`. + +## No Tenant ID / Migration Proof + +Static tests assert: + +- no `tenant_id` column exists on `tenant_configuration_resources` +- no `tenant_id` column exists on `tenant_configuration_resource_evidence` +- no Exchange comparable migration was added +- no Exchange-specific compare table or mini-platform was added + +## Product Surface Close-Out + +- **Runtime UI files changed**: no. +- **UI impact**: N/A - no rendered UI surface changed. +- **Product Surface exceptions**: none. +- **Browser proof**: N/A - no rendered UI surface changed. +- **Human Product Sanity**: N/A - no product surface changed. +- **Visible complexity outcome**: neutral. +- **No-legacy confirmation**: no aliases, fallback readers, hidden routes, duplicate UI, old labels, compatibility shim, dual write, or fallback reader. +- **Completed-spec rewrite assertion**: completed historical specs were not rewritten. +- **Livewire v4 compliance**: unchanged; no Livewire code changed. +- **Provider registration location**: unchanged; Laravel panel providers remain under `apps/platform/bootstrap/providers.php`. +- **Global search posture**: unchanged; no Filament Resource changed. +- **Destructive/high-impact action posture**: none; no UI action, runtime trigger, restore, apply, or destructive/high-impact action was added. +- **Asset strategy**: no assets; no `filament:assets` requirement introduced. +- **Deployment impact**: no env vars, migrations, queues, scheduler, storage, assets, routes, jobs, listeners, commands, or Dokploy runtime behavior changes. + +## Validation + +- `php -l apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php` + - Result: PASS. +- `php -l apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php` + - Result: PASS. +- `php -l apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php` + - Result: PASS. +- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec437 --compact` + - Result: failed before completion with Symfony Process signal 9. Non-Docker fallback used. +- `cd apps/platform && php artisan test --filter=Spec437 --compact` + - Result: PASS, 34 tests / 225 assertions. +- `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec436|Spec435|Spec434|Spec433|Spec432|Spec431|Spec430' --compact` + - Result: failed before completion with Symfony Process signal 9. Non-Docker fallback used. +- `cd apps/platform && php artisan test --filter='Spec436|Spec435|Spec434|Spec433|Spec432|Spec431|Spec430' --compact` + - Result: PASS, 302 tests / 2196 assertions. +- `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec421|Spec425|Spec415|Spec417|Spec419|Spec420|Spec426|Spec427|ProviderCapabilityRegistry' --compact` + - Result: failed before completion with Symfony Process signal 9. Non-Docker fallback used. +- `cd apps/platform && php artisan test --filter='Spec421|Spec425|Spec415|Spec417|Spec419|Spec420|Spec426|Spec427|ProviderCapabilityRegistry' --compact` + - Result: PASS, 282 passed / 8 skipped / 2488 assertions. Skips are existing PostgreSQL-specific skips in the SQLite/default fallback lane. +- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` + - Result: PASS. +- `git diff --check` + - Result: PASS. +- `git status --short` + - Result: intended Spec 437 runtime/test/spec files. + +## Post-Implementation Analysis + +Iteration 1 found review findings around fail-open exact protected values, missing content-backed provenance enforcement, case-sensitive remoteDomain unsafe identity handling, unsalted identity hashing, incomplete target hash coverage, and incomplete spec/report consistency. + +Iteration 2 resolved the confirmed findings by adding content-backed/captured provenance blockers, scoped app-keyed HMAC identity anchors, case/separator-insensitive unsafe remoteDomain identity checks, fail-closed `unsafe_exact_value` handling, expanded target hash/security tests, and synchronized Spec Kit artifacts. + +Iteration 3 resolved the follow-up review finding that exact safe fields were field-allowlisted but not value-policy-validated. The builder now validates exact safe values by field policy, blocks malformed allowlisted values, requires a valid SHA-256 payload hash, and records the strategic hash decision: run/evidence provenance is excluded from `comparable_hash`, while source/shape/normalizer metadata remains hash-relevant because it changes compare semantics under the Spec 435 precedent. + +Confirmed in-scope findings after second fix pass: none. + +Residual risks: none for this slice. Renderable output, certified compare packs, customer output, reports, Review Packs, PDFs, Teams slices, and restore/apply remain deferred to separate specs. + +## Deferred Work + +- Spec 438 - Exchange Internal Render Model Slice 1. +- Teams PowerShell adapter and evidence slices. +- Exchange/Teams certified compare pack. +- M365 customer output and claim guard. +- Restore/apply. +- Reports, Review Packs, PDFs, and customer-facing output. + +## Merge Readiness Gate + +Status: PASS. + +Merge-ready: yes. + +Manual review prompt: + +```markdown +Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer. + +Führe eine finale manuelle Review der implementierten Spec `437-exchange-comparable-promotion-slice-1` streng repo-basiert durch. + +Ziel: +Pruefe, ob die Implementierung nach dem Agenten-Loop wirklich merge-ready ist. + +Wichtig: +- Keine Implementierung. +- Keine Codeaenderungen. +- Keine Scope-Erweiterung. +- Pruefe gegen spec.md, plan.md, tasks.md und constitution.md. +- Pruefe die geaenderten Dateien, Tests, Browser-Smoke-Test-Ergebnis, RBAC, Workspace-/Tenant-Isolation, Auditability, UX und OperationRun-Semantik, soweit relevant. +- Benenne nur konkrete Findings mit Repo-Beleg. +- Gib am Ende eine klare Entscheidung: Merge-ready, merge-ready with notes, oder not merge-ready. +``` diff --git a/specs/437-exchange-comparable-promotion-slice-1/plan.md b/specs/437-exchange-comparable-promotion-slice-1/plan.md new file mode 100644 index 00000000..8792670d --- /dev/null +++ b/specs/437-exchange-comparable-promotion-slice-1/plan.md @@ -0,0 +1,283 @@ +# Implementation Plan: Spec 437 - Exchange Comparable Promotion Slice 1 + +**Branch**: `feat/437-exchange-comparable-promotion-slice-1` | **Date**: 2026-07-09 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/437-exchange-comparable-promotion-slice-1/spec.md` + +## Summary + +Derive deterministic machine-comparable payloads for exactly `transportRule`, `remoteDomain`, and `inboundConnector` from immutable Spec 436 Exchange PowerShell content-backed evidence. The builder must consume only `normalized_payload` as material input, require a valid SHA-256 `payload_hash` plus content-backed/captured provenance gates, preserve existing evidence rows, keep persisted coverage state unchanged, represent protected values through redacted presence/count/safe-state semantics, emit stable identity as a scoped app-keyed HMAC anchor, enforce value policies for exact safe fields, keep compare-shape/source contract metadata hash-relevant, and add no renderable output, certification, restore, UI, customer output, jobs, schedules, listeners, migrations, `tenant_id`, new OperationRun type, or Exchange-specific compare table. + +## Technical Context + +**Language/Version**: PHP 8.4 / Laravel 12 +**Primary Dependencies**: Laravel, Pest 4, existing TenantConfiguration Coverage v2 services +**Storage**: Existing PostgreSQL-backed Coverage v2 tables are read only for this slice; no migration, comparable artifact persistence, or persisted coverage-level promotion planned +**Testing**: Pest unit and feature tests +**Validation Lanes**: fast-feedback and confidence; browser N/A +**Target Platform**: Laravel Sail locally, Dokploy/container deployment for staging/production +**Project Type**: Laravel monolith under `apps/platform` +**Performance Goals**: deterministic in-memory comparable derivation with no remote/provider calls +**Constraints**: normalized-payload-only material input, valid SHA-256 payload hash and content-backed/captured provenance required, evidence immutability, no persisted `coverage_level = comparable`, protected config redaction, exact safe field value policies, scoped HMAC identity anchors, no rendered surface +**Scale/Scope**: exactly three Exchange PowerShell resource types + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: no operator-facing surface change. +- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: N/A. +- **No-impact class, if applicable**: backend-only. +- **Native vs custom classification summary**: N/A. +- **Shared-family relevance**: backend Coverage v2 evidence/compare semantics only. +- **State layers in scope**: none. +- **Audience modes in scope**: N/A. +- **Decision/diagnostic/raw hierarchy plan**: N/A at UI layer; raw/provider/protected values must not enter comparable output. +- **Raw/support gating plan**: raw evidence remains internal storage only; comparable output contains no raw/protected exact values and does not update persisted coverage/readiness state. +- **One-primary-action / duplicate-truth control**: N/A. +- **Handling modes by drift class or surface**: hard stop if any rendered surface, route, action, report, download, or customer output appears. +- **Repository-signal treatment**: review-mandatory for any accidental UI/read-model/customer/report/trigger file change. +- **Special surface test profiles**: N/A. +- **Required tests or manual smoke**: `N/A - no rendered UI surface changed`. +- **Exception path and spread control**: none. +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage. +- **UI/Productization coverage decision**: No UI surface impact. +- **Coverage artifacts to update**: none. +- **No-impact rationale**: comparable-only backend service and tests. +- **Navigation / Filament provider-panel handling**: no changes. +- **Screenshot or page-report need**: no. + +## Product Surface Contract Plan + +- **Product Surface Contract reference**: `docs/product/standards/product-surface-contract.md` checked for no-rendered-surface path. +- **No-legacy posture**: canonical bounded addition; no aliases, fallback readers, hidden routes, duplicate UI, old labels, or legacy fixtures. +- **Page archetype and surface budget plan**: N/A. +- **Technical Annex and deep-link demotion plan**: N/A at runtime; OperationRun/evidence/raw IDs/source keys/payloads are not rendered. +- **Canonical status vocabulary plan**: N/A - no product status vocabulary. +- **Product Surface exceptions**: none. +- **Browser verification plan**: `N/A - no rendered UI surface changed`. +- **Human Product Sanity plan**: N/A. +- **Visible complexity outcome target**: neutral. +- **Implementation report target**: `specs/437-exchange-comparable-promotion-slice-1/implementation-report.md`. + +## Filament / Livewire / Deployment Posture + +- **Livewire v4 compliance**: unchanged; no Livewire/Filament code changes. +- **Panel provider registration location**: unchanged; no panel provider registration change. Laravel panel providers remain under `apps/platform/bootstrap/providers.php`. +- **Global search posture**: unchanged; no Filament Resource changed. +- **Destructive/high-impact action posture**: none. +- **Asset strategy**: no assets; no new `filament:assets` requirement. +- **Testing plan**: focused unit and feature tests only; browser N/A. +- **Deployment impact**: no env vars, migrations, queues, scheduler, storage, assets, routes, jobs, listeners, or commands. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes, backend Coverage v2 comparable semantics. +- **Systems touched**: existing TenantConfiguration evidence/comparable service layer. +- **Shared abstractions reused**: Spec 436 normalized evidence payloads; existing evidence models; existing hash/provenance metadata; app-keyed HMAC identity anchoring. +- **New abstraction introduced? why?**: bounded `ExchangePowerShellComparablePayloadBuilder` because existing generic/ExchangeTeams compare paths do not enforce Spec 436 normalized-payload-only material input, required content-backed provenance, target-specific protected-value safety, exact safe field value policies, scoped identity HMAC anchoring, and no persisted coverage-level promotion for this slice. +- **Why the existing abstraction was sufficient or insufficient**: Evidence storage is sufficient. Existing generic/ExchangeTeams comparable normalizers are insufficient and forbidden for this input path. +- **Bounded deviation / spread control**: provider-specific builder stays local to Exchange PowerShell evidence for three types; no platform-core status/persistence/UI/customer seam change. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no. +- **Central contract reused**: N/A. +- **Delegated UX behaviors**: N/A. +- **Surface-owned behavior kept local**: none. +- **Queued DB-notification policy**: N/A. +- **Terminal notification path**: N/A. +- **Exception path**: none. + +OperationRun implementation posture: + +- Do not add or modify `OperationRunType` or `OperationCatalog`. +- Do not use Exchange PowerShell invocation runs as comparable owners. +- Do not use OperationRun context as input. +- Do not store comparable payloads, deltas, raw output, transcripts, protected config, or customer text in OperationRun context/messages/notifications. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes. +- **Provider-owned seams**: Exchange PowerShell comparable payload builder and target-specific safe field mapping. +- **Platform-core seams**: Coverage v2 evidence remains source truth; ownership remains workspace + managed environment + provider connection. +- **Neutral platform terms / contracts preserved**: evidence, comparable payload, normalized payload, resource type, workload, provider connection, managed environment, operation run provenance. +- **Retained provider-specific semantics and why**: `transportRule`, `remoteDomain`, `inboundConnector`, Exchange PowerShell source metadata, and protected Exchange config semantics are required for this bounded slice. +- **Bounded extraction or follow-up path**: Spec 438 internal render model is separate. + +## Constitution Check + +| Principle / Gate | Result | Notes | +|---|---|---| +| Inventory-first / evidence truth | PASS | Comparable derives only when immutable evidence is content-backed/captured and does not promote persisted evidence coverage level. | +| Read/write separation | PASS | No provider writes, restore, apply, destructive action, or UI action. | +| Graph contract path | PASS | No Graph or Exchange call in Spec 437. | +| Deterministic capabilities | PASS | Comparable derivation is deterministic and testable. | +| Workspace isolation | PASS | No new ownership truth; evidence scope remains existing workspace/managed-environment/provider connection. | +| RBAC-UX | PASS | No routes/actions/resources/global search. | +| OperationRun truth | PASS | No new OperationRun type; context not input. | +| Evidence anchor contract | PASS | No fallback-to-latest or OperationRun-as-proof. | +| Data minimization | PASS | Raw/protected values excluded from comparable payloads; unexpected exact protected/sensitive values block output. | +| Product Surface | PASS | N/A - no rendered UI surface changed. | +| Customer output | PASS | No customer output, reports, Review Packs, PDFs, or customer claims. | +| Provider boundary | PASS | Provider-specific builder is bounded and local. | +| Test governance | PASS | Unit/Feature only; Browser N/A. | +| Proportionality | PASS | One bounded helper; no persistence/status/UI framework. | +| TCM/Coverage v2 cutover guard | PASS | No legacy adapters, fallback readers, dual writes, tenant_id, or customer claims. | + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for builder/comparator determinism and redaction; Feature for evidence immutability and no-promotion/no-surface guards. +- **Affected validation lanes**: fast-feedback, confidence. +- **Why this lane mix is the narrowest sufficient proof**: Spec 437 changes backend comparable behavior only; no browser/render/provider execution lane is needed. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec437 --compact` + - targeted regressions listed in Phase 6. +- **Fixture / helper / factory / seed / context cost risks**: reuse existing TenantConfiguration evidence factories/helpers; avoid global defaults. +- **Expensive defaults or shared helper growth introduced?**: no. +- **Heavy-family additions, promotions, or visibility changes**: none. +- **Surface-class relief / special coverage rule**: N/A. +- **Closing validation and reviewer handoff**: reviewer must verify valid payload hash and content-backed/captured provenance gates, scoped HMAC identity anchors, exact safe field value policies, no raw payload/context input, no evidence mutation, no UI/customer/trigger/persistence expansion, and exact target scope. +- **Budget / baseline / trend follow-up**: none. +- **Review-stop questions**: lane fit, redaction, evidence immutability, no-product-surface, no-mini-platform. +- **Escalation path**: none unless implementation requires renderable, persistence, UI, or migration; then amend/split. +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage. +- **Why no dedicated follow-up spec is needed**: comparable-only builder is the dedicated bounded slice; renderable is already deferred to Spec 438. + +## Project Structure + +### Documentation + +```text +specs/437-exchange-comparable-promotion-slice-1/ +|-- checklists/ +| `-- requirements.md +|-- implementation-report.md +|-- plan.md +|-- spec.md +`-- tasks.md +``` + +### Source Code + +Likely runtime paths if implementation proceeds: + +```text +apps/platform/app/Services/TenantConfiguration/ +|-- ExchangePowerShellComparablePayloadBuilder.php +`-- ExchangePowerShellCoverageComparator.php # only if needed + +apps/platform/tests/Unit/Support/TenantConfiguration/ +`-- Spec437ExchangeComparablePayloadBuilderTest.php + +apps/platform/tests/Feature/TenantConfiguration/ +`-- Spec437ExchangeComparablePromotionFeatureTest.php +``` + +**Structure Decision**: Use existing TenantConfiguration service/test locations. Do not create new base folders, migrations, routes, UI surfaces, jobs, listeners, schedules, commands, or customer-output paths. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|---|---|---| +| New bounded builder helper | Required to enforce normalized-payload-only input and protected Exchange target semantics. | Reusing generic/ExchangeTeams normalizer would violate Spec 437 input and redaction gates. | + +## Proportionality Review + +- **Current operator problem**: Future compare/render/certification work cannot safely proceed until Exchange evidence can be compared without leaking protected configuration or overclaiming readiness. +- **Existing structure is insufficient because**: Existing generic/ExchangeTeams comparable path predates Spec 436 normalized evidence and includes broader renderable behavior that Spec 437 explicitly defers. +- **Narrowest correct implementation**: One local builder for three concrete Exchange PowerShell target types; comparator only if needed; no persisted comparable artifact or coverage-level promotion. +- **Ownership cost created**: Focused tests and future review burden around redaction/determinism. +- **Alternative intentionally rejected**: Raw payload compare, normalized provider payload compare outside Spec 436 evidence, and ExchangeTeamsComparablePayloadNormalizer reuse. +- **Release truth**: Current-release prerequisite for the Exchange roadmap. + +## Phase 0 - Preflight + +- Capture branch and HEAD. +- Capture `git status --short`. +- Confirm Spec 436 PASS/merge-ready or merged at current base. +- Confirm target types content-backed. +- Confirm comparable-only scope. +- Confirm renderable deferred. +- Confirm no UI/jobs/schedules/listeners/migration scope. +- Confirm no hard-gate stop condition. + +Deliverable: Preflight documented in `implementation-report.md`. + +## Phase 1 - Comparable Builder + +- Add Exchange PowerShell comparable payload builder. +- Use `normalized_payload` only. +- Require a valid SHA-256 `payload_hash`, `evidence_state = content_backed`, `coverage_level = content_backed`, and `capture_outcome = captured`. +- Forbid raw payload/stdout/stderr/transcript input. +- Forbid OperationRun context input. +- Add deterministic output shape. + +Deliverable: Comparable builder exists and is covered by unit tests. + +## Phase 2 - Target Comparable Payloads + +- Add `transportRule` comparable payload. +- Add `remoteDomain` comparable payload. +- Add `inboundConnector` comparable payload. +- Encode protected config using redacted presence/count/safe-state semantics. +- Block unexpected exact protected/sensitive values rather than sanitizing them through. +- Validate exact safe fields by target-specific value policy before output. +- Emit stable identity through scoped HMAC anchors instead of exact values or unsalted hashes. +- Keep compare-shape and normalizer contract metadata hash-relevant; exclude run/evidence provenance from comparable hash. +- Exclude volatile fields. + +Deliverable: Three target comparable payloads. + +## Phase 3 - Determinism / Delta Semantics + +- Prove deterministic comparable payload. +- Prove material changes produce changed comparable payload or machine deltas. +- Prove volatile changes ignored. +- Prove provider ordering deterministic. +- Add comparator only if repo architecture requires it. + +Deliverable: Comparable semantics are deterministic. + +## Phase 4 - Safety / Immutability + +- Prove evidence rows are not mutated. +- Prove persisted coverage/resource/resource-type state is not promoted to comparable. +- Prove no raw payload leaks. +- Prove OperationRun context is not compare input. +- Prove no renderable/customer/cert/restore state. + +Deliverable: Comparable promotion without overclaim. + +## Phase 5 - Product Surface Guard + +- Prove no UI/routes/jobs/schedules/listeners. +- Prove no report/review-pack/PDF/customer output. +- Prove browser N/A. + +Deliverable: No product surface changed. + +## Phase 6 - Regression / Report + +- Run focused tests. +- Run regressions. +- Run Pint. +- Run `git diff --check`. +- Complete implementation report. + +Deliverable: Spec 437 implementation report. + +## Rollout Considerations + +- No migration or deployment flag. +- No queue/scheduler/storage/asset change. +- No `filament:assets` requirement introduced by this spec. +- Comparable derivation remains internal backend behavior until a later spec explicitly adds render/read-model/customer surfaces. + +## Risk Controls + +- Hard stop on raw payload/context input. +- Hard stop on protected exact value leakage. +- Hard stop on missing content-backed/captured provenance. +- Hard stop on invalid payload hash or malformed exact safe field values. +- Hard stop on evidence mutation. +- Hard stop on UI/read-model/customer/report/trigger/migration expansion. +- Hard stop on new OperationRun type or tenant_id ownership truth. +- Use static/no-surface tests in addition to behavior tests. diff --git a/specs/437-exchange-comparable-promotion-slice-1/spec.md b/specs/437-exchange-comparable-promotion-slice-1/spec.md new file mode 100644 index 00000000..1f39541d --- /dev/null +++ b/specs/437-exchange-comparable-promotion-slice-1/spec.md @@ -0,0 +1,1024 @@ +# Feature Specification: Spec 437 - Exchange Comparable Promotion Slice 1 + +**Feature Branch**: `feat/437-exchange-comparable-promotion-slice-1` +**Created**: 2026-07-09 +**Status**: Draft +**Type**: Coverage v2 / Microsoft 365 / Exchange / Comparable Payloads / Backend-only +**Priority**: P0 +**Risk**: High +**Mode**: Comparable-only promotion for the first Exchange PowerShell content-backed slice; no renderable, no certification, no restore, no UI, no customer claims +**Input**: User-provided corrected Spec 437 draft for comparable-only Exchange promotion after Spec 436. + +## Preparation Selection Summary + +- **Selected candidate**: Spec 437 - Exchange Comparable Promotion Slice 1. +- **Source location**: Direct user-provided corrected Spec 437 draft in this session. +- **Why selected**: `docs/product/spec-candidates.md` records no safe automatic next-best-prep target, but the user provided the next bounded Exchange roadmap slice. Spec 436 is present at HEAD and records PASS for content-backed evidence promotion of `transportRule`, `remoteDomain`, and `inboundConnector`. +- **Close alternatives deferred**: Spec 438 internal render model, Teams slices, certification, restore/apply, customer output, Review Packs, reports, PDFs, UI/read-model output, jobs, schedules, listeners, target expansion, and Exchange-specific compare persistence remain deferred. +- **Roadmap relationship**: Continues the corrected Exchange sequence: Specs 429-436 establish source catalog, adapter contracts, invocation, runner boundary, credential/permission readiness, evidence capture guard, structured output/normalizers, and content-backed evidence. Spec 437 may prove only comparable payloads for the three Spec 436 Exchange targets. +- **Completed-spec guardrail result**: Specs 430-436 are completed or implementation-closed read-only context and were not modified during preparation. No existing `specs/437-*` package or branch was found before preparation. Spec 422 is completed historical context only and is not reused because it promoted broader Exchange/Teams comparable/renderable behavior from generic content-backed rows, while Spec 437 is comparable-only from Spec 436 Exchange PowerShell normalized evidence. +- **Smallest viable implementation slice**: Add an Exchange PowerShell comparable payload builder for exactly `transportRule`, `remoteDomain`, and `inboundConnector`; derive deterministic machine-comparable payloads only from immutable Spec 436 `normalized_payload`; keep evidence rows immutable; add no renderable/customer/product/trigger/persistence expansion. +- **Feature description fed into Spec Kit**: Derive deterministic machine-comparable Exchange payloads for `transportRule`, `remoteDomain`, and `inboundConnector` from immutable Spec 436 normalized content-backed evidence only, with protected values represented by redacted presence/count/safe-state semantics and no raw payload, stdout, stderr, transcript, OperationRun context, renderable output, certification, restore, UI, routes, jobs, schedules, listeners, migrations, customer output, reports, Review Packs, PDFs, `tenant_id` ownership truth, or Exchange-specific compare table. +- **Candidate Selection Gate**: PASS for a directly provided candidate, scoped to a comparable-only backend slice. + +## Spec Candidate Check + +- **Problem**: Spec 436 can persist safe content-backed Exchange evidence, but TenantPilot still cannot derive deterministic comparable payloads for the three first Exchange PowerShell targets without risking raw payload or protected configuration leakage. +- **Today's failure**: Exchange evidence rows can be content-backed, but any compare work would either be absent, rely on generic/older ExchangeTeams compare paths, or risk using raw provider payloads, raw stdout/stderr/transcripts, OperationRun context, domain-only identities, or protected routing/config values. +- **User-visible improvement**: Future operators and reviewers get honest internal compare readiness: selected Exchange evidence can become machine-comparable without implying renderability, certification, restore, customer readiness, reports, or UI. +- **Smallest enterprise-capable version**: Comparable builder output for exactly `transportRule`, `remoteDomain`, and `inboundConnector`, consuming only Spec 436 `normalized_payload` as material input, returning deterministic machine payloads or explicit blockers. +- **Explicit non-goals**: No render model, no UI, no read-model output, no certification, no restore/apply, no reports, no Review Packs, no PDFs, no customer claims, no Teams, no additional Exchange types, no provider calls, no evidence creation, no migration, no new OperationRun type, no `tenant_id`, no Exchange-specific compare table. +- **Permanent complexity imported**: One bounded Exchange PowerShell comparable builder and optional comparator tests. No new persisted entity/table/artifact, no persisted coverage-level promotion, no new enum/status family, no UI framework, no customer-output path, no trigger surface. +- **Why now**: Spec 436 is PASS at HEAD and is the prerequisite for comparable promotion. Spec 438 render work must not start until comparable payload safety is proven. +- **Why not local**: Compare safety must be explicit and target-specific because these Exchange types include mail-flow rules, domains, and connector routing/TLS configuration. Reusing generic payload comparison or the old ExchangeTeams comparable normalizer would not enforce Spec 436 normalized-payload-only input and protected-value semantics. +- **Approval class**: Core Enterprise. +- **Red flags triggered**: New builder/comparator helper. Defense: the helper is bounded to three concrete resource types, protects evidence/redaction truth, and prevents unsafe customer/product overclaim. No persistence or status family is added. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve as a comparable-only backend safety slice. + +## Spec Scope Fields + +- **Scope**: Workspace + managed-environment scoped Coverage v2 evidence, derived comparable payload behavior only. +- **Primary Routes**: N/A - no route, page, controller, API route, Filament page/resource/widget, Livewire component, view, report, PDF, or dashboard change. +- **Data Ownership**: Existing `TenantConfigurationResource`, `TenantConfigurationResourceEvidence`, `TenantConfigurationResourceType`, `OperationRun`, and same-scope `ProviderConnection` records only as read inputs. No new table or ownership column. Comparable state is not persisted into `TenantConfigurationResourceEvidence.coverage_level`, `TenantConfigurationResource.latest_*`, or `TenantConfigurationResourceType.default_coverage_level`. Provider-native tenant identifiers remain metadata and MUST NOT become platform ownership truth. +- **RBAC**: No new authorization surface or mutating action. Implementation tests must still prove no route/UI/job/customer output is introduced and comparable derivation does not bypass existing evidence scope assumptions. + +For canonical-view specs: + +- **Default filter behavior when tenant-context is active**: N/A - no canonical route or rendered view. +- **Explicit entitlement checks preventing cross-tenant leakage**: Comparable derivation must operate on explicitly provided, already scoped evidence records; no fallback-to-latest evidence or tenant/provider-native ID ownership may be introduced. + +## No Legacy / No Backward Compatibility Constraint + +TenantPilot is pre-production unless this spec explicitly records a compatibility exception. + +- **Compatibility posture**: canonical bounded addition over Spec 436 normalized evidence. +- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no. +- **Why clean replacement is safe now**: Spec 437 does not replace user-facing behavior or persisted customer truth. It forbids legacy adapters, fallback readers, dual writes, Coverage v1 vocabulary, generic Exchange compare fallback, and old customer-facing coverage claims. + +## UI Surface Impact + +Does this spec add, remove, rename, or materially change any reachable UI surface? + +- [x] No UI surface impact +- [ ] Existing page changed +- [ ] New page/route added +- [ ] Navigation changed +- [ ] Filament panel/provider surface changed +- [ ] New modal/drawer/wizard/action added +- [ ] New table/form/state added +- [ ] Customer-facing surface changed +- [ ] Dangerous action changed +- [ ] Status/evidence/review presentation changed +- [ ] Workspace/environment context presentation changed + +No-impact rationale: Spec 437 is backend comparable-payload preparation and implementation only. It forbids routes, Filament pages/resources/widgets, Livewire components, Blade views, navigation, assets, global search, dashboards, read-model rendering, reports, downloads, Review Packs, PDFs, and customer output. + +## UI/Productization Coverage + +N/A - no reachable UI surface impact. No route inventory, design coverage, page report, screenshot, or browser artifact is required unless the spec is amended or split. + +## Product Surface Impact + +Reference: `docs/product/standards/product-surface-contract.md`. + +- **Product Surface Contract applies?**: no rendered product surface changes. The contract is recorded as an explicit no-impact gate because this spec touches evidence/comparable semantics that must not leak into UI or customer output. +- **Page archetype**: N/A. +- **Primary user question**: N/A. +- **Primary action**: N/A. +- **Surface budget result**: N/A. +- **Technical Annex / deep-link demotion**: N/A at runtime. Spec 437 forbids OperationRun links, raw evidence IDs, raw payloads, provider diagnostics, source keys, reports, PDFs, and rendered technical output. +- **Canonical status vocabulary**: N/A - no product-facing status vocabulary changes. Any internal derived comparable outcome must not become a customer/product state. +- **Visible complexity impact**: neutral. +- **Product Surface exceptions**: none. + +## Browser Verification Plan + +- **Browser proof required?**: no. +- **No-browser rationale**: `N/A - no rendered UI surface changed`. +- **Focused path when required**: N/A. +- **Primary interaction to execute**: N/A. +- **Console, Livewire, Filament, network, and 500-error checks**: N/A. +- **Full-suite failure triage**: N/A. + +## Human Product Sanity Check + +- **Required?**: no. +- **No-human-sanity rationale**: N/A - no product surface changed. +- **Reviewer questions**: N/A. +- **Planned result location**: `specs/437-exchange-comparable-promotion-slice-1/implementation-report.md` must record `N/A - no rendered UI surface changed`. + +## Product Surface Merge Gate Checklist + +- [x] No-legacy posture or approved exception recorded. +- [x] Product Surface Impact is completed with `N/A` rationale. +- [x] Browser proof is `N/A - no rendered UI surface changed`. +- [x] Human Product Sanity is not applicable with rationale. +- [x] Product Surface exceptions are `none`. +- [x] Implementation report will state Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, visible complexity outcome, and no completed-spec rewrite assertion. + +## Cross-Cutting / Shared Pattern Reuse + +- **Cross-cutting feature?**: yes, backend evidence/compare semantics only. +- **Interaction class(es)**: Coverage v2 evidence comparable payload derivation; no operator-facing interaction class. +- **Systems touched**: Existing Coverage v2 evidence storage, Spec 436 normalized evidence, target-specific Exchange normalizer output, hash/provenance metadata. +- **Existing pattern(s) to extend**: Existing TenantConfiguration services under `apps/platform/app/Services/TenantConfiguration/`, Entra comparable/comparator precedent as implementation context, Spec 436 Exchange PowerShell evidence normalizer as source input. +- **Shared contract / presenter / builder / renderer to reuse**: Reuse existing normalized evidence and hash/provenance metadata. Do not reuse `GenericPayloadNormalizer` or `ExchangeTeamsComparablePayloadNormalizer` for Spec 436 Exchange PowerShell evidence. +- **Why the existing shared path is sufficient or insufficient**: Existing Coverage v2 evidence rows are sufficient as source truth. Existing generic/ExchangeTeams compare paths are insufficient because Spec 437 must consume only Spec 436 `normalized_payload` and must block protected exact values by target. +- **Allowed deviation and why**: Add bounded provider-specific comparable builder only if implemented as the narrowest safe current-release shape for the three concrete Exchange targets. +- **Consistency impact**: Comparable output remains machine-readable internal payload, not renderable or customer prose. +- **Review focus**: Verify no raw payload, stdout/stderr/transcript, OperationRun context, report output, Review Pack output, or UI read model is accepted as builder input. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no. +- **Shared OperationRun UX contract/layer reused**: N/A. +- **Delegated start/completion UX behaviors**: N/A. +- **Local surface-owned behavior that remains**: none. +- **Queued DB-notification policy**: N/A. +- **Terminal notification path**: N/A. +- **Exception required?**: none. + +OperationRun requirements for implementation: + +- No new OperationRun type. +- `tenant_configuration.capture` remains evidence provenance for Spec 436 rows. +- `tenant_configuration.exchange_powershell_invocation` remains runner truth only and must not own comparable output. +- OperationRun context must not be compare input and must not store raw payload, comparable payload, deltas, rendered payload, stdout, stderr, transcripts, protected config, or customer-facing text. + +## Provider Boundary / Platform Core Check + +- **Shared provider/platform boundary touched?**: yes, provider-owned Exchange PowerShell compare semantics over platform-owned Coverage v2 evidence. +- **Boundary classification**: mixed, with provider-specific logic bounded inside TenantConfiguration/Exchange PowerShell services and platform-core ownership unchanged. +- **Seams affected**: comparable payload derivation for Exchange PowerShell evidence; no registry expansion, no source contract expansion, no route/UI/customer seam. +- **Neutral platform terms preserved or introduced**: evidence, comparable payload, normalized payload, resource type, workload, provider connection, managed environment, operation run provenance. +- **Provider-specific semantics retained and why**: `transportRule`, `remoteDomain`, `inboundConnector`, Exchange PowerShell command/source metadata, and target redaction semantics are required for this bounded slice. +- **Why this does not deepen provider coupling accidentally**: Provider-specific compare logic is local to Exchange PowerShell content-backed evidence and does not alter platform-core ownership, source-of-truth fields, global status vocabulary, UI, customer output, or persistence schema. +- **Follow-up path**: Spec 438 may introduce internal render models only after its own preflight and Product Surface/read-model risk review. + +## UI / Surface Guardrail Impact + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Exchange comparable payload builder | no | N/A | backend evidence/compare only | none | no | `N/A - no operator-facing surface change` | + +## Decision-First Surface Role + +N/A - no operator-facing surface changed. + +## Audience-Aware Disclosure + +N/A - no operator-facing or customer/auditor surface changed. Customer output remains unchanged and forbidden. + +## UI/UX Surface Classification + +N/A - no operator-facing surface changed. + +## Operator Surface Contract + +N/A - no operator-facing surface changed. + +## Proportionality Review + +- **New source of truth?**: no. Comparable payloads are derived from immutable Spec 436 evidence. +- **New persisted entity/table/artifact?**: no. Spec 437 comparable output is derived service/builder output only; it does not persist a comparable artifact or promote persisted evidence/resource/resource-type coverage state. +- **New abstraction?**: yes, a bounded Exchange PowerShell comparable payload builder, and optionally a bounded comparator if repo architecture needs it. +- **New enum/state/reason family?**: no. Do not add customer/product status families. Delta classifications are local machine categories only if a comparator is added. +- **New cross-domain UI framework/taxonomy?**: no. +- **Current operator problem**: Future compare/render/certification work is blocked until selected Exchange evidence can be compared without leaking protected mail-flow/routing configuration or overclaiming product readiness. +- **Existing structure is insufficient because**: `GenericPayloadNormalizer` and `ExchangeTeamsComparablePayloadNormalizer` do not enforce Spec 436 normalized-payload-only input for these Exchange PowerShell evidence rows and do not encode the corrected target-specific protected-value constraints. +- **Narrowest correct implementation**: One local builder for exactly three concrete target types, using the existing Spec 436 normalized payload shape and no new persistence. +- **Ownership cost**: Focused service tests, feature guard tests, and future maintenance when Spec 438 render work consumes comparable output. +- **Alternative intentionally rejected**: Comparing raw payloads or reusing the generic/ExchangeTeams normalizer. Both would weaken redaction, input-truth, and no-overclaim gates. +- **Release truth**: Current-release prerequisite for the immediate Exchange roadmap, not future-release speculation. + +### Compatibility posture + +This feature assumes a pre-production environment. Backward compatibility, legacy aliases, migration shims, fallback readers, and compatibility-specific tests are out of scope. + +## Testing / Lane / Runtime Impact + +- **Test purpose / classification**: Unit for deterministic builders and comparator semantics; Feature for evidence immutability/no-surface/no-tenant/no-OperationRun-type guards. +- **Validation lane(s)**: fast-feedback and confidence. Browser is N/A. +- **Why this classification and these lanes are sufficient**: The proving purpose is backend deterministic payload derivation and guardrails, not UI rendering or external provider execution. +- **New or expanded test families**: focused Spec 437 Unit/Feature tests only. +- **Fixture / helper cost impact**: reuse existing TenantConfiguration evidence factories/helpers where possible; no broad workspace/provider setup defaults should be added. +- **Heavy-family visibility / justification**: none. Browser/heavy governance remain N/A unless the spec is amended. +- **Special surface test profile**: N/A. +- **Standard-native relief or required special coverage**: N/A - no rendered UI. +- **Reviewer handoff**: Verify tests prove normalized-payload-only input, evidence immutability, protected-value redaction, no product/customer surface, no migration, no tenant_id, no new OperationRun type, no generic/ExchangeTeams normalizer use for Spec 436 Exchange comparable derivation. +- **Budget / baseline / trend impact**: none expected. +- **Escalation needed**: none; structural compare/render/customer work is deferred to separate specs. +- **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 --filter=Spec437 --compact` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec436|Spec435|Spec434|Spec433|Spec432|Spec431|Spec430' --compact` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec421|Spec425|Spec415|Spec417|Spec419|Spec420|Spec426|Spec427|ProviderCapabilityRegistry' --compact` + - `git diff --check` + - `git status --short` + +## Executive Summary + +Spec 436 created append-only, content-backed Coverage v2 evidence for the first Exchange PowerShell slice: + +- `transportRule` +- `remoteDomain` +- `inboundConnector` + +Spec 437 introduces deterministic comparable payloads for those three Exchange resource types, derived only from Spec 436 normalized content-backed evidence. + +Spec 437 does not implement renderable output, certification, restore/apply, UI, routes, Filament pages, Livewire components, jobs, schedules, listeners, reports, Review Packs, PDFs, or customer claims. + +The required output is: + +TenantPilot can derive machine-comparable Exchange payloads for `transportRule`, `remoteDomain`, and `inboundConnector` from immutable Spec 436 content-backed evidence, without exposing raw payloads, protected configuration values, customer claims, renderable output, UI, reports, restore, or certification. + +## Preflight Corrections Applied + +This spec applies the corrected preflight decisions: + +1. Scope is comparable-only. +2. Renderable is explicitly deferred. +3. Target scope is `transportRule`, `remoteDomain`, `inboundConnector`. +4. Builders must consume Spec 436 `normalized_payload` only. +5. Raw provider payload, stdout, stderr, transcript, and OperationRun context are forbidden as compare input. +6. Protected/sensitive values compare only through redacted presence/count/safe-state semantics. +7. Existing evidence rows remain immutable. +8. No raw/normalized/hash/provenance field may be mutated. +9. No new OperationRun type. +10. No Exchange invocation OperationRun ownership. +11. No customer output. +12. No reports, Review Packs, or PDFs. +13. No UI/routes/jobs/schedules/listeners. +14. Browser proof remains N/A only because this is backend comparable-only. +15. No certification, restore, renderable, or customer-ready state. + +## Strategic Position + +Current Exchange roadmap context: + +- 429 - Exchange/Teams Source Surface Catalog & Adapter Strategy +- 430 - Exchange PowerShell Adapter Contract Slice 1 +- 431 - Exchange PowerShell Invocation Operation Registration & Execution Gate +- 432 - Exchange PowerShell Production Runner Boundary & Runtime Gate +- 433 - Exchange Credential & Permission Evidence Support +- 434 - Exchange Evidence Capture Adapter & Content-Only Guard +- 435 - Exchange Structured Output & Target Normalizer Readiness +- 436 - Exchange Content-Backed Evidence Promotion Slice 1 +- 437 - Exchange Comparable Promotion Slice 1 +- 438 - Exchange Internal Render Model Slice 1 +- 439 - Teams PowerShell Adapter Contract Slice 1 +- 440 - Teams Invocation / Evidence Slice +- 441 - Exchange/Teams Certified Core Compare Pack +- 442 - M365 Customer Output & Claim Guard +- 443 - M365 Pilot Readiness Gate + +Spec 436 proved selected Exchange types are content-backed. Spec 437 may prove only that selected Exchange types are comparable. + +Spec 437 must not prove selected Exchange types are renderable, certified, restore-ready, customer-ready, or customer-claimable. + +## Prerequisites + +Spec 437 may begin implementation only after Spec 436 is merge-ready or merged into the current base branch. + +Required Spec 436 proof: + +- append-only content-backed evidence exists for `transportRule` +- append-only content-backed evidence exists for `remoteDomain` +- append-only content-backed evidence exists for `inboundConnector` +- evidence owner is `tenant_configuration.capture` +- Exchange invocation operation remains runner truth only +- Spec 435 structured output envelope is used +- Spec 435 target normalizers are used +- Spec 435 hash builder is used +- `GenericPayloadNormalizer` is not used for Exchange evidence +- `ExchangeTeamsComparablePayloadNormalizer` is not used for Spec 436 Exchange PowerShell evidence +- raw provider payload is stored only in evidence storage +- raw stdout/stderr/transcript is not evidence +- OperationRun context is sanitized +- empty collection creates no resource/evidence row +- coverage remains content-backed only +- no compare/render/certification/restore/customer state +- no UI/routes/jobs/schedules/listeners/migrations +- no `tenant_id` ownership truth + +Hard stop if any condition fails or implementation attempts any forbidden expansion. + +## Current Problem + +Spec 436 stores safe content-backed Exchange evidence, including deterministic normalized payloads and payload hashes. + +The platform still cannot safely compare Exchange evidence across snapshots/runs for: + +- `transportRule` +- `remoteDomain` +- `inboundConnector` + +Spec 437 closes that gap by deriving deterministic comparable payloads from normalized content-backed evidence. + +## Business / Product Value + +Spec 437 reduces the risk of false Exchange readiness claims by proving that the first Exchange PowerShell evidence slice can be compared internally without exposing protected configuration or becoming customer-facing output. + +The value is deliberately preparatory: it unlocks later render/certification/customer-output work while preserving the current product truth that Exchange is not yet renderable, certified, restore-ready, or customer-ready. + +## Primary Users / Operators + +- Platform implementers who need a safe comparable payload contract. +- Release reviewers who need proof that Spec 436 evidence can be compared without raw payload leakage. +- Future operator-surface reviewers who will depend on comparable payloads before approving renderable output in Spec 438. + +## Goal + +Implement comparable-only backend promotion for: + +- `transportRule` +- `remoteDomain` +- `inboundConnector` + +Spec 437 must: + +1. Add an Exchange PowerShell comparable payload builder. +2. Add deterministic comparable payloads for all three target types. +3. Use Spec 436 `normalized_payload` as the only compare input. +4. Ignore raw provider payload. +5. Ignore raw stdout/stderr/transcript. +6. Ignore OperationRun context as compare input. +7. Preserve immutable evidence rows. +8. Produce machine-readable comparable payloads. +9. Represent material changes deterministically. +10. Ignore volatile fields. +11. Handle provider ordering deterministically. +12. Redact or omit protected config where required. +13. Compare protected config only through safe presence/count/state semantics. +14. Keep certification false. +15. Keep restore false. +16. Keep customer claim false. +17. Keep renderable deferred. +18. Add no UI/routes/jobs/schedules/listeners/reports/review packs/PDF. + +## Non-Goals + +Spec 437 does not: + +- implement renderable output or internal render models +- implement UI display, Filament pages, Livewire components, routes, navigation, dashboards, reports, Review Pack output, PDF output, or customer output +- implement certification, restore/apply, drift UI, baseline compare UI, or customer-safe summaries +- implement Teams, Exchange Admin API, additional Exchange types, Security & Compliance types, restore types, or customer-facing output +- call Microsoft, Exchange, Graph, or PowerShell +- create evidence rows +- modify `raw_payload`, `normalized_payload`, `payload_hash`, or OperationRun provenance +- create Exchange-specific compare tables +- add `tenant_id` ownership truth +- add migrations, jobs, schedules, listeners, console commands, provider registration changes, or runtime triggers + +If any of these appear, stop and split or amend the spec. + +## Target Scope + +Included resource types: + +- `transportRule` +- `remoteDomain` +- `inboundConnector` + +Explicitly excluded: + +- `outboundConnector` +- `acceptedDomain` +- `organizationConfig` +- `mailboxPlan` +- `sharingPolicy` +- all Teams types +- all Security & Compliance types +- all restore types +- all customer-facing output + +No target expansion. + +## Target Eligibility + +### `transportRule` + +Comparable eligibility: yes. + +Prerequisites: + +- content-backed evidence exists +- stable identity exists through `id`, `sourceId`, `Guid`, or `RuleId` +- normalized payload deterministic +- hash deterministic +- sensitive/protected rule values redacted or omitted + +Spec 437 must compare: + +- stable identity +- enabled/state/mode safe fields +- priority/order if semantically material +- action/condition/exception structures through redacted deterministic representation +- material counts/states +- safe normalized metadata + +Spec 437 must not compare: + +- raw rule text +- raw transport rule patterns +- raw header values if classified sensitive +- raw email addresses if classified sensitive +- raw provider payload +- raw stdout/stderr + +### `remoteDomain` + +Comparable eligibility: yes. + +Prerequisites: + +- content-backed evidence exists with stable source identity +- domain-only/display-only identity remains blocked +- normalized payload deterministic +- hash deterministic +- domain/settings fields classified and redacted where needed + +Spec 437 must compare: + +- stable identity +- safe normalized settings +- redacted field presence +- safe state/count semantics +- default/custom classification if safe +- material configuration state + +Spec 437 must not compare: + +- domain-only identity as stable identity +- display-only identity as stable identity +- raw domain values if classified protected +- raw provider payload +- raw stdout/stderr + +### `inboundConnector` + +Comparable eligibility: yes. + +Prerequisites: + +- content-backed evidence exists with stable connector identity +- normalized payload deterministic +- hash deterministic +- protected config redaction enforced + +Protected config includes IPs, smart hosts, certificate names, comments, routing metadata, TLS settings, and hosts. + +Spec 437 must compare: + +- stable connector identity +- safe state/presence/count semantics +- redacted protected config markers +- safe TLS/routing state categories +- material normalized settings where safe + +Spec 437 must not compare: + +- raw IP ranges +- raw host names +- raw smart hosts +- raw certificate names +- raw comments +- raw routing metadata +- raw provider payload +- raw stdout/stderr + +## Required Final State + +For each target, when content-backed evidence is safe and present: + +| Required state | Value | +|---|---| +| `content_backed_prerequisite` | true | +| `comparable_payload_built` | true | +| `compare_input` | `normalized_payload_only` | +| `raw_payload_used` | false | +| `stdout_stderr_used` | false | +| `operation_run_context_used_as_input` | false | +| `protected_values_redacted_or_omitted` | true | +| `volatile_fields_ignored` | true | +| `provider_ordering_deterministic` | true | +| `material_changes_detectable` | true | +| `derived_comparable_builder_state` | comparable | +| `persisted_evidence_coverage_level` | `content_backed` unchanged | +| `resource_latest_state` | unchanged | +| `resource_type_default_coverage_level` | unchanged | +| `renderable` | false | +| `certified` | false | +| `restore_ready` | false | +| `customer_claimable` | false | + +When evidence is missing or unsafe: + +- no comparable artifact +- no coverage promotion +- specific blocker state +- no renderable/certification/restore/customer state + +## Promotion Semantics + +Preferred model: + +Comparable is derived service/builder output from immutable content-backed evidence. Spec 437 does not persist `coverage_level = comparable` and does not promote persisted resource, evidence, or resource-type coverage state. + +Allowed: + +- build comparable payloads from an explicitly supplied content-backed evidence row's `normalized_payload` +- require `payload_hash`, `evidence_state = content_backed`, `coverage_level = content_backed`, and `capture_outcome = captured` before comparable output is derived +- return an explicit machine blocker when evidence is missing, unsafe, unsupported, or not content-backed +- include `payload_hash` and safe source metadata as non-material provenance anchors when useful + +Forbidden: + +- mutate `raw_payload` +- mutate `normalized_payload` +- mutate `payload_hash` +- mutate `coverage_level` +- mutate OperationRun provenance +- update `TenantConfigurationResource.latest_*` fields +- update `TenantConfigurationResourceType.default_coverage_level` +- persist a comparable artifact, compare table, or Exchange-specific compare row +- replace evidence row +- delete previous evidence +- overwrite content-backed evidence + +If implementation needs persisted comparable state, renderable output, read-model output, or coverage-level promotion, stop and amend or split the spec. + +## Comparable Payload Requirements + +Comparable payload must: + +- use `normalized_payload` only +- be deterministic +- include stable identity +- emit stable identity as a scoped, deployment-keyed HMAC anchor rather than an exact value or unsalted hash +- include resource type +- include `workload = exchange` +- include safe source contract metadata if present in normalized evidence +- include normalizer version when available +- treat source shape metadata that defines compare semantics, including payload shape version and normalizer version, as hash-relevant contract metadata +- include material safe fields +- exclude volatile fields +- exclude raw payload, stdout/stderr/transcript, credentials, OperationRun context, customer interpretation, and UI/report wording +- derive material identity from `normalized_payload.source_identity`; safe evidence metadata may validate provenance but must not override identity or provide material compare fields + +Comparable payload must support: + +- same normalized material payload => same comparable payload +- material change => changed comparable payload +- volatile change => unchanged comparable payload +- provider ordering change => unchanged comparable payload when order is not meaningful +- redacted sensitive value change => safe redacted state/count delta or no exact-value delta + +## Target Comparable Builders + +Recommended conceptual class: + +- `ExchangePowerShellComparablePayloadBuilder` + +Optional target helpers: + +- `ExchangeTransportRuleComparablePayloadBuilder` +- `ExchangeRemoteDomainComparablePayloadBuilder` +- `ExchangeInboundConnectorComparablePayloadBuilder` + +Repo-canonical equivalents are allowed if they preserve all gates. + +### Builder Input + +Allowed input: + +- `resource_type` +- `normalized_payload` +- required `payload_hash` as a 64-character lowercase SHA-256 non-material provenance/hash anchor +- required content-backed evidence metadata only for scope/provenance/blocker context: `evidence_state = content_backed`, `coverage_level = content_backed`, and `capture_outcome = captured` +- canonical identity key only when derived from `normalized_payload.source_identity` + +Forbidden input: + +- `raw_payload` +- raw stdout +- raw stderr +- PowerShell transcript +- OperationRun context +- logs +- report output +- Review Pack output +- UI read model +- externally supplied material identity that overrides `normalized_payload.source_identity` +- evidence metadata as material compare fields + +### Builder Output + +Allowed output: + +- stable identity key +- scoped HMAC identity anchor +- resource type +- workload +- material safe fields +- redacted field presence +- redacted field count +- safe boolean/state fields +- safe enum/state fields +- deterministic collection summaries +- non-material provenance anchors +- normalizer version metadata + +Forbidden output: + +- raw provider object +- raw policy text +- raw IPs +- raw hosts +- raw certificate names +- raw comments +- customer-friendly prose +- risk language +- secure/insecure verdicts +- restore instructions +- certification labels + +## Delta / Compare Semantics + +Spec 437 may introduce a comparator only if repo architecture needs it. + +Recommended conceptual class: + +- `ExchangePowerShellCoverageComparator` + +The comparator must compare comparable payloads, not raw or normalized provider payloads. + +Allowed machine delta categories: + +- `added` +- `removed` +- `changed` +- `unchanged` +- `redacted_value_changed` +- `redacted_presence_changed` +- `redacted_count_changed` + +Forbidden delta categories: + +- `critical_risk` +- `customer_issue` +- `non_compliant` +- `certified_change` +- `restore_required` +- `security_failure` + +No customer-facing interpretation. + +## Redaction / Protected Config + +Never expose in comparable payload: + +- raw provider payload +- raw stdout +- raw stderr +- PowerShell transcript +- credential material +- tokens +- secrets +- authorization headers +- cookies +- raw transport rule patterns +- raw header values if sensitive +- raw domain values if protected +- raw IP ranges +- raw smart hosts +- raw certificate names +- raw connector comments +- raw routing metadata + +Allowed comparable representation: + +- `present = true/false` +- `count = integer` +- `redacted = true` +- `state = safe enum` +- scoped HMAC identity anchor only when safe and non-reversible according to repo policy + +Comparable derivation must fail closed with `unsafe_exact_value` when protected/sensitive comparable sections contain an exact unredacted value that is not explicitly classified as safe for the target type. Exact-value fields classified as safe must also pass field-specific value policy checks, such as boolean-only fields, bounded enum-only fields, or non-negative integer-only fields. + +If exact value comparison would leak protected config, compare only presence/count/safe state or block comparable for that field. + +## Empty Collection Behavior + +Spec 436 empty collection creates no evidence row. + +Therefore Spec 437 must not create: + +- fake comparable artifact +- fake comparable resource +- collection-level comparable proof +- fake empty delta + +Empty collection remains zero summary only: no evidence, no comparable, no renderable. + +If no evidence row exists, no comparable payload exists. + +## Product Surface / Customer Output Safety + +Customer output must remain unchanged. + +Forbidden: + +- customer-safe summary +- customer report +- Review Pack section +- PDF output +- customer route +- customer claim +- customer-ready badge +- certification label +- restore recommendation + +Static tests must prove no customer/report/review-pack/PDF code changed. + +## Data / Migration Impact + +Default: no migration. + +Existing model should be sufficient. + +Forbidden: + +- Exchange-specific compare table +- Exchange-specific render table +- `tenant_id` column +- customer output table +- UI state table +- legacy snapshot table + +If migration is required, stop and amend the spec. + +## Likely Affected Files + +Spec artifacts: + +- `specs/437-exchange-comparable-promotion-slice-1/spec.md` +- `specs/437-exchange-comparable-promotion-slice-1/plan.md` +- `specs/437-exchange-comparable-promotion-slice-1/tasks.md` +- `specs/437-exchange-comparable-promotion-slice-1/checklists/requirements.md` +- `specs/437-exchange-comparable-promotion-slice-1/implementation-report.md` + +Likely new runtime files: + +- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php` +- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCoverageComparator.php` if needed + +Likely new tests: + +- `apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php` +- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php` + +Existing files that may be referenced or minimally changed if repo pattern requires: + +- `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php` +- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellTargetEvidenceNormalizer.php` +- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellHashInputBuilder.php` + +Files that must not change: + +- `apps/platform/routes/**` +- `apps/platform/app/Filament/**` +- `apps/platform/app/Livewire/**` +- `apps/platform/resources/views/**` +- `apps/platform/app/Jobs/**` +- `apps/platform/app/Listeners/**` +- `apps/platform/app/Console/**` +- `apps/platform/database/migrations/**` +- customer report/review-pack/PDF code +- `apps/platform/app/Support/OperationRunType.php` +- `apps/platform/app/Support/OperationCatalog.php` +- live Exchange/Graph/PowerShell runner code +- `apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php` unless the spec is amended with product-surface/browser proof + +## User Scenarios & Testing + +### User Story 1 - Comparable transportRule Payload (Priority: P1) + +As a platform reviewer, I need content-backed `transportRule` evidence to produce a deterministic comparable payload without raw rule text, raw patterns, or raw headers, so future compare work can detect material mail-flow rule changes without leaking protected values. + +**Independent Test**: Build comparable payloads from equivalent Spec 436 normalized `transportRule` evidence and assert stable output; mutate safe material state and assert changed output; mutate volatile/provider ordering only and assert unchanged output. + +### User Story 2 - Comparable remoteDomain Payload With Stable Identity Only (Priority: P1) + +As a platform reviewer, I need content-backed `remoteDomain` evidence to compare only from stable source identity and safe settings, so domain-only or display-only identity cannot become ambiguous comparable proof. + +**Independent Test**: Build comparable payloads from stable Spec 436 normalized `remoteDomain` evidence; assert domain-only/display-only/missing unsafe identity has no comparable payload; assert protected domain/settings values are represented only through safe semantics. + +### User Story 3 - Comparable inboundConnector Payload With Protected Config Redaction (Priority: P1) + +As a platform reviewer, I need content-backed `inboundConnector` evidence to compare routing/TLS state without exposing raw IPs, hosts, smart hosts, certificate names, comments, or routing metadata. + +**Independent Test**: Build comparable payloads from stable Spec 436 normalized `inboundConnector` evidence with protected config and assert only safe presence/count/state markers appear. + +### User Story 4 - Comparable Promotion Guardrails (Priority: P1) + +As a release reviewer, I need Spec 437 to prove no evidence mutation, product surface, customer output, OperationRun type, migration, tenant_id, renderable, certification, or restore expansion, so the comparable-only slice cannot overclaim. + +**Independent Test**: Feature/static tests assert evidence rows are immutable and forbidden file/surface categories remain unchanged. + +### Edge Cases + +- Missing evidence returns no comparable payload and an explicit blocker. +- Empty collection creates no comparable artifact because Spec 436 creates no evidence row. +- Unsafe identity returns no comparable payload. +- Redacted exact protected value changes either produce safe redacted presence/count/state deltas or no exact-value delta. +- Unredacted exact protected/sensitive values in comparable sections block comparable output. +- Volatile normalized fields do not alter comparable payload. +- Provider ordering changes do not alter comparable payload where ordering is not semantically meaningful. +- Existing Spec 436 `raw_payload`, `normalized_payload`, `payload_hash`, `operation_run_id`, source metadata, and previous rows remain unchanged. + +## Requirements + +### Functional Requirements + +- **FR-437-001**: System MUST support comparable payload derivation for exactly `transportRule`, `remoteDomain`, and `inboundConnector`. +- **FR-437-002**: Comparable builders MUST consume only Spec 436 `normalized_payload` as material input; safe evidence metadata and `payload_hash` may be used only for provenance, scope validation, scoped HMAC identity anchors, or blocker context. +- **FR-437-002a**: Comparable builders MUST require `payload_hash`, `evidence_state = content_backed`, `coverage_level = content_backed`, and `capture_outcome = captured`; otherwise they MUST return no comparable payload and an explicit blocker. +- **FR-437-003**: Comparable builders MUST NOT accept or read raw provider payload, raw stdout, raw stderr, PowerShell transcript, OperationRun context, logs, UI read models, report output, or Review Pack output. +- **FR-437-004**: Comparable payloads MUST be deterministic for the same normalized material payload. +- **FR-437-005**: Material safe changes MUST change comparable payloads or produce machine delta output. +- **FR-437-006**: Volatile field changes MUST NOT change comparable payloads. +- **FR-437-007**: Provider ordering changes MUST NOT change comparable payloads when order is not semantically meaningful. +- **FR-437-008**: Protected exact values MUST be omitted, redacted, or represented only through safe presence/count/state semantics; unexpected exact unredacted values in protected/sensitive comparable sections MUST block comparable output. +- **FR-437-009**: `transportRule` comparable payloads MUST exclude raw rule text, raw patterns, raw sensitive header values, and raw email addresses if classified sensitive. +- **FR-437-010**: `remoteDomain` comparable payloads MUST block domain-only/display-only identity and must not expose protected raw domain values. +- **FR-437-011**: `inboundConnector` comparable payloads MUST exclude raw IP ranges, hosts, smart hosts, certificate names, comments, and routing metadata. +- **FR-437-012**: Empty or missing evidence MUST produce no comparable artifact. +- **FR-437-013**: Unsafe identity or blocked target MUST produce no comparable artifact. +- **FR-437-014**: Existing evidence rows MUST remain immutable; implementation MUST NOT mutate `raw_payload`, `normalized_payload`, `payload_hash`, `operation_run_id`, source metadata, or prior evidence rows. +- **FR-437-015**: No renderable, certification, restore-ready, customer-ready, customer-claimable, report, Review Pack, or PDF state may be added. +- **FR-437-016**: No route, Filament page/resource/widget, Livewire component, Blade view, navigation, asset, global search, job, schedule, listener, console command, migration, new OperationRun type, `tenant_id`, or Exchange-specific compare table may be added. +- **FR-437-017**: Implementation MUST NOT use `GenericPayloadNormalizer` or `ExchangeTeamsComparablePayloadNormalizer` as the Spec 436 Exchange PowerShell comparable input path. +- **FR-437-018**: If a comparator is added, it MUST compare comparable payloads only and use machine categories: `added`, `removed`, `changed`, `unchanged`, `redacted_value_changed`, `redacted_presence_changed`, and `redacted_count_changed`. +- **FR-437-019**: Comparator output MUST NOT include customer-facing risk, compliance, certification, restore, or security verdict categories. +- **FR-437-020**: Implementation MUST NOT persist comparable promotion by changing `TenantConfigurationResourceEvidence.coverage_level`, `TenantConfigurationResource.latest_*`, or `TenantConfigurationResourceType.default_coverage_level`; comparable remains derived builder/service output for this slice. + +### Non-Functional Requirements + +- **NFR-437-001**: Comparable derivation MUST be deterministic across equivalent normalized payloads. +- **NFR-437-002**: Comparable derivation MUST be side-effect free for evidence rows and OperationRun provenance. +- **NFR-437-003**: Comparable derivation MUST remain local/in-memory service output for this slice and MUST NOT persist comparable output or coverage-level promotion. +- **NFR-437-004**: Comparable derivation MUST not perform provider calls, Graph calls, Exchange calls, PowerShell execution, queue dispatch, scheduling, or listener-triggered work. +- **NFR-437-005**: Tests MUST use the narrowest honest Unit/Feature lanes and MUST keep Browser proof `N/A - no rendered UI surface changed`. + +## Assumptions + +- Spec 436 is merged into the current base or is merge-ready before implementation begins. +- Spec 436 normalized payload shape remains the source truth for the three target types. +- Existing Coverage v2 evidence tables are sufficient; no migration or Exchange-specific compare table is needed. +- Existing Spec 436 empty-collection behavior remains unchanged: no evidence row means no comparable payload. +- Spec 438 will handle renderable concerns separately and must not be folded into Spec 437. + +## Risks + +- Protected Exchange configuration could leak if comparable payloads include exact values instead of safe presence/count/state semantics. +- Evidence immutability could be weakened if comparable derivation mutates or overwrites source evidence rows. +- Product overclaim could appear if comparable state is treated as renderable, certified, restore-ready, customer-ready, or customer-claimable. +- Scope could expand by reusing the older generic Exchange/Teams comparable/renderable path instead of the corrected Spec 436 normalized-payload-only input path. +- A persistence shortcut could create an Exchange mini-platform, compare table, migration, or tenant_id ownership truth. + +## Open Questions + +None blocking. + +Resolved implementation decisions: + +- Builder API remains narrow and does not introduce a new DTO/framework; it fails closed unless the caller supplies explicit content-backed provenance (`payload_hash`, `evidence_state`, `coverage_level`, and `capture_outcome`). +- Stable identity anchors use scoped app-keyed HMAC values, not exact values or unsalted SHA-256 hashes, because Exchange identities can be low-entropy or guessable in a SaaS setting. +- Comparable hash excludes non-material Evidence/OperationRun provenance, but includes compare contract metadata that changes interpretation of material payloads, especially resource type, source surface, command contract metadata, payload shape version, and normalizer version. This follows Spec 435 hash-input precedent and prevents comparing values normalized under different semantics as if they were equivalent. +- If implementation discovers that existing Coverage v2 architecture cannot represent comparable output without new persistence, stop and amend the spec instead of adding a migration or compare table. + +## Success Criteria + +### Measurable Outcomes + +- **SC-437-001**: Focused Spec 437 unit tests prove deterministic comparable payloads for all three targets. +- **SC-437-002**: Focused Spec 437 feature/static tests prove evidence immutability and no forbidden product/customer/trigger/persistence expansion. +- **SC-437-003**: Regression tests for Specs 430-436 and relevant Coverage v2/Entra compare guardrails pass or exact unrelated failures are documented. +- **SC-437-004**: Pint and `git diff --check` pass. +- **SC-437-005**: Implementation report records target matrix, safety matrix, tests, browser N/A, deployment impact, and deferred work. + +## Acceptance Criteria + +Spec 437 passes only if: + +- scope is exactly `transportRule`, `remoteDomain`, `inboundConnector` +- scope remains comparable-only +- `normalized_payload` is the only compare input +- raw payload, stdout/stderr/transcript, OperationRun context, logs, report output, and Review Pack output are not compare inputs +- comparable payloads are deterministic and machine-readable +- stable identity field is included +- stable identity exact value is not emitted; identity matching uses a scoped HMAC anchor +- stable identity is derived from `normalized_payload.source_identity`, not from externally supplied metadata +- content-backed provenance gates require a valid SHA-256 `payload_hash`, `evidence_state = content_backed`, `coverage_level = content_backed`, and `capture_outcome = captured` +- exact safe fields pass target-specific value policies before they can appear in comparable output +- material safe fields are included +- volatile fields are excluded +- provider ordering is deterministic +- protected values are redacted/omitted +- no customer-facing prose, risk verdict, or certification label appears +- evidence payload/provenance is immutable +- persisted evidence/resource/resource-type coverage state remains unchanged and no `coverage_level = comparable` promotion is written +- missing/empty/unsafe evidence creates no comparable artifact +- no renderable/certified/restore/customer/product promotion appears +- no UI/routes/jobs/schedules/listeners/migrations/tenant_id/OperationRun type/Exchange-specific compare table appears +- focused unit/feature tests and regressions pass or failures are documented + +## Final Candidate Gate + +PASS requires: + +- Spec 436 is merge-ready or merged into the current base. +- Only `transportRule`, `remoteDomain`, and `inboundConnector` are in scope. +- Spec 437 remains comparable-only. +- Renderable is deferred. +- Comparable payloads use `normalized_payload` only. +- Raw payload is not compare input. +- Raw stdout/stderr/transcript are not compare input. +- OperationRun context is not compare input. +- Comparable payloads are deterministic. +- Material changes are detectable. +- Volatile changes are ignored. +- Protected values are redacted/omitted. +- Evidence rows remain immutable. +- No renderable, certification, restore, customer-ready/customer-claimable state appears. +- No UI/routes/jobs/schedules/listeners are added. +- No migration is added. +- No `tenant_id` ownership truth is introduced. +- No Exchange-specific compare table is introduced. +- Focused tests and regressions pass. + +PASS WITH CONDITIONS is allowed only for bounded follow-ups that do not weaken comparable determinism, redaction, evidence immutability, no-renderable posture, no-customer-output posture, no-product-surface posture, ownership, or no-mini-platform posture. + +FAIL if renderable output is added; raw payload/stdout/stderr/transcript/OperationRun context is used as compare input; protected config leaks; evidence payload/provenance is mutated; certification/restore/customer claims appear; UI/routes/jobs/schedules/listeners are added; migration is added without amendment; `tenant_id` ownership truth is introduced; Exchange-specific compare table appears without approval; or tests do not prove comparable safety. + +## Implementation Report Requirements + +`implementation-report.md` must include: + +- Candidate gate result +- Branch and HEAD +- Dirty state before/after +- Files changed +- Spec 436 prerequisite proof +- Target comparable outcome matrix +- Comparable input proof +- Comparable output proof +- Evidence immutability proof +- Redaction proof +- Determinism proof +- Delta proof if comparator added +- No raw payload proof +- No OperationRun context input proof +- No persisted coverage-level promotion proof +- No renderable proof +- No certification/restore/customer proof +- No UI/route/job/schedule/listener proof +- No tenant_id proof +- No migration proof +- Tests run +- Deferred work + +Required target matrix: + +| Type | Content-backed prerequisite | Comparable? | Renderable? | Blocker | +|---|---|---|---|---| +| `transportRule` | Yes | Pending implementation proof | No | Pending implementation proof | +| `remoteDomain` | Yes | Pending implementation proof | No | Pending implementation proof | +| `inboundConnector` | Yes | Pending implementation proof | No | Pending implementation proof | + +Required safety matrix: + +| Area | State | +|---|---| +| Raw payload as input | No | +| Normalized payload as input | Yes | +| OperationRun context as input | No | +| Protected exact values exposed | No | +| Renderable | No | +| Certification | No | +| Restore | No | +| Customer output | No | +| UI | No | +| Jobs/Schedules/Listeners | No | +| Migration | No | + +## Follow-up Spec Candidates + +- Spec 438 - Exchange Internal Render Model Slice 1. Requires its own preflight because renderable output increases Product Surface/read-model/UI/customer-claim risk. +- Teams PowerShell adapter and evidence slices remain separate. +- Exchange/Teams certified compare pack remains separate. +- M365 customer output and claim guard remains separate. diff --git a/specs/437-exchange-comparable-promotion-slice-1/tasks.md b/specs/437-exchange-comparable-promotion-slice-1/tasks.md new file mode 100644 index 00000000..0d1c0623 --- /dev/null +++ b/specs/437-exchange-comparable-promotion-slice-1/tasks.md @@ -0,0 +1,194 @@ +# Tasks: Spec 437 - Exchange Comparable Promotion Slice 1 + +**Input**: Design documents from `specs/437-exchange-comparable-promotion-slice-1/` +**Prerequisites**: [spec.md](./spec.md), [plan.md](./plan.md), [checklists/requirements.md](./checklists/requirements.md) +**Tests**: Required. Backend runtime behavior changes must be covered with Pest unit/feature tests. Browser proof is `N/A - no rendered UI surface changed`. + +## Test Governance Checklist + +- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior. +- [x] New or changed tests stay in Unit/Feature families; any heavy-governance or browser addition is explicit. +- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default. +- [x] Planned validation commands cover the change without pulling in unrelated lane cost. +- [x] Browser proof is explicitly `N/A - no rendered UI surface changed`. +- [x] Human Product Sanity is explicitly `N/A - no product surface changed`. +- [x] No material budget, baseline, trend, or escalation note is required unless implementation expands scope. + +## Phase 1 - Preflight + +**Purpose**: Prove the implementation branch and prerequisites are safe before runtime work. + +- [x] T001 Capture current branch, HEAD, and `git status --short` in `specs/437-exchange-comparable-promotion-slice-1/implementation-report.md`. +- [x] T002 Confirm Spec 436 PASS/merge-ready or merged state from `specs/436-exchange-content-backed-evidence-promotion-slice-1/implementation-report.md`. +- [x] T003 Confirm target scope is exactly `transportRule`, `remoteDomain`, and `inboundConnector`. +- [x] T004 Confirm comparable-only scope and renderable deferred. +- [x] T005 Confirm no UI/routes/jobs/schedules/listeners/commands/migrations/customer output are in scope. +- [x] T006 Confirm no `tenant_id` ownership truth, Exchange-specific compare table, or new OperationRun type is in scope. +- [x] T007 Confirm hard-gate skills have no stop condition: evidence immutability, OperationRun context, customer output, product surface, and TCM/Coverage v2 guard. + +**Checkpoint**: Stop if Spec 436 proof is missing or any forbidden expansion is required. + +## Phase 2 - Comparable Builder Foundation + +**Purpose**: Add normalized-payload-only comparable derivation. + +- [x] T008 Add `apps/platform/tests/Unit/Support/TenantConfiguration/Spec437ExchangeComparablePayloadBuilderTest.php` with failing tests for normalized-payload-only input. +- [x] T009 Add `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellComparablePayloadBuilder.php`. +- [x] T010 Ensure the builder accepts resource type, Spec 436 `normalized_payload`, required valid SHA-256 `payload_hash`, required content-backed/captured provenance metadata, and no raw payload/stdout/stderr/transcript/context arguments; stable material identity must be derived from `normalized_payload.source_identity`, not externally supplied metadata. +- [x] T011 Ensure unsupported resource types return no comparable payload or explicit blocker. +- [x] T012 Ensure builder output includes workload `exchange`, resource type, stable identity field plus scoped HMAC anchor derived from `normalized_payload.source_identity`, value-policy-validated safe material fields, redacted markers/counts/states, and safe normalizer/source metadata. +- [x] T013 Ensure builder output excludes raw provider object, raw stdout/stderr/transcript, credentials, OperationRun context, customer prose, risk verdicts, restore instructions, and certification labels. + +**Checkpoint**: Builder exists and cannot consume forbidden inputs by API shape or tests. + +## Phase 3 - transportRule Comparable Payload + +**Purpose**: Prove safe deterministic transport rule comparable payloads. + +- [x] T014 Add tests for `transportRule` stable identity and deterministic comparable output. +- [x] T015 Implement `transportRule` mapping for enabled/state/mode safe fields. +- [x] T016 Include priority/order when semantically material. +- [x] T017 Represent conditions/actions/exceptions through redacted deterministic structures. +- [x] T018 Exclude raw rule text, raw patterns, sensitive raw header values, sensitive raw email addresses, raw provider payload, and raw stdout/stderr. +- [x] T019 Add tests for material change, volatile-only change, and provider ordering change. + +**Checkpoint**: `transportRule` comparable payload is safe and deterministic. + +## Phase 4 - remoteDomain Comparable Payload + +**Purpose**: Prove safe deterministic remote domain comparable payloads. + +- [x] T020 Add tests for stable source identity and deterministic `remoteDomain` comparable output. +- [x] T021 Block domain-only and display-only identity from comparable payload creation, including case and separator variants such as `domain_name` and `DISPLAY_NAME`. +- [x] T022 Represent safe settings and default/custom classification only where safe. +- [x] T023 Represent protected domain/settings values through redacted presence/count/state semantics. +- [x] T024 Exclude raw domain values if protected, raw provider payload, and raw stdout/stderr. +- [x] T025 Add tests for material change, volatile-only change, redacted-count hash change, missing/non-content-backed evidence, and unsafe identity. + +**Checkpoint**: `remoteDomain` comparable payload is safe and deterministic. + +## Phase 5 - inboundConnector Comparable Payload + +**Purpose**: Prove safe deterministic inbound connector comparable payloads. + +- [x] T026 Add tests for stable connector identity and deterministic `inboundConnector` comparable output. +- [x] T027 Represent routing/TLS/protected config through safe presence/count/state categories. +- [x] T028 Exclude raw IPs, hosts, smart hosts, certificate names, comments, routing metadata, raw provider payload, and raw stdout/stderr. +- [x] T029 Add tests for protected config redaction, no exact-value exposure, and malformed exact safe field blockers. +- [x] T030 Add tests for material change, volatile-only change, provider ordering change, redacted-count hash change, missing/non-content-backed evidence, and unsafe identity. + +**Checkpoint**: `inboundConnector` comparable payload is safe and deterministic. + +## Phase 6 - Determinism and Optional Delta Semantics + +**Purpose**: Prove comparable behavior is stable and machine-oriented. + +- [x] T031 Add tests proving same normalized material produces the same comparable payload. +- [x] T032 Add tests proving material changes change comparable payload. +- [x] T033 Add tests proving volatile changes do not change comparable payload. +- [x] T034 Add tests proving provider ordering does not change comparable payload when order is not meaningful. +- [x] T035 If repo architecture requires comparison output, add `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCoverageComparator.php`. Implementation note: N/A - repository architecture did not require a comparator for this comparable-payload-only slice. +- [x] T036 If comparator is added, add tests for only machine categories: `added`, `removed`, `changed`, `unchanged`, `redacted_value_changed`, `redacted_presence_changed`, `redacted_count_changed`. Implementation note: N/A - no comparator added. +- [x] T037 If comparator is added, test that customer-facing categories such as `critical_risk`, `non_compliant`, `certified_change`, `restore_required`, and `security_failure` never appear. Implementation note: N/A - no comparator added. + +**Checkpoint**: Comparable semantics are deterministic; delta semantics are safe if added. + +## Phase 7 - Evidence Immutability and Empty/Missing/Unsafe Evidence + +**Purpose**: Prove comparable promotion does not rewrite source evidence. + +- [x] T038 Add `apps/platform/tests/Feature/TenantConfiguration/Spec437ExchangeComparablePromotionFeatureTest.php`. +- [x] T039 Test comparable derivation does not mutate `raw_payload`. +- [x] T040 Test comparable derivation does not mutate `normalized_payload`. +- [x] T041 Test comparable derivation does not mutate `payload_hash`. +- [x] T042 Test comparable derivation does not mutate `operation_run_id` or OperationRun provenance. +- [x] T043 Test comparable derivation does not mutate persisted `coverage_level`, `latest_evidence_state`, `latest_claim_state`, `latest_payload_hash`, `TenantConfigurationResourceType.default_coverage_level`, or prior evidence rows. +- [x] T044 Test empty collection has no comparable artifact because no Spec 436 evidence row exists. +- [x] T045 Test missing evidence has no comparable artifact. +- [x] T046 Test unsafe identity and blocked target have no comparable artifact. + +**Checkpoint**: Comparable artifacts require safe content-backed evidence and evidence rows remain immutable. + +## Phase 8 - Redaction and No Raw Input Guards + +**Purpose**: Prove protected values and forbidden input sources cannot leak. + +- [x] T047 Add tests proving raw provider payload is not comparable input. +- [x] T048 Add tests proving raw stdout/stderr/transcript are impossible compare inputs. +- [x] T049 Add tests proving OperationRun context is not compare input. +- [x] T050 Add tests proving credential material, tokens, authorization headers, cookies, secrets, unexpected exact protected/sensitive values, malformed exact safe values, and invalid payload hashes do not appear in comparable payloads. +- [x] T051 Add tests proving inbound connector raw IP ranges, hosts, smart hosts, certificate names, comments, and routing metadata do not appear. +- [x] T052 Add tests proving remote domain protected values do not appear. +- [x] T053 Add tests proving transport rule sensitive values do not appear. +- [x] T054 Test `GenericPayloadNormalizer` and `ExchangeTeamsComparablePayloadNormalizer` are not used for Spec 436 Exchange PowerShell comparable derivation. + +**Checkpoint**: Comparable payloads do not leak protected config or forbidden input data. + +## Phase 9 - No Product Promotion / No Trigger Surface + +**Purpose**: Prove Spec 437 remains comparable-only. + +- [x] T055 Add or update feature/static tests proving no renderable state, render model, persisted comparable coverage-level promotion, or read-model output is added. +- [x] T056 Add or update feature/static tests proving no certification, restore, customer-ready, customer-claimable, customer-safe summary, report, Review Pack, or PDF output is added. +- [x] T057 Add or update feature/static tests proving no route, Filament page/resource/widget, Livewire component, Blade view, navigation, asset/global search change, or `CoverageV2ReadinessReadModel` rendered output is added. +- [x] T058 Add or update feature/static tests proving no job, schedule, listener, console command, provider registration change, or runtime trigger is added. +- [x] T059 Add or update feature/static tests proving no migration, `tenant_id`, Exchange-specific compare table, legacy shim, fallback reader, or Exchange mini-platform is added. +- [x] T060 Record browser proof as `N/A - no rendered UI surface changed`. + +**Checkpoint**: No rendered or background trigger surface changed. + +## Phase 10 - Regression Tests + +**Purpose**: Prove adjacent Coverage v2 and Exchange readiness behavior remains intact. + +- [x] T061 Run focused Spec 437 unit/feature tests. +- [x] T062 Run Spec 436 regression. +- [x] T063 Run Spec 435 regression. +- [x] T064 Run Spec 434 regression. +- [x] T065 Run Spec 433 regression. +- [x] T066 Run Spec 432/431/430 regression. +- [x] T067 Run Spec 421/425 Entra comparable/certified guard regression if available. +- [x] T068 Run Spec 415/417/419/420/426/427 Coverage v2/evidence/identity/source-contract regression. +- [x] T069 Run `ProviderCapabilityRegistryTest` or equivalent. + +**Checkpoint**: Existing readiness/capture/identity/evidence behavior remains intact. + +## Phase 11 - Validation and Close-Out + +**Purpose**: Complete final checks and implementation report. + +- [x] T070 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`. +- [x] T071 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec437 --compact`. +- [x] T072 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec436|Spec435|Spec434|Spec433|Spec432|Spec431|Spec430' --compact`. +- [x] T073 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec421|Spec425|Spec415|Spec417|Spec419|Spec420|Spec426|Spec427|ProviderCapabilityRegistry' --compact`. +- [x] T074 Run `git diff --check`. +- [x] T075 Run `git status --short`. +- [x] T076 Complete `specs/437-exchange-comparable-promotion-slice-1/implementation-report.md` with candidate gate result, branch/HEAD, dirty state before/after, files changed, Spec 436 prerequisite proof, target comparable matrix, safety matrix, no persisted coverage-level promotion proof, tests run, browser N/A, deployment impact, and deferred work. + +**Checkpoint**: Validation passes or exact failures are documented. + +## Dependencies & Execution Order + +- Phase 1 blocks all implementation. +- Phase 2 blocks target-specific comparable payload work. +- Phases 3-5 may proceed in parallel after Phase 2 if separate files/helpers are used. +- Phase 6 depends on Phases 3-5. +- Phases 7-9 depend on builder behavior. +- Phase 10 depends on implementation and focused tests. +- Phase 11 is final. + +## Hard Stops + +Stop and amend or split if implementation requires any of: + +- renderable output +- raw payload/stdout/stderr/transcript/OperationRun context as compare input +- protected config exact-value exposure +- evidence payload/provenance mutation +- persisted `coverage_level = comparable` promotion or resource/resource-type coverage state mutation +- certification/restore/customer claims +- UI/routes/jobs/schedules/listeners/commands/provider registration +- migration or Exchange-specific compare table +- `tenant_id` ownership truth +- new OperationRun type +- additional Exchange or Teams target types