Compare commits
2 Commits
30a6733f78
...
f8c298632b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c298632b | ||
|
|
bcec737cd4 |
@ -0,0 +1,674 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\TenantConfiguration;
|
||||||
|
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use BackedEnum;
|
||||||
|
|
||||||
|
final class ExchangePowerShellComparablePayloadBuilder
|
||||||
|
{
|
||||||
|
public const string PAYLOAD_SHAPE_VERSION = 'exchange-powershell-comparable-payload-v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const SUPPORTED_RESOURCE_TYPES = [
|
||||||
|
'transportRule',
|
||||||
|
'remoteDomain',
|
||||||
|
'inboundConnector',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
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<string, array<string, array<string, string|array{enum: list<string>}>>>
|
||||||
|
*/
|
||||||
|
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<string, list<string>>
|
||||||
|
*/
|
||||||
|
private const PARTIAL_EXACT_VALUE_SECTIONS = [
|
||||||
|
'transportRule' => [
|
||||||
|
'material',
|
||||||
|
'rule_order',
|
||||||
|
],
|
||||||
|
'inboundConnector' => [
|
||||||
|
'material',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $normalizedPayload
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
* @return array{comparable: bool, blocker: string|null, resource_type: string, payload: array<string, mixed>|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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $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<string, mixed> $normalizedPayload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $normalizedPayload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $normalizedPayload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $normalizedPayload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $normalizedPayload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $normalizedPayload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $metadata
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, string|array{enum: list<string>}> $policies
|
||||||
|
* @return string|array{enum: list<string>}|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<string>} $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<string>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,273 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\TenantConfigurationResource;
|
||||||
|
use App\Models\TenantConfigurationResourceEvidence;
|
||||||
|
use App\Models\TenantConfigurationResourceType;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellComparablePayloadBuilder;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellTargetEvidenceNormalizer;
|
||||||
|
use App\Services\TenantConfiguration\ResourceTypeRegistry;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use App\Support\TenantConfiguration\ClaimState;
|
||||||
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
use App\Support\TenantConfiguration\IdentityState;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
app(ResourceTypeRegistry::class)->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);
|
||||||
|
}
|
||||||
@ -0,0 +1,601 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellComparablePayloadBuilder;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
|
||||||
|
use App\Services\TenantConfiguration\ExchangePowerShellTargetEvidenceNormalizer;
|
||||||
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
||||||
|
use App\Support\TenantConfiguration\CoverageLevel;
|
||||||
|
use App\Support\TenantConfiguration\EvidenceState;
|
||||||
|
|
||||||
|
it('Spec437 builds deterministic comparable payloads from normalized payloads for each Exchange target', function (
|
||||||
|
string $canonicalType,
|
||||||
|
array $normalizedPayload,
|
||||||
|
array $expectedPaths,
|
||||||
|
array $forbiddenValues,
|
||||||
|
): void {
|
||||||
|
$normalizedPayload['raw_payload'] = ['secret' => '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);
|
||||||
|
}
|
||||||
@ -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.
|
||||||
@ -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.
|
||||||
|
```
|
||||||
283
specs/437-exchange-comparable-promotion-slice-1/plan.md
Normal file
283
specs/437-exchange-comparable-promotion-slice-1/plan.md
Normal file
@ -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.
|
||||||
1024
specs/437-exchange-comparable-promotion-slice-1/spec.md
Normal file
1024
specs/437-exchange-comparable-promotion-slice-1/spec.md
Normal file
File diff suppressed because it is too large
Load Diff
194
specs/437-exchange-comparable-promotion-slice-1/tasks.md
Normal file
194
specs/437-exchange-comparable-promotion-slice-1/tasks.md
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user