Spec 435: Exchange structured output readiness (#502)

## Summary

- Add Spec 435 backend-only Exchange structured output envelope and target normalizer readiness helpers for transportRule, remoteDomain, and inboundConnector.
- Add deterministic hash input/readiness handling and no-promotion guard coverage.
- Add Spec 435 spec, plan, tasks, checklist, and implementation report artifacts.

## Validation

- git diff --cached --check
- cd apps/platform && php artisan test --filter=Spec435 --compact

## Product Surface / Deployment

- Livewire v4 compliance unchanged.
- Filament provider registration unchanged under apps/platform/bootstrap/providers.php.
- Global search unchanged; no resources added or modified.
- Destructive/high-impact actions: none.
- Asset strategy: none; no filament:assets requirement for this slice.
- Browser proof: N/A - no rendered UI surface changed.
- Deployment impact: no env vars, migrations, queues, scheduler, storage, assets, or Dokploy runtime behavior changes.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #502
This commit is contained in:
ahmido 2026-07-08 14:33:23 +00:00
parent a23131cdbc
commit a09cc6ca8d
17 changed files with 3084 additions and 0 deletions

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class ExchangeInboundConnectorEvidenceNormalizer extends ExchangePowerShellTargetEvidenceNormalizer
{
public function resourceType(): string
{
return 'inboundConnector';
}
public function normalizerVersion(): string
{
return 'exchange-inbound-connector-readiness-v1';
}
/**
* @return list<string>
*/
protected function materialFields(): array
{
return [
'Guid',
'ConnectorId',
'Identity',
'Name',
'DisplayName',
'ConnectorType',
'Enabled',
'RequireTls',
'SenderDomains',
'SenderIPAddresses',
'TlsSenderCertificateName',
'AssociatedAcceptedDomains',
'RestrictDomainsToIPAddresses',
'CloudServicesMailEnabled',
'ConnectorSource',
'SmartHosts',
'Comment',
];
}
/**
* @return list<list<string>>
*/
protected function identityAliasGroups(): array
{
return [
['id', 'sourceId'],
['Guid', 'ConnectorId', 'Identity'],
];
}
protected function normalizePayload(array $payload, array $identity): array
{
$normalized = parent::normalizePayload($payload, $identity);
$normalized['routing'] = $this->settingGroup($payload, [], [
'ConnectorType',
'Enabled',
'RequireTls',
'SenderDomains',
'SenderIPAddresses',
'TlsSenderCertificateName',
'AssociatedAcceptedDomains',
'RestrictDomainsToIPAddresses',
'CloudServicesMailEnabled',
'ConnectorSource',
'SmartHosts',
'Comment',
]);
return $this->sortAssociative($normalized);
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class ExchangePowerShellEvidenceNormalizer
{
/**
* @var list<string>
*/
public const INCLUDED_TYPES = [
'transportRule',
'remoteDomain',
'inboundConnector',
];
public function __construct(
private readonly ExchangeTransportRuleEvidenceNormalizer $transportRules,
private readonly ExchangeRemoteDomainEvidenceNormalizer $remoteDomains,
private readonly ExchangeInboundConnectorEvidenceNormalizer $inboundConnectors,
) {}
public function evaluate(ExchangePowerShellStructuredOutputEnvelope $envelope): ExchangePowerShellNormalizerReadiness
{
return match ($envelope->resourceType) {
'transportRule' => $this->transportRules->evaluate($envelope),
'remoteDomain' => $this->remoteDomains->evaluate($envelope),
'inboundConnector' => $this->inboundConnectors->evaluate($envelope),
default => ExchangePowerShellNormalizerReadiness::blocked(
resourceType: $envelope->resourceType,
definition: [
'resource_type' => $envelope->resourceType,
'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE,
'included_types' => self::INCLUDED_TYPES,
],
blockers: ['blocked_target_not_ready'],
itemCount: $envelope->itemCount,
),
};
}
/**
* @return array<string, array<string, mixed>>
*/
public function definitions(): array
{
return [
'transportRule' => $this->transportRules->definition(),
'remoteDomain' => $this->remoteDomains->definition(),
'inboundConnector' => $this->inboundConnectors->definition(),
];
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class ExchangePowerShellHashInputBuilder
{
/**
* @param array<string, mixed> $normalizedPayload
* @return array<string, mixed>
*/
public function build(
string $resourceType,
string $sourceSurface,
string $commandContractName,
string $commandContractVersion,
string $payloadShapeVersion,
string $normalizerVersion,
array $normalizedPayload,
): array {
return $this->canonicalize([
'resource_type' => $resourceType,
'source_surface' => $sourceSurface,
'command_contract_name' => $commandContractName,
'command_contract_version' => $commandContractVersion,
'payload_shape_version' => $payloadShapeVersion,
'normalizer_version' => $normalizerVersion,
'normalized_payload' => $normalizedPayload,
]);
}
/**
* @param array<string, mixed> $hashInput
*/
public function hash(array $hashInput): string
{
return hash('sha256', $this->canonicalJson($hashInput));
}
/**
* @param array<string, mixed> $hashInput
*/
public function canonicalJson(array $hashInput): string
{
return json_encode($this->canonicalize($hashInput), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
private function canonicalize(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map(fn (mixed $item): mixed => $this->canonicalize($item), $value);
}
$normalized = [];
foreach ($value as $key => $nestedValue) {
if ($nestedValue === null) {
continue;
}
$normalized[(string) $key] = $this->canonicalize($nestedValue);
}
ksort($normalized, SORT_NATURAL | SORT_FLAG_CASE);
return $normalized;
}
}

View File

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final readonly class ExchangePowerShellNormalizerReadiness
{
public const string STATE_READY = 'ready';
public const string STATE_BLOCKED = 'blocked';
/**
* @param list<string> $blockers
* @param array<string, mixed> $definition
* @param list<array<string, mixed>> $normalizedPreviews
* @param list<string> $hashPreviews
*/
private function __construct(
public string $resourceType,
public bool $ready,
public array $blockers,
public array $definition,
public array $normalizedPreviews,
public array $hashPreviews,
public string $normalizerReadinessState,
public string $redactionReadinessState,
public string $identityReadinessState,
public string $hashReadinessState,
public int $itemCount,
public bool $emptyCollection,
) {}
/**
* @param array<string, mixed> $definition
* @param list<array<string, mixed>> $normalizedPreviews
* @param list<string> $hashPreviews
*/
public static function ready(
string $resourceType,
array $definition,
array $normalizedPreviews,
array $hashPreviews,
int $itemCount,
bool $emptyCollection = false,
): self {
return new self(
resourceType: $resourceType,
ready: true,
blockers: [],
definition: $definition,
normalizedPreviews: $normalizedPreviews,
hashPreviews: $hashPreviews,
normalizerReadinessState: self::STATE_READY,
redactionReadinessState: self::STATE_READY,
identityReadinessState: self::STATE_READY,
hashReadinessState: self::STATE_READY,
itemCount: max(0, $itemCount),
emptyCollection: $emptyCollection,
);
}
/**
* @param array<string, mixed> $definition
* @param list<string> $blockers
*/
public static function blocked(string $resourceType, array $definition, array $blockers, int $itemCount = 0): self
{
$blockers = array_values(array_unique(array_filter(
array_map(static fn (mixed $blocker): string => is_string($blocker) ? trim($blocker) : '', $blockers),
static fn (string $blocker): bool => $blocker !== '',
)));
return new self(
resourceType: $resourceType,
ready: false,
blockers: $blockers !== [] ? $blockers : ['blocked_target_not_ready'],
definition: $definition,
normalizedPreviews: [],
hashPreviews: [],
normalizerReadinessState: self::STATE_BLOCKED,
redactionReadinessState: self::STATE_BLOCKED,
identityReadinessState: self::STATE_BLOCKED,
hashReadinessState: self::STATE_BLOCKED,
itemCount: max(0, $itemCount),
emptyCollection: false,
);
}
/**
* @return array<string, mixed>
*/
public function safeOperationContext(): array
{
return [
'resource_type' => $this->resourceType,
'normalizer_readiness_state' => $this->normalizerReadinessState,
'redaction_readiness_state' => $this->redactionReadinessState,
'identity_readiness_state' => $this->identityReadinessState,
'hash_readiness_state' => $this->hashReadinessState,
'item_count' => $this->itemCount,
'empty_collection' => $this->emptyCollection,
'blockers' => $this->blockers,
];
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
...$this->safeOperationContext(),
'ready' => $this->ready,
'definition' => $this->definition,
'normalized_previews' => $this->normalizedPreviews,
'hash_previews' => $this->hashPreviews,
];
}
}

View File

@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final readonly class ExchangePowerShellStructuredOutputEnvelope
{
public const string SHAPE_STRUCTURED_COLLECTION = 'structured_collection';
public const string SHAPE_STRUCTURED_EMPTY_COLLECTION = 'structured_empty_collection';
public const string SHAPE_BLOCKED_UNKNOWN = 'unknown_blocked_output';
public const string OUTPUT_GUARD_ACCEPTED = 'accepted';
public const string OUTPUT_GUARD_BLOCKED = 'blocked';
public const string READINESS_PENDING = 'pending';
public const string READINESS_READY = 'ready';
public const string READINESS_BLOCKED = 'blocked';
/**
* @var list<string>
*/
private const FORBIDDEN_ITEM_KEY_FRAGMENTS = [
'authorization',
'clientsecret',
'cookie',
'credential',
'password',
'providersecret',
'rawproviderpayload',
'rawshellstderr',
'rawshellstdout',
'rawstderr',
'rawstdout',
'rawtranscript',
'refreshtoken',
'secret',
'stderr',
'stdout',
'token',
'transcript',
];
/**
* @param list<array<string, mixed>> $items
*/
private function __construct(
public string $resourceType,
public string $commandContractName,
public string $commandContractVersion,
public string $sourceSurface,
public string $runnerMode,
public string $shapeState,
public array $items,
public int $itemCount,
public bool $emptyCollection,
public bool $warningsClassified,
public string $outputGuardState,
public string $normalizerReadinessState,
public string $redactionReadinessState,
public string $identityReadinessState,
public ?string $readinessBlocker = null,
) {}
public static function fromInvocationResult(
string $resourceType,
ExchangePowerShellCommandContract $contract,
string $runnerMode,
ExchangePowerShellInvocationResult $result,
): self {
$contractPayload = $contract->toArray();
$outputState = self::stringContext($result->context, 'output_state');
$shapeState = self::shapeState($outputState, $result);
$items = self::safeItems($result->collection);
$accepted = $result->successful
&& in_array($shapeState, [self::SHAPE_STRUCTURED_COLLECTION, self::SHAPE_STRUCTURED_EMPTY_COLLECTION], true)
&& ($shapeState === self::SHAPE_STRUCTURED_EMPTY_COLLECTION || $items !== []);
$itemCount = self::intContext($result->context, 'item_count') ?? count($items);
return new self(
resourceType: $resourceType,
commandContractName: (string) ($contractPayload['command_name'] ?? ''),
commandContractVersion: (string) ($contractPayload['command_contract_version'] ?? ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION),
sourceSurface: (string) ($contractPayload['source_surface'] ?? ExchangePowerShellCommandContracts::SOURCE_SURFACE),
runnerMode: trim($runnerMode) !== '' ? trim($runnerMode) : 'unknown',
shapeState: $shapeState,
items: $accepted ? $items : [],
itemCount: max(0, $itemCount),
emptyCollection: $shapeState === self::SHAPE_STRUCTURED_EMPTY_COLLECTION,
warningsClassified: $outputState === 'warning_prefixed',
outputGuardState: $accepted ? self::OUTPUT_GUARD_ACCEPTED : self::OUTPUT_GUARD_BLOCKED,
normalizerReadinessState: $accepted ? self::READINESS_PENDING : self::READINESS_BLOCKED,
redactionReadinessState: $accepted ? self::READINESS_PENDING : self::READINESS_BLOCKED,
identityReadinessState: $accepted ? self::READINESS_PENDING : self::READINESS_BLOCKED,
readinessBlocker: $accepted ? null : self::blockerFor($outputState, $result->failureCode),
);
}
public function accepted(): bool
{
return $this->outputGuardState === self::OUTPUT_GUARD_ACCEPTED;
}
/**
* @return array<string, mixed>
*/
public function safeSummary(): array
{
return [
'resource_type' => $this->resourceType,
'command_contract_name' => $this->commandContractName,
'command_contract_version' => $this->commandContractVersion,
'source_surface' => $this->sourceSurface,
'runner_mode' => $this->runnerMode,
'shape_state' => $this->shapeState,
'item_count' => $this->itemCount,
'empty_collection' => $this->emptyCollection,
'warnings_classified' => $this->warningsClassified,
'output_guard_state' => $this->outputGuardState,
'normalizer_readiness_state' => $this->normalizerReadinessState,
'redaction_readiness_state' => $this->redactionReadinessState,
'identity_readiness_state' => $this->identityReadinessState,
'readiness_blocker' => $this->readinessBlocker,
];
}
private static function shapeState(?string $outputState, ExchangePowerShellInvocationResult $result): string
{
if ($result->successful && $outputState === 'empty_collection') {
return self::SHAPE_STRUCTURED_EMPTY_COLLECTION;
}
if ($result->successful && $outputState === self::SHAPE_STRUCTURED_COLLECTION) {
return self::SHAPE_STRUCTURED_COLLECTION;
}
return match ($outputState) {
'malformed_collection_item' => 'malformed_collection_item',
'malformed_json_collection' => 'malformed_json_collection',
'warning_prefixed' => 'warning_prefixed',
'non_utf8_or_binary' => 'non_utf8_or_binary',
'stdout_oversized' => 'stdout_oversized',
'stderr_oversized' => 'stderr_oversized',
'nonzero_exit' => 'nonzero_exit',
'timeout' => 'timeout',
'executor_exception' => 'executor_exception',
'item_limit_exceeded' => 'item_limit_exceeded',
'stderr_present' => 'stderr_present',
default => self::SHAPE_BLOCKED_UNKNOWN,
};
}
private static function blockerFor(?string $outputState, ?string $failureCode): string
{
return match ($outputState) {
'malformed_collection_item', 'malformed_json_collection' => 'blocked_malformed_output',
'warning_prefixed' => 'blocked_warning_prefixed_output',
'non_utf8_or_binary' => 'blocked_binary_output',
'stdout_oversized', 'stderr_oversized', 'item_limit_exceeded' => 'blocked_oversized_output',
'nonzero_exit' => 'blocked_nonzero_exit',
'timeout' => 'blocked_timeout',
'stderr_present' => 'blocked_stderr_present',
default => $failureCode !== null && $failureCode !== '' ? $failureCode : 'blocked_unknown_output',
};
}
/**
* @return list<array<string, mixed>>
*/
private static function safeItems(mixed $collection): array
{
if (! is_array($collection) || ! array_is_list($collection)) {
return [];
}
$items = [];
foreach ($collection as $item) {
if (! is_array($item) || ($item !== [] && array_is_list($item))) {
return [];
}
$items[] = self::sanitizeItem($item);
}
return $items;
}
/**
* @param array<string, mixed> $item
* @return array<string, mixed>
*/
private static function sanitizeItem(array $item): array
{
$sanitized = [];
foreach ($item as $key => $value) {
if (! is_string($key) || self::isForbiddenItemKey($key)) {
continue;
}
$sanitized[$key] = self::sanitizeItemValue($value);
}
return $sanitized;
}
private static function sanitizeItemValue(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_values(array_map(
static fn (mixed $item): mixed => self::sanitizeItemValue($item),
$value,
));
}
return self::sanitizeItem($value);
}
private static function isForbiddenItemKey(string $key): bool
{
$normalized = strtolower((string) preg_replace('/[^a-zA-Z0-9]+/', '', $key));
foreach (self::FORBIDDEN_ITEM_KEY_FRAGMENTS as $fragment) {
if (str_contains($normalized, $fragment)) {
return true;
}
}
return false;
}
private static function stringContext(array $context, string $key): ?string
{
$value = $context[$key] ?? null;
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
private static function intContext(array $context, string $key): ?int
{
$value = $context[$key] ?? null;
if (! is_int($value) && ! (is_string($value) && ctype_digit($value))) {
return null;
}
return (int) $value;
}
}

View File

@ -0,0 +1,511 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
abstract class ExchangePowerShellTargetEvidenceNormalizer
{
public const string PAYLOAD_SHAPE_VERSION = 'exchange-powershell-normalized-payload-v1';
public function __construct(
protected readonly ExchangePowerShellCommandContracts $contracts,
protected readonly ExchangePowerShellHashInputBuilder $hashes,
) {}
abstract public function resourceType(): string;
abstract public function normalizerVersion(): string;
/**
* @return list<string>
*/
abstract protected function materialFields(): array;
/**
* @return list<list<string>>
*/
protected function identityAliasGroups(): array
{
return [
['id', 'sourceId'],
];
}
public function evaluate(ExchangePowerShellStructuredOutputEnvelope $envelope): ExchangePowerShellNormalizerReadiness
{
$definition = $this->definition();
if ($envelope->resourceType !== $this->resourceType()) {
return ExchangePowerShellNormalizerReadiness::blocked(
resourceType: $envelope->resourceType,
definition: $definition,
blockers: ['blocked_target_not_ready'],
itemCount: $envelope->itemCount,
);
}
if (! $envelope->accepted()) {
return ExchangePowerShellNormalizerReadiness::blocked(
resourceType: $this->resourceType(),
definition: $definition,
blockers: [$envelope->readinessBlocker ?? 'blocked_unknown_output'],
itemCount: $envelope->itemCount,
);
}
if ($envelope->emptyCollection) {
return ExchangePowerShellNormalizerReadiness::ready(
resourceType: $this->resourceType(),
definition: $definition,
normalizedPreviews: [],
hashPreviews: [],
itemCount: 0,
emptyCollection: true,
);
}
$previews = [];
$seenIdentities = [];
foreach ($envelope->items as $item) {
$identity = $this->stableIdentity($item);
if (($identity['allowed'] ?? false) !== true) {
return ExchangePowerShellNormalizerReadiness::blocked(
resourceType: $this->resourceType(),
definition: $definition,
blockers: [(string) ($identity['blocker'] ?? 'blocked_identity_unproven')],
itemCount: $envelope->itemCount,
);
}
$identityKey = (string) $identity['key'];
if (isset($seenIdentities[$identityKey])) {
return ExchangePowerShellNormalizerReadiness::blocked(
resourceType: $this->resourceType(),
definition: $definition,
blockers: ['duplicate_stable_identity'],
itemCount: $envelope->itemCount,
);
}
$seenIdentities[$identityKey] = true;
$normalized = $this->normalizePayload($item, $identity);
$hashInput = $this->hashes->build(
resourceType: $this->resourceType(),
sourceSurface: ExchangePowerShellCommandContracts::SOURCE_SURFACE,
commandContractName: (string) $definition['command_contract_name'],
commandContractVersion: (string) $definition['command_contract_version'],
payloadShapeVersion: self::PAYLOAD_SHAPE_VERSION,
normalizerVersion: $this->normalizerVersion(),
normalizedPayload: $normalized,
);
$hashPreview = $this->hashes->hash($hashInput);
$previews[] = [
'identity_key' => $identityKey,
'identity_sort_key' => (string) $identity['value'],
'hash_preview' => $hashPreview,
'normalized_preview' => $normalized,
];
}
usort($previews, static function (array $left, array $right): int {
return [$left['identity_sort_key'], $left['identity_key'], $left['hash_preview']]
<=> [$right['identity_sort_key'], $right['identity_key'], $right['hash_preview']];
});
return ExchangePowerShellNormalizerReadiness::ready(
resourceType: $this->resourceType(),
definition: $definition,
normalizedPreviews: array_values(array_map(
static fn (array $preview): array => $preview['normalized_preview'],
$previews,
)),
hashPreviews: array_values(array_map(
static fn (array $preview): string => (string) $preview['hash_preview'],
$previews,
)),
itemCount: count($previews),
);
}
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$contract = $this->contract();
$shape = is_array($contract['response_shape'] ?? null) ? $contract['response_shape'] : [];
return [
'resource_type' => $this->resourceType(),
'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE,
'command_contract_name' => (string) ($contract['command_name'] ?? ''),
'command_contract_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION,
'payload_shape_version' => self::PAYLOAD_SHAPE_VERSION,
'normalizer_version' => $this->normalizerVersion(),
'identity_candidate_fields' => $this->stringList($shape['required_identity_candidate_fields'] ?? []),
'material_fields' => $this->materialFields(),
'volatile_fields' => $this->stringList($shape['volatile_fields'] ?? []),
'sensitive_fields' => $this->stringList($shape['sensitive_fields'] ?? []),
'protected_fields' => $this->stringList($shape['protected_configuration_fields'] ?? []),
'ordering_rules' => [
'associative_keys_sorted' => true,
'unordered_lists_sorted_by_canonical_value' => true,
'collection_ordering' => 'stable_identity_value_then_hash_preview',
],
'hash_input_rules' => [
'includes_resource_type' => true,
'includes_source_surface' => true,
'includes_command_contract' => true,
'includes_payload_shape_version' => true,
'includes_normalizer_version' => true,
'excludes_raw_stdout' => true,
'excludes_raw_stderr' => true,
'excludes_operation_run_context' => true,
'excludes_runtime_timestamps' => true,
'excludes_powershell_session_metadata' => true,
'excludes_credential_metadata' => true,
'excludes_permission_metadata' => true,
],
'redaction_policy' => [
'protected_configuration_values' => 'presence_only_redacted',
'sensitive_values' => 'presence_only_redacted',
'raw_provider_payload_default_visible' => false,
],
'readiness_blockers' => [
'blocked_malformed_output',
'blocked_warning_prefixed_output',
'blocked_binary_output',
'blocked_oversized_output',
'blocked_nonzero_exit',
'blocked_timeout',
'identity_conflict',
'missing_stable_external_id',
'derived_identity_blocked',
'display_name_only',
'duplicate_stable_identity',
'unsupported_identity',
'blocked_redaction_unproven',
'blocked_hash_unready',
'blocked_target_not_ready',
],
];
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $identity
* @return array<string, mixed>
*/
protected function normalizePayload(array $payload, array $identity): array
{
$material = [];
$protected = [];
$sensitive = [];
$volatile = $this->volatileFields();
foreach ($this->materialFields() as $field) {
if (! array_key_exists($field, $payload) || in_array($field, $volatile, true)) {
continue;
}
if ($this->isProtectedField($field)) {
$protected[$field] = $this->redactedMarker($payload[$field]);
continue;
}
if ($this->isSensitiveField($field)) {
$sensitive[$field] = $this->redactedMarker($payload[$field]);
continue;
}
$material[$this->settingKey($field)] = $this->normalizeSettingValue($payload[$field]);
}
return $this->sortAssociative([
'canonical_type' => $this->resourceType(),
'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE,
'payload_shape_version' => self::PAYLOAD_SHAPE_VERSION,
'normalizer_version' => $this->normalizerVersion(),
'source_identity' => [
'field' => $identity['field'],
'value' => $identity['value'],
],
'material' => $this->sortAssociative($material),
'protected_configuration' => $this->sortAssociative($protected),
'sensitive_content' => $this->sortAssociative($sensitive),
'diagnostics' => [
'volatile_fields' => $volatile,
'protected_fields' => array_keys($protected),
'sensitive_fields' => array_keys($sensitive),
],
]);
}
/**
* @param array<string, mixed> $payload
* @return array{allowed: bool, key?: string, field?: string, value?: string, blocker?: string}
*/
protected function stableIdentity(array $payload): array
{
foreach ($this->identityAliasGroups() as $group) {
$values = [];
foreach ($group as $field) {
$value = $this->stringValue($payload[$field] ?? null);
if ($value !== null) {
$values[$field] = $value;
}
}
if (count(array_unique(array_values($values))) > 1) {
return ['allowed' => false, 'blocker' => 'identity_conflict'];
}
}
foreach ($this->identityFields() as $field) {
$value = $this->stringValue($payload[$field] ?? null);
if ($value !== null) {
return [
'allowed' => true,
'field' => $field,
'value' => $value,
'key' => $this->resourceType().':'.$field.':'.hash('sha256', $value),
];
}
}
if ($this->hasDisplayOnlyIdentity($payload)) {
return ['allowed' => false, 'blocker' => 'display_name_only'];
}
foreach ($this->derivedIdentityFields() as $field) {
if ($this->stringValue($payload[$field] ?? null) !== null) {
return ['allowed' => false, 'blocker' => 'derived_identity_blocked'];
}
}
return ['allowed' => false, 'blocker' => 'missing_stable_external_id'];
}
/**
* @param list<string> $groupKeys
* @param list<string> $rootKeys
* @return array<string, mixed>
*/
protected function settingGroup(array $payload, array $groupKeys, array $rootKeys = []): array
{
$settings = [];
foreach ($groupKeys as $groupKey) {
$group = $payload[$groupKey] ?? null;
if (! is_array($group)) {
continue;
}
foreach ($group as $key => $value) {
$field = (string) $key;
$settings[$this->settingKey($field)] = $this->redactedOrNormalized($field, $value);
}
}
foreach ($rootKeys as $rootKey) {
if (! array_key_exists($rootKey, $payload)) {
continue;
}
$settings[$this->settingKey($rootKey)] = $this->redactedOrNormalized($rootKey, $payload[$rootKey]);
}
return $this->sortAssociative($settings);
}
protected function redactedOrNormalized(string $field, mixed $value): mixed
{
if ($this->isProtectedField($field) || $this->isSensitiveField($field)) {
return $this->redactedMarker($value);
}
return $this->normalizeSettingValue($value);
}
protected function redactedMarker(mixed $value): array
{
return [
'value' => '[redacted]',
'present' => $value !== null,
'count' => is_array($value) ? count($value) : ($value === null ? 0 : 1),
];
}
protected function normalizeSettingValue(mixed $value): mixed
{
if (is_bool($value)) {
return $value;
}
if (is_scalar($value)) {
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
if (! is_array($value)) {
return null;
}
if (array_is_list($value)) {
$items = array_values(array_filter(
array_map(fn (mixed $item): mixed => $this->normalizeSettingValue($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_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
json_encode($right, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
));
return $items;
}
$normalized = [];
foreach ($value as $key => $nestedValue) {
$normalized[$this->settingKey((string) $key)] = $this->normalizeSettingValue($nestedValue);
}
return $this->sortAssociative($normalized);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function sortAssociative(array $payload): array
{
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;
}
protected function settingKey(string $key): string
{
$key = str_replace(
['IP', 'TLS', 'OOF', 'TNEF'],
['Ip', 'Tls', 'Oof', 'Tnef'],
$key,
);
return str($key)
->replaceMatches('/[^A-Za-z0-9]+/', '_')
->snake()
->trim('_')
->toString();
}
protected function stringValue(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
/**
* @return list<string>
*/
protected function identityFields(): array
{
return $this->stringList(data_get($this->contract(), 'response_shape.required_identity_candidate_fields', []));
}
/**
* @return list<string>
*/
protected function derivedIdentityFields(): array
{
return $this->stringList(data_get($this->contract(), 'response_shape.derived_identity_candidate_fields', []));
}
/**
* @return list<string>
*/
protected function volatileFields(): array
{
return $this->stringList(data_get($this->contract(), 'response_shape.volatile_fields', []));
}
protected function isSensitiveField(string $field): bool
{
return in_array(
strtolower($field),
array_map('strtolower', $this->stringList(data_get($this->contract(), 'response_shape.sensitive_fields', []))),
true,
);
}
protected function isProtectedField(string $field): bool
{
return in_array(
strtolower($field),
array_map('strtolower', $this->stringList(data_get($this->contract(), 'response_shape.protected_configuration_fields', []))),
true,
);
}
/**
* @return array<string, mixed>
*/
protected function contract(): array
{
return $this->contracts->contractForCanonicalType($this->resourceType()) ?? [];
}
/**
* @return list<string>
*/
protected function stringList(mixed $value): array
{
if (! is_array($value)) {
return [];
}
return array_values(array_filter(
array_map(static fn (mixed $item): string => is_string($item) ? trim($item) : '', $value),
static fn (string $item): bool => $item !== '',
));
}
private function hasDisplayOnlyIdentity(array $payload): bool
{
foreach (['DisplayName', 'displayName', 'Name', 'name'] as $field) {
if ($this->stringValue($payload[$field] ?? null) !== null) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class ExchangeRemoteDomainEvidenceNormalizer extends ExchangePowerShellTargetEvidenceNormalizer
{
public function resourceType(): string
{
return 'remoteDomain';
}
public function normalizerVersion(): string
{
return 'exchange-remote-domain-readiness-v1';
}
/**
* @return list<string>
*/
protected function materialFields(): array
{
return [
'Guid',
'RemoteDomainId',
'Identity',
'DomainName',
'Name',
'DisplayName',
'IsDefault',
'AllowedOOFType',
'AutoReplyEnabled',
'AutoForwardEnabled',
'MailTipsAccessLevel',
'MailTipsAccessScope',
'TargetDeliveryDomain',
'TNEFEnabled',
'TrustedMailOutboundEnabled',
];
}
/**
* @return list<list<string>>
*/
protected function identityAliasGroups(): array
{
return [
['id', 'sourceId'],
['Guid', 'RemoteDomainId', 'Identity'],
];
}
protected function normalizePayload(array $payload, array $identity): array
{
$normalized = parent::normalizePayload($payload, $identity);
$normalized['remote_domain_class'] = [
'is_default' => $this->normalizeSettingValue($payload['IsDefault'] ?? $payload['isDefault'] ?? null),
'domain_name_is_identity' => false,
];
$normalized['settings'] = $this->settingGroup($payload, [], [
'AllowedOOFType',
'AutoReplyEnabled',
'AutoForwardEnabled',
'MailTipsAccessLevel',
'MailTipsAccessScope',
'TargetDeliveryDomain',
'TNEFEnabled',
'TrustedMailOutboundEnabled',
]);
return $this->sortAssociative($normalized);
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
final class ExchangeTransportRuleEvidenceNormalizer extends ExchangePowerShellTargetEvidenceNormalizer
{
public function resourceType(): string
{
return 'transportRule';
}
public function normalizerVersion(): string
{
return 'exchange-transport-rule-readiness-v1';
}
/**
* @return list<string>
*/
protected function materialFields(): array
{
return [
'Guid',
'RuleId',
'Identity',
'DisplayName',
'Name',
'Priority',
'Mode',
'State',
'Enabled',
'From',
'RecipientDomainIs',
'SenderDomainIs',
'SentTo',
'SubjectContainsWords',
'SubjectOrBodyContainsWords',
'BodyContainsWords',
'ApplyHtmlDisclaimerText',
'ApplyHtmlDisclaimerFallbackAction',
'ApplyHtmlDisclaimerLocation',
'DeleteMessage',
'RedirectMessageTo',
'ExceptIfFrom',
'ExceptIfRecipientDomainIs',
'HeaderContainsWords',
'SetHeaderValue',
];
}
/**
* @return list<list<string>>
*/
protected function identityAliasGroups(): array
{
return [
['id', 'sourceId'],
['Guid', 'RuleId'],
];
}
protected function normalizePayload(array $payload, array $identity): array
{
$normalized = parent::normalizePayload($payload, $identity);
$normalized['rule_order'] = [
'priority' => $this->normalizeSettingValue($payload['Priority'] ?? $payload['priority'] ?? null),
'order_is_identity' => false,
];
$normalized['conditions'] = $this->settingGroup($payload, ['Conditions', 'conditions'], [
'From',
'RecipientDomainIs',
'SenderDomainIs',
'SentTo',
'SubjectContainsWords',
'SubjectOrBodyContainsWords',
'BodyContainsWords',
]);
$normalized['actions'] = $this->settingGroup($payload, ['Actions', 'actions'], [
'ApplyHtmlDisclaimerText',
'ApplyHtmlDisclaimerFallbackAction',
'ApplyHtmlDisclaimerLocation',
'DeleteMessage',
'RedirectMessageTo',
'HeaderContainsWords',
'SetHeaderValue',
]);
$normalized['exceptions'] = $this->settingGroup($payload, ['Exceptions', 'exceptions'], [
'ExceptIfFrom',
'ExceptIfRecipientDomainIs',
]);
return $this->sortAssociative($normalized);
}
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Services\TenantConfiguration\ExchangePowerShellCommandContract;
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\ExchangePowerShellEvidenceNormalizer;
use App\Services\TenantConfiguration\ExchangePowerShellInvocationResult;
use App\Services\TenantConfiguration\ExchangePowerShellStructuredOutputEnvelope;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
beforeEach(function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
});
it('Spec435 readiness evaluation remains in-memory and creates no resources evidence or operation runs', function (): void {
$beforeRuns = OperationRun::query()->count();
$readiness = app(ExchangePowerShellEvidenceNormalizer::class)->evaluate(
spec435FeatureEnvelope('transportRule', [[
'Guid' => 'rule-guid-1',
'DisplayName' => 'Rule 1',
'Enabled' => true,
]]),
);
expect($readiness->ready)->toBeTrue()
->and(OperationRun::query()->count())->toBe($beforeRuns)
->and(TenantConfigurationResource::query()->count())->toBe(0)
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
});
it('Spec435 empty collections create no durable collection proof resource or evidence row', function (): void {
$readiness = app(ExchangePowerShellEvidenceNormalizer::class)->evaluate(
spec435FeatureEnvelope('transportRule', [], 'empty_collection'),
);
expect($readiness->ready)->toBeTrue()
->and($readiness->emptyCollection)->toBeTrue()
->and($readiness->normalizedPreviews)->toBe([])
->and($readiness->hashPreviews)->toBe([])
->and(TenantConfigurationResource::query()->count())->toBe(0)
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
});
it('Spec435 readiness code stays separated from capture upsert and evidence append paths', function (): void {
$runtimeSource = collect(spec435RuntimeFiles())
->map(fn (string $file): string => file_get_contents($file) ?: '')
->implode("\n");
expect($runtimeSource)
->not->toContain('ExchangePowerShellEvidenceCaptureAdapter::capture')
->not->toContain('CoverageResourceUpserter')
->not->toContain('CoverageEvidenceWriter')
->not->toContain('TenantConfigurationResourceEvidence::')
->not->toContain('content_backed')
->not->toContain('comparable')
->not->toContain('renderable')
->not->toContain('certified')
->not->toContain('restore_ready')
->not->toContain('customer_ready');
});
it('Spec435 introduces no product surface triggers migrations tenant-id ownership or mini-platform', function (): void {
$runtimeSource = collect(spec435RuntimeFiles())
->map(fn (string $file): string => file_get_contents($file) ?: '')
->implode("\n");
expect(preg_match('/\btenant_id\b/', $runtimeSource))->toBe(0)
->and(Schema::hasColumn('tenant_configuration_resources', 'tenant_id'))->toBeFalse()
->and(Schema::hasColumn('tenant_configuration_resource_evidence', 'tenant_id'))->toBeFalse()
->and(glob(app_path('Filament/**/*ExchangePowerShell*')) ?: [])->toBe([])
->and(glob(app_path('Livewire/**/*ExchangePowerShell*')) ?: [])->toBe([])
->and(glob(base_path('routes/*exchange*')) ?: [])->toBe([])
->and(glob(resource_path('views/**/*ExchangePowerShell*')) ?: [])->toBe([])
->and(glob(database_path('migrations/*exchange*evidence*')) ?: [])->toBe([])
->and(glob(database_path('migrations/*tenant*configuration*exchange*')) ?: [])->toBe([])
->and(File::exists(app_path('Jobs/TenantConfiguration/CaptureExchangePowerShellEvidenceJob.php')))->toBeFalse()
->and(File::exists(app_path('Jobs/TenantConfiguration/NormalizeExchangePowerShellEvidenceJob.php')))->toBeFalse()
->and(File::exists(app_path('Console/Commands/CaptureExchangePowerShellEvidence.php')))->toBeFalse();
});
function spec435FeatureEnvelope(
string $canonicalType,
array $items,
string $outputState = 'structured_collection',
): ExchangePowerShellStructuredOutputEnvelope {
return ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: $canonicalType,
contract: spec435FeatureVerifiedContract($canonicalType),
runnerMode: 'fake',
result: ExchangePowerShellInvocationResult::succeeded($items, context: [
'output_state' => $outputState,
'item_count' => count($items),
]),
);
}
function spec435FeatureVerifiedContract(string $canonicalType): ExchangePowerShellCommandContract
{
$contracts = new ExchangePowerShellCommandContracts;
$contract = $contracts->contractForCanonicalType($canonicalType);
if (! is_array($contract)) {
throw new RuntimeException('Missing Spec435 feature command contract.');
}
return ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []);
}
/**
* @return list<string>
*/
function spec435RuntimeFiles(): array
{
return [
app_path('Services/TenantConfiguration/ExchangePowerShellStructuredOutputEnvelope.php'),
app_path('Services/TenantConfiguration/ExchangePowerShellEvidenceNormalizer.php'),
app_path('Services/TenantConfiguration/ExchangePowerShellTargetEvidenceNormalizer.php'),
app_path('Services/TenantConfiguration/ExchangeTransportRuleEvidenceNormalizer.php'),
app_path('Services/TenantConfiguration/ExchangeRemoteDomainEvidenceNormalizer.php'),
app_path('Services/TenantConfiguration/ExchangeInboundConnectorEvidenceNormalizer.php'),
app_path('Services/TenantConfiguration/ExchangePowerShellNormalizerReadiness.php'),
app_path('Services/TenantConfiguration/ExchangePowerShellHashInputBuilder.php'),
];
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\ExchangePowerShellHashInputBuilder;
use App\Services\TenantConfiguration\ExchangePowerShellTargetEvidenceNormalizer;
it('Spec435 hash input includes target and normalizer metadata while excluding raw runtime context', function (): void {
$builder = app(ExchangePowerShellHashInputBuilder::class);
$input = $builder->build(
resourceType: 'transportRule',
sourceSurface: ExchangePowerShellCommandContracts::SOURCE_SURFACE,
commandContractName: 'Get-TransportRule',
commandContractVersion: ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION,
payloadShapeVersion: ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION,
normalizerVersion: 'exchange-transport-rule-readiness-v1',
normalizedPayload: [
'canonical_type' => 'transportRule',
'material' => ['enabled' => true],
],
);
$json = $builder->canonicalJson($input);
expect($input)->toMatchArray([
'resource_type' => 'transportRule',
'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE,
'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',
])
->and($json)
->not->toContain('raw_stdout')
->not->toContain('raw_stderr')
->not->toContain('operation_run')
->not->toContain('credential')
->not->toContain('permission');
});
it('Spec435 hash previews change when normalizer version or target type changes', function (): void {
$builder = app(ExchangePowerShellHashInputBuilder::class);
$payload = ['canonical_type' => 'transportRule', 'material' => ['enabled' => true]];
$first = $builder->hash($builder->build(
resourceType: 'transportRule',
sourceSurface: ExchangePowerShellCommandContracts::SOURCE_SURFACE,
commandContractName: 'Get-TransportRule',
commandContractVersion: ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION,
payloadShapeVersion: ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION,
normalizerVersion: 'exchange-transport-rule-readiness-v1',
normalizedPayload: $payload,
));
$versionChanged = $builder->hash($builder->build(
resourceType: 'transportRule',
sourceSurface: ExchangePowerShellCommandContracts::SOURCE_SURFACE,
commandContractName: 'Get-TransportRule',
commandContractVersion: ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION,
payloadShapeVersion: ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION,
normalizerVersion: 'exchange-transport-rule-readiness-v2',
normalizedPayload: $payload,
));
$targetChanged = $builder->hash($builder->build(
resourceType: 'remoteDomain',
sourceSurface: ExchangePowerShellCommandContracts::SOURCE_SURFACE,
commandContractName: 'Get-RemoteDomain',
commandContractVersion: ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION,
payloadShapeVersion: ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION,
normalizerVersion: 'exchange-transport-rule-readiness-v1',
normalizedPayload: $payload,
));
expect($first)->not->toBe($versionChanged)
->and($first)->not->toBe($targetChanged);
});

View File

@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangePowerShellCommandContract;
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\ExchangePowerShellEvidenceNormalizer;
use App\Services\TenantConfiguration\ExchangePowerShellInvocationResult;
use App\Services\TenantConfiguration\ExchangePowerShellNormalizerReadiness;
use App\Services\TenantConfiguration\ExchangePowerShellStructuredOutputEnvelope;
it('Spec435 proves transportRule normalizer identity redaction ordering and hash readiness', function (): void {
$readiness = spec435EvaluateReadiness('transportRule', [[
'Guid' => 'rule-guid-1',
'DisplayName' => 'Block unsafe mail',
'Priority' => 5,
'Enabled' => true,
'Mode' => 'Enforce',
'SenderDomainIs' => ['z.example', 'a.example'],
'SubjectContainsWords' => ['very-sensitive-phrase'],
'Actions' => ['SetHeaderValue' => 'header-secret', 'DeleteMessage' => true],
'WhenChanged' => '2026-07-08T10:00:00Z',
]]);
$preview = $readiness->normalizedPreviews[0];
$json = json_encode($readiness->toArray(), JSON_THROW_ON_ERROR);
expect($readiness->ready)->toBeTrue()
->and($readiness->blockers)->toBe([])
->and(data_get($preview, 'source_identity'))->toBe(['field' => 'Guid', 'value' => 'rule-guid-1'])
->and(data_get($preview, 'rule_order.priority'))->toBe('5')
->and(data_get($preview, 'conditions.sender_domain_is.value'))->toBe('[redacted]')
->and(data_get($preview, 'conditions.sender_domain_is.count'))->toBe(2)
->and(data_get($preview, 'conditions.subject_contains_words.value'))->toBe('[redacted]')
->and(data_get($preview, 'actions.set_header_value.value'))->toBe('[redacted]')
->and($readiness->hashPreviews)->toHaveCount(1)
->and($json)
->not->toContain('very-sensitive-phrase')
->not->toContain('header-secret');
expect(json_encode($readiness->safeOperationContext(), JSON_THROW_ON_ERROR))
->not->toContain('raw_stdout')
->not->toContain('raw_stderr');
});
it('Spec435 proves transportRule hash previews are deterministic across volatile and provider-order-only changes', function (): void {
$first = spec435EvaluateReadiness('transportRule', [[
'Guid' => 'rule-guid-1',
'DisplayName' => 'Block unsafe mail',
'Enabled' => true,
'SenderDomainIs' => ['z.example', 'a.example'],
'WhenChanged' => '2026-07-08T10:00:00Z',
]]);
$volatileChanged = spec435EvaluateReadiness('transportRule', [[
'Guid' => 'rule-guid-1',
'DisplayName' => 'Block unsafe mail',
'Enabled' => true,
'SenderDomainIs' => ['a.example', 'z.example'],
'WhenChanged' => '2026-07-09T10:00:00Z',
]]);
$materialChanged = spec435EvaluateReadiness('transportRule', [[
'Guid' => 'rule-guid-1',
'DisplayName' => 'Block unsafe mail',
'Enabled' => false,
'SenderDomainIs' => ['z.example', 'a.example'],
'WhenChanged' => '2026-07-09T10:00:00Z',
]]);
expect($first->hashPreviews[0])->toBe($volatileChanged->hashPreviews[0])
->and($first->hashPreviews[0])->not->toBe($materialChanged->hashPreviews[0]);
});
it('Spec435 sorts collection previews deterministically independent of provider item order', function (): void {
$first = spec435EvaluateReadiness('remoteDomain', [
['Identity' => 'z.example', 'DomainName' => 'z.example'],
['Identity' => 'a.example', 'DomainName' => 'a.example'],
]);
$second = spec435EvaluateReadiness('remoteDomain', [
['Identity' => 'a.example', 'DomainName' => 'a.example'],
['Identity' => 'z.example', 'DomainName' => 'z.example'],
]);
expect($first->hashPreviews)->toBe($second->hashPreviews)
->and(data_get($first->normalizedPreviews[0], 'source_identity.value'))->toBe('a.example')
->and(data_get($first->normalizedPreviews[1], 'source_identity.value'))->toBe('z.example');
});
it('Spec435 proves remoteDomain readiness from source Identity and blocks display/domain-only unsafe identity', function (
array $payload,
bool $expectedReady,
?string $expectedBlocker,
): void {
$readiness = spec435EvaluateReadiness('remoteDomain', [$payload]);
expect($readiness->ready)->toBe($expectedReady);
if ($expectedBlocker !== null) {
expect($readiness->blockers)->toContain($expectedBlocker);
} else {
expect(data_get($readiness->normalizedPreviews[0], 'source_identity.field'))->toBe('Identity')
->and(data_get($readiness->normalizedPreviews[0], 'remote_domain_class.domain_name_is_identity'))->toBeFalse()
->and(json_encode($readiness->toArray(), JSON_THROW_ON_ERROR))->not->toContain('mailtips-secret');
}
})->with([
'default with stable Identity' => [['Identity' => 'Default', 'IsDefault' => true, 'DomainName' => '*', 'MailTipsAccessScope' => 'mailtips-secret'], true, null],
'custom with stable Identity' => [['Identity' => 'contoso.example', 'IsDefault' => false, 'DomainName' => 'contoso.example'], true, null],
'display-name-only identity' => [['DisplayName' => 'Contoso Remote Domain'], false, 'display_name_only'],
'domain-only derived identity' => [['DomainName' => 'contoso.example'], false, 'derived_identity_blocked'],
]);
it('Spec435 blocks remoteDomain missing duplicate and conflicting identities explicitly', function (
array $items,
string $expectedBlocker,
): void {
$readiness = spec435EvaluateReadiness('remoteDomain', $items);
expect($readiness->ready)->toBeFalse()
->and($readiness->blockers)->toContain($expectedBlocker);
})->with([
'missing identity' => [[[]], 'missing_stable_external_id'],
'duplicate identity' => [[['Identity' => 'Default'], ['Identity' => 'Default']], 'duplicate_stable_identity'],
'conflicting identity aliases' => [[['Guid' => 'guid-a', 'RemoteDomainId' => 'guid-b', 'Identity' => 'Default']], 'identity_conflict'],
]);
it('Spec435 proves inboundConnector protected configuration redaction and blocks unsafe connector identity', function (): void {
$readiness = spec435EvaluateReadiness('inboundConnector', [[
'Identity' => 'connector-1',
'ConnectorType' => 'OnPremises',
'Enabled' => true,
'RequireTls' => true,
'SenderIPAddresses' => ['203.0.113.10'],
'SmartHosts' => ['mail.contoso.example'],
'TlsSenderCertificateName' => 'CN=secret-cert',
'Comment' => 'sensitive routing note',
'ConnectorSource' => 'AdminUI',
]]);
$json = json_encode($readiness->toArray(), JSON_THROW_ON_ERROR);
expect($readiness->ready)->toBeTrue()
->and(data_get($readiness->normalizedPreviews[0], 'routing.sender_ip_addresses.value'))->toBe('[redacted]')
->and(data_get($readiness->normalizedPreviews[0], 'routing.smart_hosts.value'))->toBe('[redacted]')
->and(data_get($readiness->normalizedPreviews[0], 'routing.tls_sender_certificate_name.value'))->toBe('[redacted]')
->and(data_get($readiness->normalizedPreviews[0], 'routing.comment.value'))->toBe('[redacted]')
->and($json)
->not->toContain('203.0.113.10')
->not->toContain('mail.contoso.example')
->not->toContain('secret-cert')
->not->toContain('sensitive routing note');
$missing = spec435EvaluateReadiness('inboundConnector', [['DisplayName' => 'Connector display only']]);
$duplicate = spec435EvaluateReadiness('inboundConnector', [['Identity' => 'connector-1'], ['Identity' => 'connector-1']]);
$conflict = spec435EvaluateReadiness('inboundConnector', [['Guid' => 'guid-a', 'ConnectorId' => 'connector-b']]);
expect($missing->blockers)->toContain('display_name_only')
->and($duplicate->blockers)->toContain('duplicate_stable_identity')
->and($conflict->blockers)->toContain('identity_conflict');
});
it('Spec435 blocks unsupported targets and unsafe shape states before normalizer readiness', function (): void {
$unsupported = app(ExchangePowerShellEvidenceNormalizer::class)->evaluate(
spec435Envelope('acceptedDomain', [], 'structured_collection'),
);
$unsafe = app(ExchangePowerShellEvidenceNormalizer::class)->evaluate(
spec435Envelope('transportRule', [], 'malformed_json_collection', false, 'blocked_malformed_output'),
);
expect($unsupported->ready)->toBeFalse()
->and($unsupported->blockers)->toContain('blocked_target_not_ready')
->and($unsafe->ready)->toBeFalse()
->and($unsafe->blockers)->toContain('blocked_malformed_output');
});
function spec435EvaluateReadiness(string $canonicalType, array $items): ExchangePowerShellNormalizerReadiness
{
return app(ExchangePowerShellEvidenceNormalizer::class)->evaluate(
spec435Envelope($canonicalType, $items),
);
}
function spec435Envelope(
string $canonicalType,
array $items,
string $outputState = 'structured_collection',
bool $successful = true,
?string $failureCode = null,
): ExchangePowerShellStructuredOutputEnvelope {
$context = ['output_state' => $outputState, 'item_count' => count($items)];
$result = $successful
? ExchangePowerShellInvocationResult::succeeded($items, context: $context)
: ExchangePowerShellInvocationResult::failed('provider_failed', $failureCode ?? 'runner_failed', context: $context);
return ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: $canonicalType,
contract: spec435NormalizerVerifiedContract(in_array($canonicalType, ['transportRule', 'remoteDomain', 'inboundConnector'], true) ? $canonicalType : 'transportRule'),
runnerMode: 'fake',
result: $result,
);
}
function spec435NormalizerVerifiedContract(string $canonicalType): ExchangePowerShellCommandContract
{
$contracts = new ExchangePowerShellCommandContracts;
$contract = $contracts->contractForCanonicalType($canonicalType);
if (! is_array($contract)) {
throw new RuntimeException('Missing Spec435 normalizer command contract.');
}
return ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []);
}

View File

@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangePowerShellCommandContract;
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\ExchangePowerShellOutputGuard;
use App\Services\TenantConfiguration\ExchangePowerShellProcessResult;
use App\Services\TenantConfiguration\ExchangePowerShellRuntimePolicy;
use App\Services\TenantConfiguration\ExchangePowerShellStructuredOutputEnvelope;
it('Spec435 accepts structured Exchange collections and structured empty collections without raw process output', function (): void {
$contract = spec435VerifiedContract('transportRule');
$collection = (new ExchangePowerShellOutputGuard)->guard(
ExchangePowerShellProcessResult::completed(0, '[{"Guid":"rule-1","client_secret":"unsafe"}]', durationMs: 10),
$contract,
spec435RuntimePolicy(),
);
$empty = (new ExchangePowerShellOutputGuard)->guard(
ExchangePowerShellProcessResult::completed(0, '', durationMs: 11),
$contract,
spec435RuntimePolicy(),
);
$collectionEnvelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: 'transportRule',
contract: $contract,
runnerMode: 'fake',
result: $collection,
);
$emptyEnvelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: 'transportRule',
contract: $contract,
runnerMode: 'fake',
result: $empty,
);
$collectionItemsJson = json_encode($collectionEnvelope->items, JSON_THROW_ON_ERROR);
expect($collectionEnvelope->accepted())->toBeTrue()
->and($collectionEnvelope->shapeState)->toBe(ExchangePowerShellStructuredOutputEnvelope::SHAPE_STRUCTURED_COLLECTION)
->and($collectionEnvelope->itemCount)->toBe(1)
->and($collectionEnvelope->items[0]['Guid'] ?? null)->toBe('rule-1')
->and($collectionItemsJson)
->not->toContain('client_secret')
->not->toContain('unsafe')
->and($collectionEnvelope->safeSummary())
->not->toHaveKey('stdout')
->not->toHaveKey('stderr')
->and(json_encode($collectionEnvelope->safeSummary(), JSON_THROW_ON_ERROR))
->not->toContain('client_secret')
->and($emptyEnvelope->accepted())->toBeTrue()
->and($emptyEnvelope->shapeState)->toBe(ExchangePowerShellStructuredOutputEnvelope::SHAPE_STRUCTURED_EMPTY_COLLECTION)
->and($emptyEnvelope->emptyCollection)->toBeTrue()
->and($emptyEnvelope->itemCount)->toBe(0);
});
it('Spec435 strips forbidden secret and raw-output key families from public envelope items recursively', function (): void {
$contract = spec435VerifiedContract('transportRule');
$collection = (new ExchangePowerShellOutputGuard)->guard(
ExchangePowerShellProcessResult::completed(0, json_encode([[
'Guid' => 'rule-1',
'Actions' => [
'SetHeaderValue' => 'business-value-redacted-later',
'authorization_header' => 'Bearer unsafe',
],
'Nested' => [
'stdout' => 'unsafe-direct-stdout',
'stderr' => 'unsafe-direct-stderr',
'tokens' => ['unsafe-token'],
'Cookies' => ['unsafe-cookie'],
'CredentialMaterial' => 'unsafe-credential',
'PowerShellTranscript' => 'unsafe-transcript',
'RawShellStdout' => 'unsafe-stdout',
],
]], JSON_THROW_ON_ERROR), durationMs: 10),
$contract,
spec435RuntimePolicy(),
);
$envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: 'transportRule',
contract: $contract,
runnerMode: 'fake',
result: $collection,
);
$itemsJson = json_encode($envelope->items, JSON_THROW_ON_ERROR);
expect($envelope->accepted())->toBeTrue()
->and(data_get($envelope->items, '0.Guid'))->toBe('rule-1')
->and(data_get($envelope->items, '0.Actions.SetHeaderValue'))->toBe('business-value-redacted-later')
->and($itemsJson)
->not->toContain('authorization_header')
->not->toContain('unsafe-token')
->not->toContain('unsafe-cookie')
->not->toContain('unsafe-credential')
->not->toContain('unsafe-transcript')
->not->toContain('unsafe-stdout')
->not->toContain('unsafe-direct-stdout')
->not->toContain('unsafe-direct-stderr');
});
it('Spec435 maps unsafe Exchange process output shapes to explicit readiness blockers', function (
ExchangePowerShellProcessResult $processResult,
string $expectedShapeState,
string $expectedBlocker,
): void {
$contract = spec435VerifiedContract('transportRule');
$result = (new ExchangePowerShellOutputGuard)->guard(
$processResult,
$contract,
spec435RuntimePolicy(stdoutMaxBytes: 64, stderrMaxBytes: 64, itemLimit: 1),
);
$envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: 'transportRule',
contract: $contract,
runnerMode: 'fake',
result: $result,
);
expect($envelope->accepted())->toBeFalse()
->and($envelope->shapeState)->toBe($expectedShapeState)
->and($envelope->readinessBlocker)->toBe($expectedBlocker)
->and($envelope->normalizerReadinessState)->toBe(ExchangePowerShellStructuredOutputEnvelope::READINESS_BLOCKED)
->and(json_encode($envelope->safeSummary(), JSON_THROW_ON_ERROR))
->not->toContain('secret-output');
})->with([
'malformed text' => [ExchangePowerShellProcessResult::completed(0, 'raw secret-output transcript'), 'malformed_json_collection', 'blocked_malformed_output'],
'scalar output' => [ExchangePowerShellProcessResult::completed(0, '42'), 'malformed_json_collection', 'blocked_malformed_output'],
'warning-prefixed output' => [ExchangePowerShellProcessResult::completed(0, "WARNING: secret-output\n[]"), 'warning_prefixed', 'blocked_warning_prefixed_output'],
'binary output' => [ExchangePowerShellProcessResult::completed(0, "\x00\x01"), 'non_utf8_or_binary', 'blocked_binary_output'],
'oversized stdout' => [ExchangePowerShellProcessResult::completed(0, str_repeat('A', 128)), 'stdout_oversized', 'blocked_oversized_output'],
'oversized stderr' => [ExchangePowerShellProcessResult::completed(0, '[]', str_repeat('B', 128)), 'stderr_oversized', 'blocked_oversized_output'],
'non-zero exit' => [ExchangePowerShellProcessResult::completed(1, '[{"Guid":"rule-1"}]'), 'nonzero_exit', 'blocked_nonzero_exit'],
'timeout' => [ExchangePowerShellProcessResult::timedOut(60000), 'timeout', 'blocked_timeout'],
]);
function spec435RuntimePolicy(
int $stdoutMaxBytes = 524288,
int $stderrMaxBytes = 65536,
int $itemLimit = 1000,
): ExchangePowerShellRuntimePolicy {
return new ExchangePowerShellRuntimePolicy(
invocationEnabled: true,
productionRunnerEnabled: true,
allowedEnvironments: [app()->environment()],
currentEnvironment: app()->environment(),
powershellBinary: 'pwsh',
moduleCheckEnabled: true,
checkTimeoutSeconds: 5,
invocationTimeoutSeconds: 60,
stdoutMaxBytes: $stdoutMaxBytes,
stderrMaxBytes: $stderrMaxBytes,
itemLimit: $itemLimit,
lockTtlSeconds: 300,
tempFilesEnabled: false,
tempDirectory: null,
);
}
function spec435VerifiedContract(string $canonicalType): ExchangePowerShellCommandContract
{
$contracts = new ExchangePowerShellCommandContracts;
$contract = $contracts->contractForCanonicalType($canonicalType);
if (! is_array($contract)) {
throw new RuntimeException('Missing Spec435 command contract.');
}
return ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []);
}

View File

@ -0,0 +1,151 @@
# Requirements Checklist: Exchange Structured Output and Target Normalizer Readiness
**Purpose**: Preparation and implementation-readiness checklist for Spec 435.
**Created**: 2026-07-08
**Feature**: `specs/435-exchange-structured-output-target-normalizer-readiness/spec.md`
## Prerequisites
- [x] Spec 434 merge-ready proof is required before implementation.
- [x] Spec 434 adapter boundary, identity hard-stop, content-only writer guard, empty collection no-fake-evidence policy, generic Graph preservation, no UI, no migration, and no `tenant_id` are recorded as prerequisite proof.
- [x] Spec 433 credential/permission readiness, client-secret blocking, scoped/current `Exchange.ManageAsApp`, combined readiness, provider capability support, and runner-gate consumption are recorded as prerequisite proof.
- [x] Spec 432 runner boundary, process executor abstraction, output guard, timeout/concurrency guards, sanitized OperationRun context, no raw stdout/stderr outside approved paths, no UI, no migration, and no `tenant_id` are recorded as prerequisite proof.
- [x] Spec 430 command contracts exist for `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector`.
## Scope
- [x] Structured output only.
- [x] Normalizer readiness only.
- [x] Targets limited to `transportRule`, `remoteDomain`, and `inboundConnector`.
- [x] No evidence rows.
- [x] No content-backed promotion.
- [x] No compare/render.
- [x] No certification.
- [x] No restore.
- [x] No customer claim.
- [x] No UI.
- [x] No jobs/schedules/listeners.
- [x] No migration.
- [x] No `tenant_id`.
## Structured Output
- [x] Structured envelope requirement exists.
- [x] Raw stdout/stderr exclusion requirement exists.
- [x] Shape states are defined or mapped to repo-canonical equivalents.
- [x] Malformed output must block.
- [x] Scalar output must block.
- [x] Warning-prefixed output must block or classify safely.
- [x] Binary/non-UTF8 output must block.
- [x] Oversized output must block.
- [x] Non-zero exit must block.
- [x] Timeout must block.
- [x] Empty collection is distinguishable from failure states.
## Target Normalizers
- [x] `transportRule` readiness is required.
- [x] `remoteDomain` readiness must complete or explicitly block.
- [x] `inboundConnector` readiness must complete or explicitly block.
- [x] Volatile fields must be handled.
- [x] Sensitive fields must be handled.
- [x] Ordering must be deterministic.
- [x] Hash input rules must be defined.
- [x] Normalized payload previews are test-only/in-memory and not persisted.
## Identity
- [x] Stable identity rules are required.
- [x] Display-name-only identity must block.
- [x] Derived/domain-only identity must block unless proven safe.
- [x] Duplicate identity must block.
- [x] Missing identity must block.
- [x] Unsupported identity must block.
- [x] Identity-unproven state must block.
- [x] Explicit blocker states are required.
## Redaction
- [x] `transportRule` sensitive fields must be classified.
- [x] `remoteDomain` sensitive fields must be classified.
- [x] `inboundConnector` IPs, hosts, certificate names, comments, and routing metadata must be protected or potentially sensitive.
- [x] Protected config must not enter OperationRun context/logs/summaries.
- [x] Credential material must be absent.
- [x] Customer output must remain absent.
## Hash
- [x] Hash input determinism is required.
- [x] Material changes must be detected.
- [x] Volatile changes must be ignored.
- [x] Provider ordering must be handled deterministically where order is not meaningful.
- [x] Target type and normalizer version handling are required.
- [x] Hash input must exclude raw output, OperationRun context, runtime timestamps, PowerShell session metadata, credential metadata, and permission metadata.
## Empty Collection
- [x] Empty collection must be distinguished from failures.
- [x] Empty collection must create no resource row.
- [x] Empty collection must create no evidence row.
- [x] Durable collection proof is deferred.
## No Promotion
- [x] No `tenant_configuration_resource_evidence` rows.
- [x] No raw Exchange payload persisted as evidence.
- [x] No normalized Exchange payload persisted as evidence.
- [x] No content-backed state.
- [x] No comparable state.
- [x] No renderable state.
- [x] No certified state.
- [x] No restore-ready state.
- [x] No customer-ready state.
## Product Surface / Trigger
- [x] No routes.
- [x] No Filament pages/resources/widgets.
- [x] No Livewire components.
- [x] No Blade views.
- [x] No navigation.
- [x] No asset changes.
- [x] No global search changes.
- [x] No job trigger.
- [x] No schedule trigger.
- [x] No listener trigger.
- [x] Browser N/A.
## Architecture
- [x] Uses existing Exchange adapter/readiness architecture.
- [x] No migration.
- [x] No `tenant_id` ownership truth.
- [x] No Exchange-specific evidence table.
- [x] No Exchange mini-platform.
- [x] No legacy shim.
- [x] No fallback reader.
- [x] No dual write.
## Validation
- [x] Focused Spec 435 unit tests pass.
- [x] Focused Spec 435 feature tests pass.
- [x] Spec 434 regression passes.
- [x] Spec 433 regression passes.
- [x] Spec 432/431/430 regression passes.
- [x] Spec 415/417/419/420/426/427 regression passes.
- [x] Provider capability registry/evaluation regression passes.
- [x] Pint passes.
- [x] `git diff --check` passes.
- [x] `git status --short` is documented.
## Final Candidate Gate
- [x] PASS for preparation.
- [x] PASS for implementation.
- [ ] PASS WITH CONDITIONS for implementation.
- [ ] FAIL.
Preparation PASS rationale: `spec.md`, `plan.md`, and `tasks.md` define a bounded, testable, no-application-implementation package. Implementation PASS requires all applicable unchecked runtime validation items above to be checked with evidence.
FAIL if evidence rows are created, content-backed promotion occurs, raw stdout/stderr is treated as evidence, remoteDomain identity is faked, inboundConnector protected config leaks, unsafe output passes readiness, unsafe identity passes readiness, UI/routes/jobs/schedules/listeners are added, migration is added without amendment, `tenant_id` ownership truth is introduced, an Exchange-specific evidence table appears, or tests do not prove normalizer readiness safety.

View File

@ -0,0 +1,188 @@
# Implementation Report: Exchange Structured Output and Target Normalizer Readiness
Date: 2026-07-08
## Result
- **Status**: PASS.
- **Active spec**: `specs/435-exchange-structured-output-target-normalizer-readiness/`.
- **Branch**: `435-exchange-structured-output-target-normalizer-readiness`.
- **Requested branch label**: `feat/435-exchange-structured-output-target-normalizer-readiness`.
- **Branch-name exception**: recorded. Runtime/test implementation stayed on the current Spec Kit preparation branch because the active Spec 435 package was already untracked in this worktree; no branch switch was performed mid-dirty state.
- **HEAD at implementation start/end**: `a23131cd feat: add Exchange evidence capture adapter guard (#501)`.
- **Initial dirty state**: active Spec 435 package only.
- **Final dirty state**: active Spec 435 package plus intended Spec 435 runtime/test files.
- **Implementation/fix iterations**: 2 post-implementation findings fixed.
## Activated Skills and Gates
- `spec-kit-implementation-loop`: active implementation workflow.
- `pest-testing`: Pest 4 unit/feature coverage and validation.
- `tenantpilot-spec-readiness-gate`: active spec package readiness and close-out alignment.
- `tenantpilot-workspace-scope-safety`: no `tenant_id`, no scope/persistence drift, no provider-connection ownership drift.
- `tenantpilot-evidence-anchor-contract`: no evidence rows, no OperationRun-as-proof, no raw payload proof.
- `tenantpilot-provider-freshness-semantics`: runner success is not provider readiness or customer-safe evidence.
- `tenantpilot-operation-run-truth`: sanitized context only; no OperationRun lifecycle/start UX changes.
- Hard-gate stop conditions: none.
## Implementation Summary
- Added `ExchangePowerShellStructuredOutputEnvelope` as a safe in-memory envelope for structured Exchange runner output.
- Added target readiness dispatch through `ExchangePowerShellEvidenceNormalizer`.
- Added target-specific readiness normalizers for exactly:
- `transportRule`
- `remoteDomain`
- `inboundConnector`
- Added `ExchangePowerShellHashInputBuilder` and `ExchangePowerShellNormalizerReadiness` for deterministic in-memory hash previews and safe readiness summaries.
- Added focused Spec 435 unit and feature tests.
No evidence append, resource upsert, Graph/Exchange call, PowerShell execution, UI surface, job, listener, schedule, migration, `tenant_id`, customer output, restore, certification, compare/render promotion, or mini-platform was added.
## Prerequisite Proof
- Spec 434 implementation report records PASS and merge-ready after close-out hotfix.
- Spec 433 implementation report records PASS WITH CONDITIONS; the existing condition is outside this no-UI readiness slice.
- Spec 432 implementation report records PASS for production runner boundary, output guard, process executor abstraction, timeout/concurrency guards, and sanitized OperationRun context.
- Specs 430-434 were used as read-only context and were not edited.
## Structured Output Proof
- Accepted states:
- `structured_collection`
- `structured_empty_collection`
- Blocked states covered:
- malformed/scalar output
- warning-prefixed output
- binary/non-UTF8 output
- oversized output
- non-zero exit
- timeout
- Envelope safe summaries exclude raw stdout, raw stderr, transcripts, and credential/provider secret material.
- Empty collections stay distinguishable from malformed/failure states and create no durable proof.
## Target Matrix
| Type | Structured Output | Identity | Normalizer | Redaction | Hash Input | Ready for Spec 436? |
|---|---|---|---|---|---|---|
| `transportRule` | proven | stable `Guid`/source identity proven; display-only blocks | proven for state/mode, priority, conditions/actions/exceptions, volatile exclusions | sensitive conditions/actions redacted | deterministic, material/volatile/order tested | yes |
| `remoteDomain` | proven | source `Identity` proven for default/custom; display/domain-only/missing/duplicate/conflict block | proven for settings normalization | protected/sensitive remote-domain settings redacted | deterministic, includes target and normalizer metadata | yes |
| `inboundConnector` | proven | source `Identity` proven; missing/duplicate/conflict block | proven for routing settings | IPs, smart hosts, certificate names, comments, routing metadata redacted | deterministic, includes target and normalizer metadata | yes |
## No-Promotion Matrix
| Area | State |
|---|---|
| Evidence rows | No |
| Resource rows | No |
| Raw payload evidence | No |
| Normalized payload evidence | No |
| Content-backed | No |
| Compare | No |
| Render | No |
| Certification | No |
| Restore | No |
| Customer claim | No |
| UI | No |
| Jobs/Schedules/Listeners | No |
| Migration | No |
| `tenant_id` | No |
| Exchange mini-platform | No |
## 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; backend-only readiness helpers.
- **No-legacy confirmation**: canonical addition only; no aliases, fallback readers, hidden routes, duplicate UI, or compatibility shim.
- **Completed-spec rewrite assertion**: completed Specs 430-434 were not rewritten.
- **Livewire v4 compliance**: unchanged.
- **Provider registration location**: unchanged under `apps/platform/bootstrap/providers.php`.
- **Global search posture**: unchanged; no resources were added or modified.
- **Destructive/high-impact action posture**: none; no UI action or runtime start surface added.
- **Asset strategy**: none; `filament:assets` not required for this slice.
- **Deployment impact**: no env vars, migrations, queues, scheduler, storage, assets, or Dokploy runtime behavior changes.
## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec435 --compact`
- Result: failed before completion with Symfony Process signal 9. Non-Docker fallback used per tasks.
- `cd apps/platform && php artisan test --filter=Spec435 --compact`
- Result: PASS, 28 tests / 167 assertions.
- `cd apps/platform && php artisan test --filter=Spec434 --compact`
- Result: PASS, 25 tests / 154 assertions.
- `cd apps/platform && php artisan test --filter=Spec433 --compact`
- Result: PASS, 48 tests / 211 assertions.
- `cd apps/platform && php artisan test --filter='Spec432|Spec431|Spec430' --compact`
- Result: PASS, 173 tests / 1357 assertions.
- `cd apps/platform && php artisan test --filter='Spec415|Spec417|Spec419|Spec420|Spec426|Spec427' --compact`
- Result: PASS, 202 tests / 2137 assertions, 8 existing PostgreSQL-specific skips. This filter also ran existing browser smoke tests for Specs 419 and 420, both passing.
- `cd apps/platform && php artisan test --filter='ProviderCapabilityRegistryTest|ProviderCapabilityEvaluationTest' --compact`
- Result: PASS, 7 tests / 30 assertions.
- `cd apps/platform && ./vendor/bin/pint --dirty --format agent`
- Result: fixed one test file initially; final rerun PASS.
- `git diff --check`
- Result: PASS. Note: active Spec 435 files are untracked, so this command does not inspect their content until staged/tracked; syntax checks, Pint, and tests covered the new file content.
- `git status --short`
- Result: intended Spec 435 runtime/test files plus active Spec 435 package only.
## Post-Implementation Analysis
Iteration 1 confirmed and fixed one in-scope finding:
- **Medium**: readiness previews preserved provider collection order for multi-item collections, while Spec 435 requires deterministic collection ordering. Fixed by sorting previews by stable identity value with identity/hash tie-breakers and added a regression test.
Iteration 2 confirmed and fixed one in-scope finding:
- **High**: structured output envelopes preserved forbidden secret-like provider fields in public `items` state even though `safeSummary()` excluded them. Fixed by recursively stripping forbidden secret, credential, token, authorization, cookie, transcript, and raw-output key families from accepted envelope items while preserving normalizer-relevant Exchange fields. Added regression coverage that asserts public envelope items no longer contain those fields.
Remaining in-scope findings: none.
Residual risks: none for Spec 435. Evidence append and customer/product promotion remain deferred to Spec 436 or later.
## Files Changed
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellStructuredOutputEnvelope.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellTargetEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangeTransportRuleEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangeRemoteDomainEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangeInboundConnectorEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellNormalizerReadiness.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellHashInputBuilder.php`
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec435ExchangeStructuredOutputEnvelopeTest.php`
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec435ExchangeNormalizerReadinessTest.php`
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec435ExchangeHashInputReadinessTest.php`
- `apps/platform/tests/Feature/TenantConfiguration/Spec435ExchangeNoEvidencePromotionTest.php`
- `specs/435-exchange-structured-output-target-normalizer-readiness/spec.md`
- `specs/435-exchange-structured-output-target-normalizer-readiness/plan.md`
- `specs/435-exchange-structured-output-target-normalizer-readiness/tasks.md`
- `specs/435-exchange-structured-output-target-normalizer-readiness/checklists/requirements.md`
- `specs/435-exchange-structured-output-target-normalizer-readiness/implementation-report.md`
## 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 `435-exchange-structured-output-target-normalizer-readiness` streng repo-basiert durch.
Ziel:
Prüfe, ob die Implementierung nach dem Agenten-Loop wirklich merge-ready ist.
Wichtig:
- Keine Implementierung.
- Keine Codeänderungen.
- Keine Scope-Erweiterung.
- Prüfe gegen spec.md, plan.md, tasks.md und constitution.md.
- Prüfe die geänderten 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.
```

View File

@ -0,0 +1,364 @@
# Implementation Plan: Exchange Structured Output and Target Normalizer Readiness
**Branch**: `435-exchange-structured-output-target-normalizer-readiness`
**Date**: 2026-07-08
**Spec**: `specs/435-exchange-structured-output-target-normalizer-readiness/spec.md`
**Input**: User-provided Spec 435 draft and repo evidence from Specs 430-434.
## Summary
Prepare Exchange structured runner output and target normalizer readiness for exactly `transportRule`, `remoteDomain`, and `inboundConnector`. The implementation must define a safe structured envelope, prove raw stdout/stderr are not evidence, add or refine target-specific normalizer readiness, prove or explicitly block identity/redaction safety, define deterministic ordering/hash input rules, preserve empty-collection no-evidence behavior, and prove no evidence rows or product promotion occur.
Spec 435 is readiness-only. It must not create evidence, promote `content_backed`, call Exchange, execute tenant PowerShell, add UI, add jobs/schedules/listeners, add migrations, introduce `tenant_id`, or create customer claims.
## Technical Context
- **Language/Version**: PHP 8.4.15, Laravel 12.
- **Primary Dependencies**: existing TenantConfiguration/Coverage v2 services, Exchange PowerShell runner boundary, Pest 4 tests.
- **Storage**: no schema change and no new persistence.
- **External Calls**: none. Tests use fake process executor or fake runner output only.
- **UI**: none. Filament v5/Livewire v4 compliance unchanged.
- **OperationRun**: no new operation type or UX. Existing `tenant_configuration.capture` remains the future evidence-owning operation from Spec 434; Spec 435 only proves context remains sanitized.
- **Evidence Truth**: no evidence rows, no raw/normalized evidence persistence, no content-backed promotion.
- **Provider Boundary**: Exchange output shape and target normalization are provider-owned; Coverage v2, identity safety, redaction, and claim boundaries remain platform-core.
## Constitution and Gate Alignment
- **Proportionality**: The new envelope/normalizer readiness helpers are justified because evidence promotion would otherwise be unsafe. They are non-persisted, provider-owned, target-limited, and immediate prerequisites for Spec 436.
- **No premature abstraction**: Do not create a generic multi-provider PowerShell framework. Keep names and code under Exchange/TenantConfiguration boundaries.
- **No new persisted truth**: No migrations, tables, rows, or durable collection-level proof.
- **OperationRun truth**: OperationRun remains execution truth only; it is not customer proof or evidence payload truth.
- **Evidence anchor contract**: No evidence anchors or evidence rows are created. Future proof must be explicit and scoped in Spec 436.
- **Provider freshness semantics**: Do not treat runner success as provider readiness or customer-safe evidence.
- **Product Surface Contract**: N/A - no rendered UI surface changed.
- **Customer output gate**: no customer/auditor output, reports, Review Packs, PDFs, downloads, or customer-safe labels.
## Current Repo Evidence
- Spec 430 created Exchange PowerShell command contracts for `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector`.
- Spec 431 registered/gated Exchange PowerShell invocation through OperationRun/provider capability paths.
- Spec 432 added the production runner boundary, process executor abstraction, output guard, timeout/concurrency guards, and sanitized OperationRun context.
- Spec 433 added Exchange credential and permission readiness, including client-secret blocking and scoped/current `Exchange.ManageAsApp` proof.
- Spec 434 added the Exchange evidence capture adapter boundary, identity hard-stop, content-only writer guard, empty collection no-fake-evidence behavior, and generic Graph capture preservation.
## Likely Affected Runtime Surfaces for Later Implementation
These are planning targets only. This preparation pass does not edit application code.
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellProductionRunner.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellProcessCommandBuilder.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellOutputGuard.php`
- `apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangeTeamsComparablePayloadNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResolver.php`
- `apps/platform/config/tenantpilot.php`
Hard boundary: Spec 435 readiness flows must not invoke `ExchangePowerShellEvidenceCaptureAdapter::capture()`, `CoverageResourceUpserter::upsert()`, `CoverageEvidenceWriter::append()`, or any equivalent evidence/resource append path. If `ExchangePowerShellEvidenceCaptureAdapter.php` is touched, it must be for guard/regression coverage or explicit separation only; evidence append remains Spec 436.
`ExchangeTeamsComparablePayloadNormalizer.php` may be used only as read-only repo evidence for existing field handling, or for extracting non-comparable shared field lists if that is the narrowest safe implementation. Spec 435 must not expand comparable/renderable support for `remoteDomain`, `inboundConnector`, or any other Exchange type.
## Likely New Runtime Files for Later Implementation
Use repo-canonical names if current conventions differ.
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellStructuredOutputEnvelope.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangeTransportRuleEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangeRemoteDomainEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangeInboundConnectorEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellNormalizerReadiness.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellHashInputBuilder.php`
## Forbidden Runtime Surfaces
Implementation must stop and amend or split before touching:
- `apps/platform/routes/**`
- `apps/platform/app/Filament/**`
- `apps/platform/app/Livewire/**`
- `apps/platform/resources/views/**`
- `apps/platform/app/Jobs/**`
- `apps/platform/app/Listeners/**`
- `apps/platform/app/Console/**`
- database migrations
- customer report, Review Pack, PDF, restore, certification, compare/render customer code
- Exchange-specific evidence tables
- `tenant_id` ownership truth
- legacy shims, fallback readers, dual writes, mini-platform structures
## Architecture Decisions
### Structured Output Envelope
Define a value object or service equivalent to `ExchangePowerShellStructuredOutputEnvelope` with:
- `resource_type`
- `command_contract_name`
- `command_contract_version`
- `source_surface = exchange_online_powershell_rest`
- `runner_mode`
- `shape_state`
- `items`
- `item_count`
- `empty_collection`
- `warnings_classified`
- `output_guard_state`
- `normalizer_readiness_state`
- `redaction_readiness_state`
- `identity_readiness_state`
The envelope must explicitly exclude raw stdout/stderr, transcripts, free-text serialized objects, credentials, provider secrets, tokens, authorization headers, and cookies.
### Shape State Mapping
Use repo-canonical equivalents when existing names differ. Required semantics:
- accepted: `structured_collection`, `structured_empty_collection`
- blocked: malformed, scalar, warning-prefixed, binary, non-UTF8, oversized, non-zero exit, timeout, unknown
Shape blockers are readiness blockers, not evidence outcomes.
Minimum repo-code mapping for current implementation review:
| Spec semantic | Current repo code or required mapping |
|---|---|
| `structured_collection` | `structured_collection` |
| `structured_empty_collection` | `empty_collection` unless a repo-canonical envelope alias is added in memory only |
| malformed collection | `malformed_json_collection` or `malformed_collection_item` |
| warning-prefixed output | `warning_prefixed` |
| binary/non-UTF8 output | `non_utf8_or_binary` |
| oversized output | `stdout_oversized`, `stderr_oversized`, or repo-equivalent aggregate blocker |
| non-zero exit | `nonzero_exit` |
| timeout | `timeout` |
### Target Normalizer Readiness
Each target normalizer or readiness evaluator defines:
- resource type and source surface
- command contract name/version
- payload shape version and normalizer version
- identity candidate fields
- material, volatile, and sensitive fields
- ordering rules
- hash input rules
- redaction policy
- readiness blockers
No normalizer is a renderer or compare engine. In-memory normalized previews and hash previews are allowed in tests only.
### Identity Readiness
Stable identity is required for future evidence append. Blockers include:
- `identity_conflict`
- `missing_external_id`
- `unsupported_identity`
- `display_name_only`
- `duplicate_identity`
- `identity_unproven`
`remoteDomain` must prove source `Identity` stability or stay explicitly blocked. Domain/display-name derived identity must not pass silently.
Minimum identity-code mapping for current implementation review:
| Spec semantic | Current repo code or required mapping |
|---|---|
| `identity_conflict` | `identity_conflict` |
| `missing_external_id` / missing identity | `missing_stable_external_id` or repo-equivalent readiness blocker |
| `unsupported_identity` | `unsupported_identity` |
| derived/domain-only identity | `derived_identity_blocked` |
| duplicate identity | `duplicate_stable_identity` |
| display-name-only identity | must map to a derived/unsupported/display-name-only readiness blocker and fail closed |
| identity unproven | `blocked_identity_unproven` or repo-equivalent readiness blocker |
### Redaction Readiness
No raw provider payload, stdout/stderr, serialized Exchange object, transcript, secret, token, credential material, mail/message/file content, transport rule sensitive pattern, or inbound connector protected config may enter OperationRun context/logs/summaries.
Allowed test-only previews must use redacted placeholders, protected-config markers, count-only metadata, or field-presence metadata.
### Hash Input Readiness
Hash previews must be deterministic and based only on normalized material payload, target type, source surface, command contract metadata, payload shape version, and normalizer version according to repo pattern. They must exclude raw output, OperationRun context, runtime timestamps, PowerShell session metadata, credential readiness, and permission readiness.
## Target Matrix
| Type | Command | Structured Output | Identity | Normalizer | Redaction | Hash Input | Ready for Spec 436? |
|---|---|---|---|---|---|---|---|
| `transportRule` | `Get-TransportRule` | prove | prove stable identity | prove deterministic rules/actions/exceptions/order | classify sensitive patterns/words/header values | prove deterministic | yes if all tests pass |
| `remoteDomain` | `Get-RemoteDomain` | prove | prove source `Identity` or block | prove settings normalization or block | classify sensitive fields | prove deterministic | yes or explicit blocker |
| `inboundConnector` | `Get-InboundConnector` | prove | prove stable connector identity or block | prove deterministic fields/order or block | prove protected config redaction or block | prove deterministic | yes or explicit blocker |
## No-Promotion Matrix
| Area | Required State |
|---|---|
| Evidence rows | No |
| Raw payload evidence | No |
| Normalized payload evidence | No |
| Content-backed | No |
| Compare | No |
| Render | No |
| Certification | No |
| Restore | No |
| Customer claim | No |
| UI | No |
| Jobs/Schedules/Listeners | No |
| Migration | No |
| `tenant_id` | No |
## Data and Migration Impact
No migration. No new table. No new persisted entity. No new durable artifact. No `tenant_id`. No Exchange-specific evidence table. No raw payload table. No customer output table. No UI state table. No legacy snapshot table.
If implementation discovers persistence is required, stop and amend the spec before application code changes continue.
## UI / Filament / Livewire Impact
- No rendered UI surface changed.
- No route, Filament page/resource/widget, Livewire component, Blade view, navigation item, action, asset, or global search change.
- Livewire v4 compliance unchanged.
- Provider registration location unchanged under `apps/platform/bootstrap/providers.php`.
- Destructive/high-impact UI actions: none.
- Asset strategy: none; no `filament:assets` deployment impact.
- Browser proof: N/A - no rendered UI surface changed.
## Test Strategy
### Unit Tests
Required focused files or repo-canonical equivalents:
- `Spec435ExchangeStructuredOutputEnvelopeTest`
- `Spec435ExchangeTransportRuleNormalizerReadinessTest`
- `Spec435ExchangeRemoteDomainNormalizerReadinessTest`
- `Spec435ExchangeInboundConnectorNormalizerReadinessTest`
- `Spec435ExchangeIdentityReadinessTest`
- `Spec435ExchangeRedactionReadinessTest`
- `Spec435ExchangeHashInputReadinessTest`
- `Spec435ExchangeEmptyCollectionReadinessTest`
- `Spec435ExchangeFailureReadinessTest`
- `Spec435NoEvidencePromotionTest`
- `Spec435NoProductSurfaceTest`
### Feature Tests
Required focused files or repo-canonical equivalents:
- `Spec435ExchangeNormalizerReadinessFeatureTest`
- `Spec435ExchangeStructuredOutputReadinessFeatureTest`
- `Spec435ExchangeNoEvidenceFeatureTest`
- `Spec435ExchangeNoProductSurfaceFeatureTest`
- `Spec435ExchangeNoTenantIdFeatureTest`
### Regression Tests
Run focused regressions for Specs 434, 433, 432, 431, 430, 415, 417, 419, 420, 426, 427, and provider capability registry/evaluation tests.
### Browser Tests
N/A - no rendered UI surface changed. If any UI change appears, stop and amend or split.
## Implementation Phases
### Phase 0 - Preflight
- Capture branch, HEAD, and dirty state.
- Confirm runtime implementation branch is `feat/435-exchange-structured-output-target-normalizer-readiness` or record an explicit Spec Kit branch-name exception before changing runtime/test code.
- Confirm Spec 434 merge-ready and no active checklist/report blockers.
- Confirm target scope and forbidden surfaces.
- Confirm current runner output, normalizer, identity, redaction, and hash state.
- Confirm readiness implementation will not call the existing capture/upsert/evidence-writer append path.
### Phase 1 - Structured Output Envelope
- Add structured output envelope.
- Exclude raw stdout/stderr and credential/provider secrets.
- Add shape state handling and output blocker mapping.
- Test malformed/scalar/warning/binary/non-UTF8/oversized/non-zero/timeout behavior.
### Phase 2 - transportRule Readiness
- Add/refine transportRule normalizer readiness.
- Prove stable identity, deterministic ordering, state/mode, conditions/actions/exceptions, volatile exclusions, sensitive classification, and hash rules.
### Phase 3 - remoteDomain Readiness
- Prove source `Identity` stability or block.
- Distinguish default and custom remote domains.
- Block missing/duplicate/domain-only/display-name-only identity.
- Define deterministic settings normalization, volatile fields, redaction, and hash rules.
### Phase 4 - inboundConnector Readiness
- Prove stable connector identity or block.
- Prove missing/duplicate identity blocks.
- Classify and redact IPs, smart hosts, certificate names, comments, and routing metadata.
- Define deterministic normalization and hash rules.
### Phase 5 - Hash / Redaction / Empty Collection
- Prove hash determinism and volatile/order behavior.
- Prove protected config cannot enter OperationRun context/logs/summaries.
- Preserve empty collection no-resource/no-evidence policy.
### Phase 6 - No-Promotion Safety
- Prove no evidence rows.
- Prove no content-backed or product claim state.
- Prove readiness dispatch does not invoke `ExchangePowerShellEvidenceCaptureAdapter::capture()`, `CoverageResourceUpserter::upsert()`, or `CoverageEvidenceWriter::append()`.
- Prove no compare/render/certification/restore/customer output.
- Prove no UI, routes, jobs, schedules, listeners, migrations, `tenant_id`, or mini-platform.
### Phase 7 - Regression / Report
- Run focused tests and regressions.
- Run Pint and `git diff --check`.
- Complete implementation report with target matrix and no-promotion matrix.
## Validation Commands
Preferred local commands:
```bash
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec435 --compact
cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec434 --compact
cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec433 --compact
cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec432|Spec431|Spec430' --compact
cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec415|Spec417|Spec419|Spec420|Spec426|Spec427' --compact
cd apps/platform && ./vendor/bin/sail artisan test --filter='ProviderCapabilityRegistryTest|ProviderCapabilityEvaluationTest' --compact
git diff --check
git status --short
```
If Sail is unavailable, document the failure and use the repo's non-Docker fallback for the same focused filters:
```bash
cd apps/platform && php artisan test --filter=Spec435 --compact
```
## Rollout and Deployment Impact
- Environment variables: none expected.
- Migrations: none.
- Queues/workers: none.
- Scheduler: none.
- Storage/volumes: none.
- Assets: none.
- Dokploy/staging/production: no deployment behavior change unless implementation unexpectedly adds runtime config, which requires spec amendment.
## Risk Controls
- Stop if evidence persistence is needed.
- Stop if UI or trigger surface is needed.
- Stop if `tenant_id`, migration, Exchange evidence table, or mini-platform appears.
- Stop if raw stdout/stderr or protected config is required outside guarded internals.
- Stop if `remoteDomain` or `inboundConnector` cannot prove identity/redaction and the implementation tries to pass instead of block.
## Spec Readiness Gate
PASS for preparation. The plan names affected surfaces, forbidden surfaces, phases, tests, no-promotion constraints, Product Surface N/A path, proportionality controls, and validation commands. No application implementation has been performed.

View File

@ -0,0 +1,342 @@
# Feature Specification: Exchange Structured Output and Target Normalizer Readiness
**Feature Branch**: `435-exchange-structured-output-target-normalizer-readiness`
**Requested Branch Label**: `feat/435-exchange-structured-output-target-normalizer-readiness`
**Created**: 2026-07-08
**Status**: Draft
**Input**: User-provided Spec 435 draft for Exchange structured runner output, target normalizer readiness, identity readiness, redaction readiness, and no-promotion safety.
**Implementation Branch Gate**: Before runtime implementation starts, work must either run on `feat/435-exchange-structured-output-target-normalizer-readiness` per `AGENTS.md` or record an explicit Spec Kit branch-name exception in the implementation report. The current preparation branch is acceptable for spec artifact work only.
## Preparation Selection
- **Selected candidate**: Spec 435 - Exchange Structured Output and Target Normalizer Readiness.
- **Source location**: User-provided manual candidate in the current request.
- **Why selected**: The active auto-prep queue in `docs/product/spec-candidates.md` has no safe automatic target, but this user-provided P0 candidate directly follows implemented Spec 434 and corrects the roadmap from premature content-backed promotion to the readiness work needed before evidence can be honest.
- **Close alternatives deferred**: Spec 436 content-backed evidence promotion, Spec 437 comparable/renderable promotion, Teams PowerShell slices, customer output, restore, certification, UI readiness badges, and Exchange target expansion are deferred because target payload semantics, identity, redaction, and hash rules are not yet proven.
- **Roadmap relationship**: Continues the Exchange sequence after Specs 429-434. Spec 435 prepares target payload semantics so Spec 436 can append actual evidence rows later.
- **Completed-spec guardrail result**: Specs 430-434 are read-only implementation context and were not modified. Spec 434 implementation report records PASS and merge-ready after close-out hotfix. No existing `specs/435-*` package or branch was found before preparation.
- **Smallest viable implementation slice**: Define a safe structured Exchange runner output envelope and target-specific normalizer readiness for exactly `transportRule`, `remoteDomain`, and `inboundConnector`, with explicit blockers for unsafe shape, identity, redaction, and hash input readiness. No evidence rows, no content-backed promotion, no UI, no triggers, no migrations, and no customer claims.
- **Feature description fed into Spec Kit**: Prepare structured Exchange runner output and target normalizer readiness for `transportRule`, `remoteDomain`, and `inboundConnector`, proving raw stdout is never evidence, identity and redaction blockers are explicit, hash input and ordering rules are deterministic, empty collections remain no-evidence, and no content-backed, comparable, renderable, certified, restore-ready, customer-ready, UI, route, job, schedule, listener, migration, `tenant_id`, or Exchange mini-platform state is introduced.
## Spec Candidate Check
- **Problem**: TenantPilot has an Exchange evidence adapter boundary from Spec 434, but Exchange runner output and target payload normalization are not yet safe enough for evidence promotion.
- **Today's failure**: The product could otherwise mistake runner success, raw stdout, unstable target identity, or unredacted protected configuration for evidence readiness.
- **User-visible improvement**: Operators and reviewers gain a truthful internal readiness claim: Exchange target output is either structured, normalizer-ready, identity-safe, redaction-safe, and hash-ready, or blocked with a precise reason before evidence promotion.
- **Smallest enterprise-capable version**: A backend-only readiness contract for exactly `transportRule`, `remoteDomain`, and `inboundConnector`; in-memory normalized previews and hash previews may exist in tests, but no evidence persistence or product claims are added.
- **Explicit non-goals**: No evidence rows, no content-backed promotion, no compare/render, no certification, no restore, no customer output, no UI, no jobs/schedules/listeners, no live Exchange calls, no target expansion, no migration, no `tenant_id`, no mini-platform.
- **Permanent complexity imported**: A narrow structured output envelope, target-specific normalizer readiness helpers, blocker strings, hash input builder, focused tests, and preparation artifacts. No persisted entity, no UI framework, and no cross-domain taxonomy.
- **Why now**: Spec 436 would otherwise create evidence without proven shape, identity, redaction, and hash semantics for the first Exchange PowerShell targets.
- **Why not local**: Three target types need the same safety boundary between runner output and future evidence append. Keeping this as ad-hoc test logic would make Spec 436 harder to audit and easier to over-promote.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: New abstraction and foundation-like readiness layer. Defense: the layer is bounded to three current targets, prevents unsafe evidence claims, and is a direct prerequisite for the next roadmap slice.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve.
## Spec Scope Fields
- **Scope**: managed-environment backend readiness under existing workspace, managed environment, provider connection, OperationRun, and Coverage v2 boundaries.
- **Primary Routes**: none.
- **Data Ownership**: no new persisted ownership. Future runtime work must preserve workspace + managed environment + provider connection scope and must not introduce platform-core `tenant_id` ownership truth.
- **RBAC**: no new operator action or route. Future runtime tests must prove readiness is reachable only through trusted backend flows and does not bypass existing provider/capture authorization.
## No Legacy / No Backward Compatibility Constraint
- **Compatibility posture**: canonical replacement / no compatibility exception.
- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no.
- **Why clean replacement is safe now**: Exchange evidence promotion is not customer-shipped. Unsafe runner-output assumptions should fail closed rather than be preserved.
## UI Surface Impact
- [x] No UI surface impact
- [ ] Existing page changed
- [ ] New page/route added
- [ ] Navigation changed
- [ ] Filament panel/provider surface changed
- [ ] New modal/drawer/wizard/action added
- [ ] New table/form/state added
- [ ] Customer-facing surface changed
- [ ] Dangerous action changed
- [ ] Status/evidence/review presentation changed
- [ ] Workspace/environment context presentation changed
## UI/Productization Coverage
N/A - no reachable UI surface impact. Spec 435 forbids routes, Filament pages/resources/widgets, Livewire components, Blade views, navigation, actions, assets, global search changes, reports, downloads, and rendered customer/operator surfaces.
## Product Surface Impact
Reference: `docs/product/standards/product-surface-contract.md`.
- **Product Surface Contract applies?**: no runtime UI surface change; gate is recorded as N/A.
- **Page archetype**: N/A.
- **Primary user question**: N/A.
- **Primary action**: N/A.
- **Surface budget result**: N/A.
- **Technical Annex / deep-link demotion**: N/A - no rendered product surface. Raw output, OperationRun context, provider payloads, protected config, and normalized previews remain backend/test-only and never customer-visible.
- **Canonical status vocabulary**: N/A for UI. Internal readiness blockers are not product-facing status vocabulary.
- **Visible complexity impact**: neutral.
- **Product Surface exceptions**: none.
## Browser Verification Plan
- **Browser proof required?**: no.
- **No-browser rationale**: `N/A - no rendered UI surface changed`.
- **Focused path when required**: N/A.
- **Primary interaction to execute**: N/A.
- **Console, Livewire, Filament, network, and 500-error checks**: N/A.
- **Full-suite failure triage**: N/A.
## Human Product Sanity Check
- **Required?**: no.
- **No-human-sanity rationale**: N/A - no product surface changed.
- **Reviewer questions**: N/A.
- **Planned result location**: implementation report must record `N/A - no rendered UI surface changed`.
## Product Surface Merge Gate Checklist
- [x] No-legacy posture or approved exception recorded.
- [x] Product Surface Impact is completed as `N/A`.
- [x] Browser proof is planned as `N/A - no rendered UI surface changed`.
- [x] Human Product Sanity is not applicable with rationale.
- [x] Product Surface exceptions are `none`.
- [x] Implementation report will state Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, and visible complexity outcome.
## Cross-Cutting / Shared Pattern Reuse
- **Cross-cutting feature?**: yes, backend evidence/capture readiness only.
- **Interaction class(es)**: evidence readiness, provider runner output readiness, OperationRun context sanitization.
- **Systems touched**: existing TenantConfiguration/Coverage v2 Exchange PowerShell runner, output guard, capture adapter, canonical identity, redaction, and provider capability readiness support.
- **Existing pattern(s) to extend**: `ExchangePowerShellCommandContracts`, `ExchangePowerShellOutputGuard`, `ExchangePowerShellProductionRunner`, `ExchangePowerShellEvidenceCaptureAdapter`, `CanonicalIdentityResolver`, `CoverageIdentityStrategyRegistry`, `CoveragePayloadRedactor`, `CoverageEvidenceWriter` boundaries.
- **Shared contract / presenter / builder / renderer to reuse**: backend services only; no UI presenter or renderer.
- **Why the existing shared path is sufficient or insufficient**: Existing capture/evidence paths are sufficient for generic Graph and Spec 434 adapter guard behavior. They are insufficient for Exchange target payload semantics because runner output shape, target identity, redaction, ordering, and hash readiness must be proven before evidence append.
- **Allowed deviation and why**: bounded provider-owned Exchange normalizer readiness helpers are allowed because Exchange PowerShell object shapes are provider-specific and must not leak into platform-core truth.
- **Consistency impact**: OperationRun stays execution truth, evidence rows remain absent, coverage promotion remains blocked, and raw output stays out of contexts/logs/summaries.
- **Review focus**: verify no runtime Product Surface framework, no evidence append, no raw stdout evidence, no target expansion, and no customer claim.
Spec 435 readiness evaluators must not call `ExchangePowerShellEvidenceCaptureAdapter::capture()`, `CoverageResourceUpserter::upsert()`, `CoverageEvidenceWriter::append()`, or any other evidence/resource append path. If those existing files are touched, the change must only add guards or regression coverage proving readiness and capture remain separate; actual evidence append remains deferred to Spec 436.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no.
- **Shared OperationRun UX contract/layer reused**: N/A.
- **Delegated start/completion UX behaviors**: N/A.
- **Local surface-owned behavior that remains**: none.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: N/A.
- **Exception required?**: none.
Spec 435 may inspect or sanitize OperationRun context behavior in tests, but it must not add an OperationRun type, start surface, link, notification, job, schedule, listener, or customer proof claim.
## Provider Boundary / Platform Core Check
- **Shared provider/platform boundary touched?**: yes.
- **Boundary classification**: mixed. Exchange PowerShell object shape and command contracts are provider-owned. Coverage v2 evidence safety, OperationRun truth, identity safety, redaction, and no-customer-claim boundaries are platform-core.
- **Seams affected**: Exchange runner output, output guard, command contract metadata, target normalizer readiness, identity readiness, redaction readiness, hash input rules, OperationRun context sanitization.
- **Neutral platform terms preserved or introduced**: provider connection, managed environment, resource type, evidence, readiness blocker, OperationRun, normalized payload preview, hash input.
- **Provider-specific semantics retained and why**: `Get-TransportRule`, `Get-RemoteDomain`, `Get-InboundConnector`, Exchange source surface `exchange_online_powershell_rest`, and target-specific protected fields are retained because this is a bounded Exchange readiness spec.
- **Why this does not deepen provider coupling accidentally**: provider-specific shape handling stays behind Exchange-named services and tests; platform evidence storage and customer surfaces remain untouched.
- **Follow-up path**: Spec 436 may use this readiness proof to implement evidence append, but only after no-promotion and redaction gates pass.
## UI / Surface Guardrail Impact
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Exchange structured-output and normalizer readiness | no | N/A | evidence/readiness backend only | backend readiness helpers only | no | `N/A - no rendered UI surface changed` |
## Proportionality Review
- **New source of truth?**: no. The envelope and normalized previews are readiness artifacts, not evidence or persisted truth.
- **New persisted entity/table/artifact?**: no.
- **New abstraction?**: yes. A narrow structured output envelope and target normalizer readiness helpers may be introduced.
- **New enum/state/reason family?**: bounded internal readiness blocker strings may be introduced or mapped to repo-canonical equivalents; no persisted enum or product-facing status family.
- **New cross-domain UI framework/taxonomy?**: no.
- **Current operator problem**: Future Exchange evidence promotion would be unsafe if raw stdout, unstable identity, protected config, or nondeterministic payload ordering could be mistaken for evidence readiness.
- **Existing structure is insufficient because**: Spec 434 guards evidence append, but it does not prove target payload shape, identity, redaction, ordering, or hash input readiness for the first Exchange types.
- **Narrowest correct implementation**: one provider-owned Exchange envelope/readiness path for exactly three read-only targets, with tests and explicit blockers, no persistence, and no product surface.
- **Ownership cost**: maintain target-specific readiness fixtures, blocker mapping, hash input expectations, and no-promotion regressions until Spec 436 consumes them.
- **Alternative intentionally rejected**: proceed directly to content-backed evidence promotion and discover unsafe shape/identity/redaction during persistence.
- **Release truth**: current-release prerequisite for immediate next Exchange evidence promotion, not a generic multi-provider framework.
## Testing / Lane / Runtime Impact
- **Test purpose / classification**: Unit and Feature.
- **Validation lane(s)**: fast-feedback/confidence; browser N/A.
- **Why this classification and these lanes are sufficient**: Target normalizers, hash input, redaction, identity blockers, and no-promotion assertions can be proven with fake runner output and DB-backed guard tests. No UI or browser workflow exists.
- **New or expanded test families**: focused Spec 435 TenantConfiguration unit/feature tests.
- **Fixture / helper cost impact**: local Exchange structured output fixtures only; no Sail/browser/provider setup defaults should be widened.
- **Heavy-family visibility / justification**: none.
- **Special surface test profile**: N/A.
- **Standard-native relief or required special coverage**: browser `N/A - no rendered UI surface changed`.
- **Reviewer handoff**: confirm tests prove readiness without creating evidence, content-backed state, UI/trigger surfaces, migration, `tenant_id`, or raw payload leakage.
- **Budget / baseline / trend impact**: expected small focused additions; no heavy-governance or browser budget impact.
- **Escalation needed**: document-in-feature for any PASS WITH CONDITIONS target blockers; reject-or-split for evidence persistence, UI, migration, target expansion, compare/render, certification, restore, or customer output.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage: N/A no rendered UI surface changed.
- **Planned validation commands**: see `tasks.md` Phase 8.
## User Scenarios and Testing
### User Story 1 - Prove Safe Structured Exchange Output (Priority: P1)
As a platform reviewer, I need Exchange runner output represented as a structured envelope that excludes raw stdout/stderr, so future evidence promotion cannot treat shell text or runner success as evidence.
**Independent Test**: Feed fake successful, empty, malformed, scalar, warning-prefixed, binary/non-UTF8, oversized, non-zero, and timeout runner results into the envelope/output guard path and assert accepted shape or explicit readiness blocker.
**Acceptance Scenarios**:
1. **Given** a structured collection for `transportRule`, **When** readiness is evaluated, **Then** the envelope records `structured_collection`, item count, command metadata, and readiness states without raw stdout or stderr.
2. **Given** a structured empty collection, **When** readiness is evaluated, **Then** it is distinguishable from malformed output, permission denied, unavailable source, and warning/scalar/binary failures.
3. **Given** malformed, scalar, warning-prefixed, binary, oversized, non-zero, timeout, or unknown output, **When** readiness is evaluated, **Then** normalizer readiness blocks and no evidence or promotion occurs.
### User Story 2 - Prove Target Normalizer Readiness (Priority: P1)
As an evidence reviewer, I need `transportRule`, `remoteDomain`, and `inboundConnector` to have deterministic identity, normalization, redaction, ordering, and hash readiness rules before Spec 436 appends evidence.
**Independent Test**: Evaluate target-specific fixtures and assert either ready state or exact blocker for identity, normalizer, redaction, or hash readiness, with deterministic normalized preview and hash preview only in memory.
**Acceptance Scenarios**:
1. **Given** a valid `transportRule` collection, **When** normalizer readiness is evaluated, **Then** stable identity, deterministic condition/action/exception ordering, priority handling, volatile exclusions, sensitive field classification, and hash input rules pass.
2. **Given** `remoteDomain` fixtures, **When** identity readiness is evaluated, **Then** stable source `Identity` is proven by default/custom distinction and duplicate/missing checks, or readiness blocks as `blocked_identity_unproven`.
3. **Given** `inboundConnector` fixtures, **When** redaction readiness is evaluated, **Then** IPs, smart hosts, certificate names, comments, and routing metadata are protected and cannot enter OperationRun context; if this cannot be proven, readiness blocks as `blocked_redaction_unproven`.
### User Story 3 - Preserve No-Evidence and No-Product-Claim Safety (Priority: P1)
As a release reviewer, I need readiness evaluation to remain non-persistent and non-promotional, so the platform cannot claim Exchange content-backed evidence, compare/render, certification, restore, or customer readiness too early.
**Independent Test**: Run readiness evaluation through trusted backend flows and assert zero `tenant_configuration_resource_evidence` rows, no raw/normalized evidence persistence, no content-backed promotion, no comparable/renderable/certified/restore/customer state, no UI/trigger artifacts, no migration, and no `tenant_id`.
**Acceptance Scenarios**:
1. **Given** all three targets pass readiness, **When** the readiness flow completes, **Then** no evidence rows are created and no coverage level is promoted.
2. **Given** unsafe identity, unsafe shape, or unsafe redaction, **When** readiness is evaluated, **Then** an explicit readiness blocker is returned and no evidence/resource/customer claim is created.
3. **Given** the final diff, **When** static guard tests inspect forbidden surfaces, **Then** no routes, UI, jobs, schedules, listeners, migrations, customer output, review pack, PDF, restore, certification, compare/render, or Exchange-specific evidence table exist.
## Functional Requirements
- **FR-435-001**: The implementation MUST define a structured Exchange output envelope or repo-canonical equivalent for Exchange runner results.
- **FR-435-002**: The envelope MUST include resource type, command contract name/version, `exchange_online_powershell_rest` source surface, runner mode, shape state, item count, empty collection marker, warning classification, output guard state, normalizer readiness state, redaction readiness state, and identity readiness state.
- **FR-435-003**: The envelope MUST NOT include raw stdout, raw stderr, PowerShell transcripts, free-text serialized Exchange objects, credential material, provider secrets, tokens, authorization headers, or cookies.
- **FR-435-004**: The implementation MUST support or map to shape states for structured collection, structured empty collection, malformed, scalar, warning-prefixed, binary, non-UTF8, oversized, non-zero exit, timeout, and unknown blocked output.
- **FR-435-005**: Raw stdout/stderr MAY exist only inside runner/output guard internals long enough to classify output; they MUST NOT be evidence, normalized evidence, OperationRun context, logs, summaries, or customer output.
- **FR-435-006**: Target readiness MUST be limited to `transportRule`, `remoteDomain`, and `inboundConnector`.
- **FR-435-007**: Target normalizer readiness MUST define resource type, source surface, command contract name/version, payload shape version, normalizer version, identity candidate fields, material fields, volatile fields, sensitive fields, ordering rules, hash input rules, redaction policy, and readiness blockers.
- **FR-435-008**: Normalized payload previews and hash previews MAY be computed in memory and tests only; they MUST NOT be persisted as evidence.
- **FR-435-009**: `transportRule` readiness MUST prove stable identity, priority/order handling, state/mode handling, deterministic conditions/actions/exceptions normalization, volatile exclusions/demotion, sensitive field classification, deterministic collection ordering, and hash input rules.
- **FR-435-010**: `remoteDomain` readiness MUST either prove stable source `Identity` with default/custom distinction, missing/duplicate blocking, and no display-name/domain-only derived identity, or block with `blocked_identity_unproven` or repo-equivalent.
- **FR-435-011**: `remoteDomain` readiness MUST define settings normalization, volatile field handling, deterministic ordering, redaction policy, and hash input rules even when promotion remains blocked.
- **FR-435-012**: `inboundConnector` readiness MUST either prove stable connector identity, duplicate/missing blocking, deterministic normalization, protected config redaction, and hash input rules, or block with `blocked_redaction_unproven`, `blocked_identity_unproven`, or repo-equivalent.
- **FR-435-013**: `inboundConnector` readiness MUST classify IPs, smart hosts, certificate names, comments, and routing metadata as protected configuration or potentially sensitive content and must not place them in OperationRun context/logs/summaries.
- **FR-435-014**: Identity readiness MUST block identity conflict, missing external ID, unsupported identity, display-name-only identity, duplicate identity, and identity-unproven states.
- **FR-435-015**: Hash readiness MUST prove same normalized material payload yields the same hash preview, material changes yield different previews, volatile changes are ignored, provider order changes are deterministic when order is not meaningful, target type is included, and normalizer version is handled consistently.
- **FR-435-016**: Hash input MUST NOT include raw stdout, raw stderr, OperationRun context, runtime timestamps, PowerShell session metadata, credential metadata, permission metadata, or provider readiness metadata.
- **FR-435-017**: Redaction readiness MUST prove raw provider payloads, raw output, serialized Exchange objects, transcripts, secrets, credentials, tokens, authorization headers, cookies, mail/message/file content, transport rule sensitive patterns, and inbound connector protected config do not enter OperationRun context/logs/summaries/customer output.
- **FR-435-018**: Empty collection readiness MUST distinguish empty collections from permission denied, unavailable source, malformed output, warning/scalar/binary output, non-zero exit, and timeout.
- **FR-435-019**: Empty collections MUST create no resource row, no evidence row, no content-backed state, and no durable collection-level proof in Spec 435.
- **FR-435-020**: Failure readiness MUST map unsafe states to explicit blockers including malformed, scalar, warning-prefixed, binary, oversized, non-zero, timeout, identity unproven/missing/duplicate, redaction unproven, normalizer unready, hash unready, and target not ready.
- **FR-435-021**: The implementation MUST prove zero `tenant_configuration_resource_evidence` rows are created by Spec 435 flows.
- **FR-435-022**: The implementation MUST NOT promote any Exchange resource to `content_backed`, comparable, renderable, certified, restore-ready, customer-ready, or any product-claim state.
- **FR-435-023**: The implementation MUST NOT call Microsoft, connect to Exchange Online, execute PowerShell against a tenant, or require real credentials in tests.
- **FR-435-024**: Tests MUST use fake process executor or fake runner output only.
- **FR-435-025**: The implementation MUST NOT add routes, Filament pages/resources/widgets, Livewire components, Blade views, navigation, assets, global search changes, jobs, schedules, listeners, reports, review packs, PDFs, customer output, restore, certification, compare/render, migrations, Exchange-specific evidence tables, `tenant_id`, legacy shims, fallback readers, dual writes, or an Exchange mini-platform.
- **FR-435-026**: Readiness flows MUST NOT invoke `ExchangePowerShellEvidenceCaptureAdapter::capture()`, `CoverageResourceUpserter::upsert()`, `CoverageEvidenceWriter::append()`, or any equivalent resource/evidence append path; tests must prove the Spec 435 readiness path is separate from Spec 436 evidence promotion.
## Non-Functional Requirements
- **NFR-435-001**: Readiness logic must fail closed when shape, identity, redaction, normalizer, or hash safety is ambiguous.
- **NFR-435-002**: Readiness outcomes must be deterministic across repeated evaluations of the same structured payload.
- **NFR-435-003**: Readiness blockers and summaries must be sanitized and safe for OperationRun context.
- **NFR-435-004**: Future implementation must follow Laravel 12, PHP 8.4, Pest 4, Filament v5/Livewire v4 no-change rules, and existing TenantConfiguration/Coverage v2 conventions.
- **NFR-435-005**: The implementation must keep test fixture cost local and avoid widening shared setup defaults.
## Out of Scope
- Evidence row creation or evidence append for Exchange targets.
- Persisting raw Exchange provider payloads or normalized Exchange payloads as evidence.
- Content-backed, comparable, renderable, certified, restore-ready, or customer-ready promotion.
- Microsoft calls, Exchange Online connection, or real tenant PowerShell execution.
- Compare output, render models, certification, restore/apply, customer reports, Review Pack output, PDF output.
- Filament pages, Livewire components, routes, navigation, assets, global search changes, jobs, schedules, listeners, console triggers.
- Teams PowerShell, Exchange Admin API, outbound connector, accepted domain, organization config, mailbox plan, sharing policy, arbitrary Exchange commands, mutation commands.
- Exchange-specific evidence tables, migrations, `tenant_id` ownership truth, legacy snapshot shims, fallback readers, dual writes.
## Required Final State
| Field | Required State |
|---|---|
| structured_exchange_output_envelope_defined | true |
| raw_stdout_not_evidence | true |
| transportRule_normalizer_ready | true |
| remoteDomain_normalizer_ready_or_explicitly_blocked | true |
| remoteDomain_identity_rule_proven_or_blocked | true |
| inboundConnector_normalizer_ready_or_explicitly_blocked | true |
| inboundConnector_redaction_rule_proven_or_blocked | true |
| exchange_hash_input_rules_defined | true |
| exchange_ordering_rules_defined | true |
| unsafe_identity_readiness_blocks | true |
| unsafe_shape_readiness_blocks | true |
| unsafe_redaction_readiness_blocks | true |
| evidence_rows_created | false |
| content_backed_promotion | false |
| compare_render_certification_restore_customer_claims | false |
| ui_jobs_routes_schedules_listeners | false |
| tenant_id_ownership_truth | false |
## Acceptance Criteria
1. Structured Exchange output envelope exists and raw stdout/stderr are not evidence.
2. Unsafe output shapes block readiness with explicit blockers.
3. `transportRule` readiness is proven.
4. `remoteDomain` readiness is proven or explicitly blocked with exact identity/normalization blocker.
5. `inboundConnector` readiness is proven or explicitly blocked with exact identity/redaction/normalization blocker.
6. Identity readiness blocks display-name-only, derived/domain-only without proof, duplicate, missing, unsupported, and unproven identity.
7. Redaction readiness classifies protected configuration and prevents protected content from entering OperationRun context/logs/summaries.
8. Hash input rules are deterministic and ignore volatile/provider-order-only changes.
9. Empty collection remains distinguishable from failures and creates no resource/evidence row.
10. No evidence rows, raw evidence persistence, normalized evidence persistence, or content-backed promotion occurs.
11. No compare/render/certification/restore/customer state appears.
12. No routes, UI, assets, global search, jobs, schedules, listeners, migrations, `tenant_id`, Exchange evidence table, legacy shim, fallback reader, or mini-platform appears.
## Success Criteria
- **PASS**: All three included targets have structured output readiness and deterministic normalizer/identity/redaction/hash readiness for Spec 436, or remoteDomain/inboundConnector are explicitly blocked without weakening safety.
- **PASS WITH CONDITIONS**: Only bounded target-specific blockers remain, and they do not weaken structured-output safety, identity safety, redaction, hash determinism, no-evidence posture, no-promotion posture, no-product-surface posture, ownership, or no-mini-platform posture.
- **FAIL**: Evidence rows are created, content-backed promotion occurs, raw stdout/stderr is evidence, remoteDomain identity is faked, inboundConnector protected config leaks, unsafe output/identity/redaction passes, UI/triggers/migrations are added, `tenant_id` appears, or tests do not prove safety.
## Risks
- **R-435-001**: Envelope/blocker states become a new platform taxonomy. Mitigation: keep provider-owned, internal, non-persisted, and mapped to repo-canonical equivalents where available.
- **R-435-002**: `remoteDomain` identity is over-trusted. Mitigation: require source `Identity` proof with default/custom distinction, missing/duplicate blocks, and no domain-only derived fallback.
- **R-435-003**: `inboundConnector` redaction leaks protected config into OperationRun context. Mitigation: classify IPs/hosts/certificate names/comments/routing metadata and block if safe summaries cannot be proven.
- **R-435-004**: Hash previews accidentally become evidence. Mitigation: tests must assert previews are in-memory only and no evidence/hash persistence occurs.
- **R-435-005**: Spec 435 drifts into Spec 436 promotion. Mitigation: static and DB-backed no-promotion tests are required, including proof that readiness does not call the existing capture/upsert/evidence-writer append path.
- **R-435-006**: Conceptual blocker names drift from repo-canonical codes. Mitigation: plan and implementation report must map conceptual names to existing codes such as `empty_collection`, `missing_stable_external_id`, `derived_identity_blocked`, and `duplicate_stable_identity` instead of introducing a persisted status family.
## Assumptions
- Spec 434 is merge-ready and HEAD `a23131cd feat: add Exchange evidence capture adapter guard (#501)` includes the adapter/content-only guard proof.
- Spec 433 PASS WITH CONDITIONS does not block this no-UI readiness slice.
- Spec 432 runner boundary, output guard, process executor abstraction, and sanitized OperationRun context exist.
- Spec 430 command contracts exist for exactly `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector`.
- The implementation can use repo-canonical names instead of the conceptual class names in this spec when current code patterns differ.
## Open Questions
None blocking preparation. During implementation, if `remoteDomain` or `inboundConnector` identity/redaction safety cannot be proven from current contracts and fixtures, the valid outcome is PASS WITH CONDITIONS with an explicit blocker, not evidence promotion.
## Follow-up Spec Candidates
- Spec 436 - Exchange Content-Backed Evidence Promotion Slice 1.
- Spec 437 - Exchange Comparable / Renderable Promotion Slice 1.
- Teams PowerShell Adapter Contract Slice 1.
- Customer output and claim guard for Microsoft 365 only after evidence and compare/render claims are proven.
## Candidate Selection Gate
PASS. The candidate was directly provided by the user, follows implemented Specs 429-434, is not covered by an existing active/completed Spec 435 package, aligns with the corrected Exchange roadmap, is scoped as backend readiness only, and defers evidence promotion and product/customer surfaces.
## Spec Readiness Gate
PASS for preparation. `spec.md`, `plan.md`, `tasks.md`, `checklists/requirements.md`, and `implementation-report.md` define a bounded, testable package for a later implementation loop. No application implementation has been performed.

View File

@ -0,0 +1,183 @@
# Tasks: Exchange Structured Output and Target Normalizer Readiness
**Input**: Design documents from `specs/435-exchange-structured-output-target-normalizer-readiness/`
**Prerequisites**: `spec.md`, `plan.md`, `checklists/requirements.md`
**Implementation Mode**: structured-output and normalizer readiness only. No evidence rows, no content-backed promotion, no compare/render, no certification, no restore, no UI, no customer claims, no jobs/schedules/listeners, no migrations, no `tenant_id`.
## Test Governance Checklist
- [x] Lane assignment is named as Unit/Feature fast-feedback/confidence and is the narrowest sufficient proof for readiness behavior.
- [x] New or changed tests stay in focused Spec 435 families; no heavy-governance or browser family is added accidentally.
- [x] Fixtures stay local to Exchange structured-output and target normalizer readiness tests.
- [x] Planned validation commands cover readiness and regressions without broad unrelated lane cost.
- [x] Browser proof is explicitly `N/A - no rendered UI surface changed`.
- [x] Human Product Sanity and Product Surface implementation-report close-out are `N/A - no rendered UI surface changed`.
- [x] Any migration, UI, customer-output, evidence-persistence, or target-expansion need is escalated to spec amendment or follow-up spec.
## Phase 1: Preflight
**Purpose**: Lock repo truth and prerequisites before runtime work.
- [x] T001 Capture current branch, HEAD, and `git status --short` in `specs/435-exchange-structured-output-target-normalizer-readiness/implementation-report.md`; before runtime/test implementation, confirm the branch is `feat/435-exchange-structured-output-target-normalizer-readiness` or record an explicit Spec Kit branch-name exception.
- [x] T002 Confirm Specs 430-434 are completed or implementation-closed read-only context and are not edited.
- [x] T003 Confirm Spec 434 implementation report is PASS/merge-ready and no stale checklist/report blocker remains open for this readiness slice.
- [x] T004 Confirm Spec 433 readiness proof remains sufficient for client-secret blocking, scoped/current `Exchange.ManageAsApp`, combined readiness, provider capability support, and runner gate consumption.
- [x] T005 Confirm Spec 432 production runner boundary, process executor abstraction, output guard, timeout/concurrency guards, and sanitized OperationRun context exist.
- [x] T006 Confirm target scope is exactly `transportRule`, `remoteDomain`, and `inboundConnector`.
- [x] T007 Confirm implementation starts with no evidence rows, no Microsoft calls, no real Exchange Online connection, no tenant PowerShell execution, no UI/routes/jobs/schedules/listeners, no migration, no `tenant_id`, no compare/render/certification/restore/customer output, and no target expansion.
## Phase 2: Structured Output Envelope
**Purpose**: Prove runner output has a safe structured contract before normalizer readiness.
- [x] T008 Add `ExchangePowerShellStructuredOutputEnvelope` or repo-canonical equivalent under `apps/platform/app/Services/TenantConfiguration/`.
- [x] T009 Include resource type, command contract name/version, `exchange_online_powershell_rest`, runner mode, shape state, items, item count, empty collection marker, warnings classification, output guard state, normalizer readiness state, redaction readiness state, and identity readiness state.
- [x] T010 Exclude raw stdout, raw stderr, transcripts, serialized Exchange object text, credential material, provider secrets, tokens, authorization headers, and cookies from envelope state.
- [x] T011 Add unit tests proving structured collection and structured empty collection are accepted as envelope states.
- [x] T012 Add tests proving malformed output maps to a readiness blocker.
- [x] T013 Add tests proving scalar output maps to a readiness blocker.
- [x] T014 Add tests proving warning-prefixed output blocks or classifies safely without normalizer readiness.
- [x] T015 Add tests proving binary/non-UTF8 output blocks.
- [x] T016 Add tests proving oversized output blocks.
- [x] T017 Add tests proving non-zero exit blocks.
- [x] T018 Add tests proving timeout blocks.
- [x] T019 Add tests proving raw runner output is not evidence and is not copied into OperationRun context/logs/summaries.
- [x] T020 Add tests proving structured-output readiness uses fake process executor or fake runner output only.
## Phase 3: Target Normalizer Readiness
**Purpose**: Define target-specific readiness without evidence persistence.
- [x] T021 Add or refine `ExchangePowerShellEvidenceNormalizer` or repo-canonical target readiness dispatcher.
- [x] T022 Add or refine `ExchangeTransportRuleEvidenceNormalizer` or repo-canonical equivalent.
- [x] T023 Add or refine `ExchangeRemoteDomainEvidenceNormalizer` or repo-canonical equivalent.
- [x] T024 Add or refine `ExchangeInboundConnectorEvidenceNormalizer` or repo-canonical equivalent.
- [x] T025 Each target readiness definition must include resource type, source surface, command contract name/version, payload shape version, normalizer version, identity candidate fields, material fields, volatile fields, sensitive fields, ordering rules, hash input rules, redaction policy, and readiness blockers.
- [x] T026 Add tests proving normalized payload previews are in-memory/test-only, are not persisted as evidence, and readiness dispatch does not invoke `ExchangePowerShellEvidenceCaptureAdapter::capture()`, `CoverageResourceUpserter::upsert()`, or `CoverageEvidenceWriter::append()`.
## Phase 4: transportRule Readiness
**Purpose**: Make `transportRule` ready for Spec 436 evidence promotion.
- [x] T027 Define `transportRule` stable identity candidate fields and tests.
- [x] T028 Define priority/order handling and tests.
- [x] T029 Define state/mode handling and tests.
- [x] T030 Normalize conditions/actions/exceptions deterministically and add tests.
- [x] T031 Exclude or demote volatile fields and add tests proving volatile-only changes do not affect hash preview.
- [x] T032 Classify sensitive patterns, words, and header values and prove they do not enter OperationRun context/logs/summaries.
- [x] T033 Define deterministic collection ordering and hash input rules.
- [x] T034 Add tests proving same material payload gives same hash preview and material change gives different hash preview.
## Phase 5: remoteDomain Readiness
**Purpose**: Prove stable `remoteDomain` identity or block explicitly.
- [x] T035 Decide whether source `Identity` can be proven stable from current contracts/fixtures.
- [x] T036 If proven, add tests distinguishing default remote domain and custom remote domains.
- [x] T037 If proven, add tests blocking missing `Identity`.
- [x] T038 If proven, add tests blocking duplicate `Identity`.
- [x] T039 Add tests blocking display-name-only identity.
- [x] T040 Add tests blocking domain-only derived identity unless explicitly proven safe.
- [x] T041 Add tests blocking identity conflict and unsupported identity.
- [x] T042 N/A - `remoteDomain` source `Identity` readiness was proven for default/custom fixtures; blocker path remains covered by missing/display/domain-only/duplicate/conflict tests.
- [x] T043 Define settings normalization, volatile fields, sensitive fields, ordering rules, redaction policy, and hash input rules.
- [x] T044 Add tests proving `remoteDomain` readiness is either complete or explicitly blocked without evidence creation.
## Phase 6: inboundConnector Readiness
**Purpose**: Prove `inboundConnector` identity and redaction safety or block explicitly.
- [x] T045 Decide whether stable connector identity can be proven from current contracts/fixtures.
- [x] T046 Add tests blocking missing connector identity.
- [x] T047 Add tests blocking duplicate connector identity.
- [x] T048 Add tests blocking identity conflict and unsupported identity.
- [x] T049 Classify IP addresses as protected config and add tests.
- [x] T050 Classify smart hosts as protected config and add tests.
- [x] T051 Classify certificate names as protected config and add tests.
- [x] T052 Classify comments as potentially sensitive and add tests.
- [x] T053 Classify routing metadata as protected config and add tests.
- [x] T054 Prove protected config, raw provider payloads, serialized objects, transcripts, mail/message/file content, credentials, secrets, tokens, authorization headers, and cookies do not enter OperationRun context/logs/summaries.
- [x] T055 N/A - `inboundConnector` protected-config redaction was proven for IPs, smart hosts, certificate names, comments, and routing metadata; blocker path remains fail-closed through explicit readiness blockers.
- [x] T056 Define deterministic material fields, volatile fields, ordering rules, and hash input rules.
- [x] T057 Add tests proving `inboundConnector` readiness is either complete or explicitly blocked without evidence creation.
## Phase 7: Hash, Empty Collection, Failure, and No-Promotion Safety
**Purpose**: Prove cross-target safety invariants.
- [x] T058 Add `ExchangePowerShellHashInputBuilder` or repo-canonical equivalent.
- [x] T059 Add tests proving same normalized material payload produces the same hash preview.
- [x] T060 Add tests proving material changes change hash preview.
- [x] T061 Add tests proving volatile changes do not change hash preview.
- [x] T062 Add tests proving provider order changes do not change hash preview when order is not meaningful.
- [x] T063 Add tests proving target type and normalizer version are included according to repo pattern.
- [x] T064 Add tests proving hash input excludes raw stdout/stderr, OperationRun context, runtime timestamps, PowerShell session metadata, credential metadata, and permission metadata.
- [x] T065 Add tests proving empty collection is distinguishable from permission failure, source unavailable, malformed output, warning/scalar/binary output, non-zero exit, and timeout.
- [x] T066 Add tests proving empty collection creates no resource row and no evidence row.
- [x] T067 Add blocker mapping tests for malformed, scalar, warning-prefixed, binary, oversized, non-zero, timeout, identity conflict, unsupported identity, identity unproven/missing/duplicate, redaction unproven, normalizer unready, hash unready, and target not ready, using repo-canonical codes such as `empty_collection`, `missing_stable_external_id`, `derived_identity_blocked`, and `duplicate_stable_identity` where applicable.
- [x] T068 Add DB-backed tests proving no `tenant_configuration_resource_evidence` rows are created by Spec 435 readiness flows and no resource/evidence append path is called.
- [x] T069 Add tests proving no raw Exchange payload or normalized Exchange payload is persisted as evidence.
- [x] T070 Add tests proving no `content_backed`, comparable, renderable, certified, restore-ready, customer-ready, report, Review Pack, PDF, or customer claim state appears.
## Phase 8: Product Surface, Trigger, Ownership, and Architecture Guards
**Purpose**: Prove readiness work does not alter product/runtime surfaces outside scope.
- [x] T071 Add static guard assertions proving no routes, Filament pages/resources/widgets, Livewire components, Blade views, navigation, assets, or global search changes are introduced.
- [x] T072 Add static guard assertions proving no jobs, schedules, listeners, console triggers, customer output, reports, Review Packs, PDFs, restore, certification, compare/render code, or browser surface are introduced.
- [x] T073 Add static guard assertions proving no migrations, Exchange-specific evidence table, raw payload table, customer output table, UI state table, or legacy snapshot table are introduced.
- [x] T074 Add static guard assertions proving no platform-core `tenant_id` ownership truth, legacy shim, fallback reader, dual write, or Exchange mini-platform is introduced.
- [x] T075 Record browser proof as `N/A - no rendered UI surface changed`.
- [x] T076 Record Livewire v4 compliance unchanged, provider registration unchanged under `apps/platform/bootstrap/providers.php`, global search unchanged, destructive/high-impact actions none, asset strategy none, and deployment impact none.
## Phase 9: Regression and Validation
**Purpose**: Prove adjacent Exchange/Coverage behavior remains intact.
- [x] T077 Run focused Spec 435 tests: `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec435 --compact`.
- [x] T078 Run Spec 434 regression: `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec434 --compact`.
- [x] T079 Run Spec 433 regression: `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec433 --compact`.
- [x] T080 Run Spec 432/431/430 regression: `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec432|Spec431|Spec430' --compact`.
- [x] T081 Run Coverage v2/generic capture/identity regression: `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec415|Spec417|Spec419|Spec420|Spec426|Spec427' --compact`.
- [x] T082 Run provider capability regression: `cd apps/platform && ./vendor/bin/sail artisan test --filter='ProviderCapabilityRegistryTest|ProviderCapabilityEvaluationTest' --compact`.
- [x] T083 If Sail is unavailable, document the failure and run the same focused filters with `cd apps/platform && php artisan test ... --compact`.
- [x] T084 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` or repo-canonical Pint command.
- [x] T085 Run `git diff --check`.
- [x] T086 Run `git status --short` and confirm only intended active Spec 435 and runtime/test files changed.
## Phase 10: Implementation Report
**Purpose**: Close out with reviewable proof.
- [x] T087 Complete `specs/435-exchange-structured-output-target-normalizer-readiness/implementation-report.md`.
- [x] T088 Record candidate gate result, branch, HEAD, dirty state before/after, and files changed.
- [x] T089 Record Spec 434, Spec 433, and Spec 432 prerequisite proof.
- [x] T090 Record structured output envelope proof.
- [x] T091 Record `transportRule`, `remoteDomain`, and `inboundConnector` readiness proof or explicit blockers.
- [x] T092 Record identity readiness, redaction readiness, hash input readiness, empty collection readiness, and failure readiness proof.
- [x] T093 Record no evidence, no content-backed promotion, no compare/render/certification, no restore/customer claim, no UI/route/job/schedule/listener, no migration, no `tenant_id`, and no mini-platform proof.
- [x] T094 Complete the required target matrix.
- [x] T095 Complete the required no-promotion matrix.
- [x] T096 Record tests run, deferred work, PASS/PASS WITH CONDITIONS/FAIL result, and any bounded follow-up blockers.
## Dependencies
- Phase 1 must complete before runtime changes.
- Phase 2 must complete before target normalizer readiness.
- Phases 4-6 can proceed in parallel by target after Phase 3.
- Phase 7 depends on target normalizer readiness definitions.
- Phase 8 can run alongside Phase 7 but must pass before close-out.
- Phase 10 closes only after validation is documented.
## Stop Conditions
Stop and amend or split if implementation needs:
- evidence rows or normalized evidence persistence;
- calls from readiness flow into `ExchangePowerShellEvidenceCaptureAdapter::capture()`, `CoverageResourceUpserter::upsert()`, `CoverageEvidenceWriter::append()`, or an equivalent resource/evidence append path;
- `content_backed`, comparable, renderable, certified, restore-ready, customer-ready, or customer-claim promotion;
- Microsoft calls, Exchange Online connection, or real tenant PowerShell execution;
- UI, routes, navigation, Filament, Livewire, Blade, browser, asset, or global search changes;
- job, schedule, listener, console trigger, or public start surface;
- migration, Exchange-specific evidence table, `tenant_id`, legacy shim, fallback reader, dual write, or mini-platform;
- outboundConnector, acceptedDomain, organizationConfig, mailboxPlan, sharingPolicy, Teams, Security and Compliance, mutation commands, restore commands, reports, Review Packs, or PDFs.