feat: promote Exchange content-backed evidence
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 7m12s

This commit is contained in:
Ahmed Darrazi 2026-07-09 15:55:44 +02:00
parent a09cc6ca8d
commit 053a33c172
10 changed files with 2877 additions and 136 deletions

View File

@ -27,8 +27,9 @@ public function __construct(
private readonly ExchangePowerShellCaptureEligibilityGate $eligibility,
private readonly ExchangePowerShellIdentityEvidenceGate $identityGate,
private readonly ExchangePowerShellContentOnlyEvidenceGuard $contentOnlyGuard,
private readonly GenericPayloadNormalizer $genericNormalizer,
private readonly ExchangeTeamsComparablePayloadNormalizer $exchangeTeamsNormalizer,
private readonly ExchangePowerShellCommandContracts $commandContracts,
private readonly ExchangePowerShellEvidenceNormalizer $normalizer,
private readonly ExchangePowerShellHashInputBuilder $hashes,
private readonly CoveragePayloadRedactor $redactor,
private readonly CoverageResourceUpserter $resourceUpserter,
private readonly CoverageEvidenceWriter $evidenceWriter,
@ -78,10 +79,31 @@ public function capture(
$sourceDecision = $eligibility['decision'];
/** @var array<string, mixed> $contract */
$contract = $eligibility['contract'];
/** @var list<array<string, mixed>> $items */
$items = $runnerResult->collection;
$envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: $canonicalType,
contract: ExchangePowerShellCommandContract::fromVerifiedArray($contract, $this->commandContracts),
runnerMode: $this->runnerMode($operationRun, $runnerResult),
result: $runnerResult,
);
$readiness = $this->normalizer->evaluate($envelope);
if ($items === []) {
if (! $readiness->ready) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::BlockedUnsupported,
adapterOutcome: $this->normalizerBlockedAdapterOutcome($readiness),
itemCount: 0,
reasonCode: $readiness->blockers[0] ?? 'exchange_normalizer_blocked',
sourceContractKey: $sourceDecision->contractKey,
evidenceIds: [],
summaryCounts: $summaryCounts,
);
}
if ($readiness->emptyCollection) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
@ -98,11 +120,32 @@ public function capture(
}
$decision = $this->capturableDecision($sourceDecision, $contract);
$evidenceItems = $this->normalizer->normalizedEvidenceItems($envelope);
if ($evidenceItems === [] || count($evidenceItems) !== $readiness->itemCount) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::BlockedUnsupported,
adapterOutcome: self::OUTCOME_PREREQUISITE_BLOCKED,
itemCount: 0,
reasonCode: 'exchange_normalizer_hash_unready',
sourceContractKey: $sourceDecision->contractKey,
evidenceIds: [],
summaryCounts: $summaryCounts,
);
}
$identity = $this->identityGate->evaluateCollection(
tenant: $tenant,
providerConnection: $providerConnection,
resourceType: $resourceType,
items: $items,
items: array_values(array_map(
static fn (array $item): array => $item['raw_payload'],
$evidenceItems,
)),
sourceMetadata: $decision->sourceMetadata,
);
@ -123,17 +166,22 @@ public function capture(
}
$evidenceIds = [];
$volatileFields = $this->volatileFields($contract);
$permissionContext = $this->permissionContext($providerConnection);
foreach ($items as $item) {
$normalizedPayload = $this->normalizedPayload($resourceType, $item, $volatileFields, $decision);
$payloadHash = $this->genericNormalizer->payloadHash($normalizedPayload);
foreach ($evidenceItems as $item) {
$rawPayload = $item['raw_payload'];
$normalizedPayload = $this->evidenceNormalizedPayload(
normalizedPayload: $item['normalized_payload'],
decision: $decision,
envelope: $envelope,
readiness: $readiness,
);
$payloadHash = $this->evidencePayloadHash($normalizedPayload, $envelope, $readiness);
$resource = $this->resourceUpserter->upsert(
tenant: $tenant,
providerConnection: $providerConnection,
resourceType: $resourceType,
payload: $item,
payload: $rawPayload,
sourceMetadata: $decision->sourceMetadata,
);
@ -143,7 +191,7 @@ public function capture(
providerConnection: $providerConnection,
operationRun: $operationRun,
decision: $decision,
rawPayload: $item,
rawPayload: $rawPayload,
normalizedPayload: $normalizedPayload,
payloadHash: $payloadHash,
permissionContext: $permissionContext,
@ -204,43 +252,52 @@ private function capturableDecision(CoverageSourceContractDecision $sourceDecisi
}
/**
* @param array<string, mixed> $payload
* @param list<string> $volatileFields
* @param array<string, mixed> $normalizedPayload
* @return array<string, mixed>
*/
private function normalizedPayload(
TenantConfigurationResourceType $resourceType,
array $payload,
array $volatileFields,
private function evidenceNormalizedPayload(
array $normalizedPayload,
CoverageSourceContractDecision $decision,
ExchangePowerShellStructuredOutputEnvelope $envelope,
ExchangePowerShellNormalizerReadiness $readiness,
): array {
$canonicalType = (string) $resourceType->canonical_type;
if ($this->exchangeTeamsNormalizer->supports($canonicalType)) {
$normalized = $this->redactedPayload($this->exchangeTeamsNormalizer->normalize($canonicalType, $payload, $volatileFields));
$source = is_array($normalized['source'] ?? null) ? $normalized['source'] : [];
$normalized['source'] = array_filter([
...$source,
'source_contract_key' => $decision->contractKey,
'source_endpoint' => $decision->sourceEndpoint,
'source_version' => $decision->sourceVersion,
'source_schema_hash' => $decision->sourceSchemaHash,
'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE,
], static fn (mixed $value): bool => $value !== null && $value !== '');
return $normalized;
}
$normalized = $this->genericNormalizer->normalize($payload, $volatileFields);
$normalized['source'] = array_filter([
$source = array_filter([
'source_contract_key' => $decision->contractKey,
'source_endpoint' => $decision->sourceEndpoint,
'source_version' => $decision->sourceVersion,
'source_schema_hash' => $decision->sourceSchemaHash,
'source_surface' => ExchangePowerShellCommandContracts::SOURCE_SURFACE,
'source_surface' => $envelope->sourceSurface,
'command_contract_name' => $envelope->commandContractName,
'command_contract_version' => $envelope->commandContractVersion,
'payload_shape_version' => $readiness->definition['payload_shape_version'] ?? null,
'normalizer_version' => $readiness->definition['normalizer_version'] ?? null,
], static fn (mixed $value): bool => $value !== null && $value !== '');
return $this->redactedPayload($normalized);
return [
...$normalizedPayload,
'source' => $source,
];
}
/**
* @param array<string, mixed> $normalizedPayload
*/
private function evidencePayloadHash(
array $normalizedPayload,
ExchangePowerShellStructuredOutputEnvelope $envelope,
ExchangePowerShellNormalizerReadiness $readiness,
): string {
$hashInput = $this->hashes->build(
resourceType: $envelope->resourceType,
sourceSurface: $envelope->sourceSurface,
commandContractName: $envelope->commandContractName,
commandContractVersion: $envelope->commandContractVersion,
payloadShapeVersion: (string) ($readiness->definition['payload_shape_version'] ?? ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION),
normalizerVersion: (string) ($readiness->definition['normalizer_version'] ?? 'unknown'),
normalizedPayload: $normalizedPayload,
);
return $this->hashes->hash($hashInput);
}
/**
@ -254,22 +311,27 @@ private function redactedPayload(array $payload): array
return is_array($redacted) ? $redacted : [];
}
/**
* @param array<string, mixed> $contract
* @return list<string>
*/
private function volatileFields(array $contract): array
private function runnerMode(OperationRun $operationRun, ExchangePowerShellInvocationResult $runnerResult): string
{
$fields = data_get($contract, 'response_shape.volatile_fields', []);
$runnerMode = $runnerResult->context['runner_mode'] ?? data_get($operationRun->context, 'runner_mode');
if (! is_array($fields)) {
return [];
return is_string($runnerMode) && trim($runnerMode) !== '' ? trim($runnerMode) : 'unknown';
}
private function normalizerBlockedAdapterOutcome(ExchangePowerShellNormalizerReadiness $readiness): string
{
foreach ($readiness->blockers as $blocker) {
if (str_contains($blocker, 'identity')
|| str_contains($blocker, 'display_name')
|| str_contains($blocker, 'duplicate')
|| str_contains($blocker, 'missing_stable')
|| str_contains($blocker, 'derived')
) {
return self::OUTCOME_IDENTITY_BLOCKED;
}
}
return array_values(array_filter(
array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $fields),
static fn (string $field): bool => $field !== '',
));
return self::OUTCOME_PREREQUISITE_BLOCKED;
}
/**

View File

@ -40,6 +40,19 @@ public function evaluate(ExchangePowerShellStructuredOutputEnvelope $envelope):
};
}
/**
* @return list<array{raw_payload: array<string, mixed>, normalized_payload: array<string, mixed>, payload_hash: string}>
*/
public function normalizedEvidenceItems(ExchangePowerShellStructuredOutputEnvelope $envelope): array
{
return match ($envelope->resourceType) {
'transportRule' => $this->transportRules->normalizedEvidenceItems($envelope),
'remoteDomain' => $this->remoteDomains->normalizedEvidenceItems($envelope),
'inboundConnector' => $this->inboundConnectors->normalizedEvidenceItems($envelope),
default => [],
};
}
/**
* @return array<string, array<string, mixed>>
*/

View File

@ -35,103 +35,43 @@ protected function identityAliasGroups(): array
public function evaluate(ExchangePowerShellStructuredOutputEnvelope $envelope): ExchangePowerShellNormalizerReadiness
{
$definition = $this->definition();
$prepared = $this->prepareEvidenceItems($envelope);
if ($envelope->resourceType !== $this->resourceType()) {
if (($prepared['ready'] ?? false) !== true) {
return ExchangePowerShellNormalizerReadiness::blocked(
resourceType: $envelope->resourceType,
definition: $definition,
blockers: ['blocked_target_not_ready'],
itemCount: $envelope->itemCount,
blockers: $prepared['blockers'],
itemCount: $prepared['item_count'],
);
}
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,
static fn (array $item): array => $item['normalized_payload'],
$prepared['items'],
)),
hashPreviews: array_values(array_map(
static fn (array $preview): string => (string) $preview['hash_preview'],
$previews,
static fn (array $item): string => (string) $item['payload_hash'],
$prepared['items'],
)),
itemCount: count($previews),
itemCount: $prepared['item_count'],
emptyCollection: $prepared['empty_collection'],
);
}
/**
* @return list<array{raw_payload: array<string, mixed>, normalized_payload: array<string, mixed>, payload_hash: string}>
*/
public function normalizedEvidenceItems(ExchangePowerShellStructuredOutputEnvelope $envelope): array
{
$prepared = $this->prepareEvidenceItems($envelope);
return ($prepared['ready'] ?? false) === true ? $prepared['items'] : [];
}
/**
* @return array<string, mixed>
*/
@ -248,6 +188,113 @@ protected function normalizePayload(array $payload, array $identity): array
]);
}
/**
* @return array{
* ready: bool,
* blockers: list<string>,
* items: list<array{raw_payload: array<string, mixed>, normalized_payload: array<string, mixed>, payload_hash: string}>,
* item_count: int,
* empty_collection: bool
* }
*/
private function prepareEvidenceItems(ExchangePowerShellStructuredOutputEnvelope $envelope): array
{
if ($envelope->resourceType !== $this->resourceType()) {
return $this->blockedEvidenceItems(['blocked_target_not_ready'], $envelope->itemCount);
}
if (! $envelope->accepted()) {
return $this->blockedEvidenceItems([$envelope->readinessBlocker ?? 'blocked_unknown_output'], $envelope->itemCount);
}
if ($envelope->emptyCollection) {
return [
'ready' => true,
'blockers' => [],
'items' => [],
'item_count' => 0,
'empty_collection' => true,
];
}
$definition = $this->definition();
$items = [];
$seenIdentities = [];
foreach ($envelope->items as $item) {
$identity = $this->stableIdentity($item);
if (($identity['allowed'] ?? false) !== true) {
return $this->blockedEvidenceItems([(string) ($identity['blocker'] ?? 'blocked_identity_unproven')], $envelope->itemCount);
}
$identityKey = (string) $identity['key'];
if (isset($seenIdentities[$identityKey])) {
return $this->blockedEvidenceItems(['duplicate_stable_identity'], $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,
);
$payloadHash = $this->hashes->hash($hashInput);
$items[] = [
'identity_key' => $identityKey,
'identity_sort_key' => (string) $identity['value'],
'payload_hash' => $payloadHash,
'raw_payload' => $item,
'normalized_payload' => $normalized,
];
}
usort($items, static function (array $left, array $right): int {
return [$left['identity_sort_key'], $left['identity_key'], $left['payload_hash']]
<=> [$right['identity_sort_key'], $right['identity_key'], $right['payload_hash']];
});
return [
'ready' => true,
'blockers' => [],
'items' => array_values(array_map(static fn (array $item): array => [
'raw_payload' => $item['raw_payload'],
'normalized_payload' => $item['normalized_payload'],
'payload_hash' => $item['payload_hash'],
], $items)),
'item_count' => count($items),
'empty_collection' => false,
];
}
/**
* @param list<string> $blockers
* @return array{
* ready: false,
* blockers: list<string>,
* items: list<array{raw_payload: array<string, mixed>, normalized_payload: array<string, mixed>, payload_hash: string}>,
* item_count: int,
* empty_collection: false
* }
*/
private function blockedEvidenceItems(array $blockers, int $itemCount): array
{
return [
'ready' => false,
'blockers' => $blockers,
'items' => [],
'item_count' => max(0, $itemCount),
'empty_collection' => false,
];
}
/**
* @param array<string, mixed> $payload
* @return array{allowed: bool, key?: string, field?: string, value?: string, blocker?: string}

View File

@ -361,12 +361,12 @@
'transport rule display-name only' => [
'transportRule',
['DisplayName' => 'Display names are not stable identifiers'],
'missing_stable_external_id',
'display_name_only',
],
'remote domain derived-only domain name' => [
'remoteDomain',
['DomainName' => 'contoso.example', 'DisplayName' => 'Contoso'],
'missing_stable_external_id',
'display_name_only',
],
]);
@ -673,8 +673,8 @@ function spec434CaptureRun(
function spec434TransportRulePayload(array $overrides = []): array
{
return [
'Guid' => '11111111-2222-3333-4444-555555555555',
'RuleId' => 'spec434-rule-1',
'Guid' => 'spec434-rule-1',
'DisplayName' => 'Spec 434 transport rule',
'Name' => 'Spec 434 transport rule',
'Priority' => 0,
@ -694,8 +694,8 @@ function spec434TransportRulePayload(array $overrides = []): array
function spec434RemoteDomainPayload(): array
{
return [
'Guid' => '22222222-3333-4444-5555-666666666666',
'RemoteDomainId' => 'spec434-remote-domain-1',
'Guid' => 'spec434-remote-domain-1',
'Identity' => 'spec434-remote-domain-1',
'DomainName' => 'contoso.example',
'DisplayName' => 'Contoso remote domain',
@ -711,8 +711,8 @@ function spec434RemoteDomainPayload(): array
function spec434InboundConnectorPayload(): array
{
return [
'Guid' => '33333333-4444-5555-6666-777777777777',
'ConnectorId' => 'spec434-inbound-connector-1',
'Guid' => 'spec434-inbound-connector-1',
'Identity' => 'spec434-inbound-connector-1',
'DisplayName' => 'Spec 434 inbound connector',
'Name' => 'Spec 434 inbound connector',

View File

@ -0,0 +1,726 @@
<?php
declare(strict_types=1);
use App\Models\ManagedEnvironment;
use App\Models\ManagedEnvironmentPermission;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\ExchangePowerShellEvidenceCaptureAdapter;
use App\Services\TenantConfiguration\ExchangePowerShellHashInputBuilder;
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
use App\Services\TenantConfiguration\ExchangePowerShellInvocationResult;
use App\Services\TenantConfiguration\ExchangePowerShellTargetEvidenceNormalizer;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderCredentialKind;
use App\Support\Providers\ProviderCredentialSource;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\EvidenceState;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
beforeEach(function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
config([
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
'tenantpilot.exchange_powershell.permission_evidence.currentness_days' => 30,
]);
});
it('Spec436 appends content-backed Exchange PowerShell evidence through target normalizers and hash builder', function (
string $canonicalType,
string $commandName,
string $normalizerVersion,
array $payload,
): void {
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = spec436ReadyProviderConnection($environment);
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
$run = spec436CaptureRun($environment, $user, $connection, resourceTypes: [$canonicalType]);
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: $run,
canonicalType: $canonicalType,
runnerResult: ExchangePowerShellInvocationResult::succeeded(
[$payload],
context: ['output_state' => 'structured_collection', 'runner_mode' => 'fake'],
),
);
$resource = TenantConfigurationResource::query()->sole();
$evidence = TenantConfigurationResourceEvidence::query()->sole();
$expectedHash = app(ExchangePowerShellHashInputBuilder::class)->hash(
app(ExchangePowerShellHashInputBuilder::class)->build(
resourceType: $canonicalType,
sourceSurface: ExchangePowerShellCommandContracts::SOURCE_SURFACE,
commandContractName: $commandName,
commandContractVersion: ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION,
payloadShapeVersion: ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION,
normalizerVersion: $normalizerVersion,
normalizedPayload: $evidence->normalized_payload,
),
);
expect($result)
->toMatchArray([
'canonical_type' => $canonicalType,
'outcome' => CaptureOutcome::Captured->value,
'adapter_outcome' => CaptureOutcome::Captured->value,
'item_count' => 1,
'reason_code' => null,
'source_contract_key' => 'exchange_powershell.'.$canonicalType,
])
->and($result['evidence_ids'])->toBe([(int) $evidence->getKey()])
->and($resource->workspace_id)->toBe((int) $environment->workspace_id)
->and($resource->managed_environment_id)->toBe((int) $environment->getKey())
->and($resource->provider_connection_id)->toBe((int) $connection->getKey())
->and($resource->latest_evidence_id)->toBe((int) $evidence->getKey())
->and($resource->latest_evidence_state)->toBe(EvidenceState::ContentBacked)
->and($resource->latest_claim_state)->toBe(ClaimState::InternalOnly)
->and($evidence->operation_run_id)->toBe((int) $run->getKey())
->and($evidence->workspace_id)->toBe((int) $environment->workspace_id)
->and($evidence->managed_environment_id)->toBe((int) $environment->getKey())
->and($evidence->provider_connection_id)->toBe((int) $connection->getKey())
->and($evidence->coverage_level)->toBe(CoverageLevel::ContentBacked)
->and($evidence->evidence_state)->toBe(EvidenceState::ContentBacked)
->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured)
->and($evidence->source_endpoint)->toBe(ExchangePowerShellCommandContracts::SOURCE_SURFACE.':'.$commandName)
->and($evidence->raw_payload)->toMatchArray($payload)
->and($evidence->normalized_payload['canonical_type'])->toBe($canonicalType)
->and($evidence->normalized_payload['source_surface'])->toBe(ExchangePowerShellCommandContracts::SOURCE_SURFACE)
->and($evidence->normalized_payload['payload_shape_version'])->toBe(ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION)
->and($evidence->normalized_payload['normalizer_version'])->toBe($normalizerVersion)
->and($evidence->normalized_payload['source']['source_contract_key'])->toBe('exchange_powershell.'.$canonicalType)
->and($evidence->normalized_payload['source']['command_contract_name'])->toBe($commandName)
->and($evidence->payload_hash)->toBe($expectedHash)
->and($evidence->permission_context['provider_connection_id'])->toBe((int) $connection->getKey());
$runJson = json_encode([
'context' => $run->refresh()->context,
'summary_counts' => $run->summary_counts,
'failure_summary' => $run->failure_summary,
], JSON_THROW_ON_ERROR);
expect($runJson)
->not->toContain('raw_stdout')
->not->toContain('raw_stderr')
->not->toContain('normalizer_preview')
->not->toContain('hash_preview')
->not->toContain('client_secret')
->not->toContain('access_token');
})->with([
'transportRule' => [
'transportRule',
'Get-TransportRule',
'exchange-transport-rule-readiness-v1',
spec436TransportRulePayload(),
],
'remoteDomain' => [
'remoteDomain',
'Get-RemoteDomain',
'exchange-remote-domain-readiness-v1',
spec436RemoteDomainPayload(),
],
'inboundConnector' => [
'inboundConnector',
'Get-InboundConnector',
'exchange-inbound-connector-readiness-v1',
spec436InboundConnectorPayload(),
],
]);
it('Spec436 blocks unsafe target-normalizer identity states before writer use', function (
string $canonicalType,
array $items,
string $expectedReasonCode,
): void {
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = spec436ReadyProviderConnection($environment);
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
$run = spec436CaptureRun($environment, $user, $connection, resourceTypes: [$canonicalType]);
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: $run,
canonicalType: $canonicalType,
runnerResult: ExchangePowerShellInvocationResult::succeeded(
$items,
context: ['output_state' => 'structured_collection'],
),
);
expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_IDENTITY_BLOCKED)
->and($result['reason_code'])->toBe($expectedReasonCode)
->and(TenantConfigurationResource::query()->count())->toBe(0)
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
})->with([
'remoteDomain domain-only' => [
'remoteDomain',
[['DomainName' => 'contoso.example']],
'derived_identity_blocked',
],
'remoteDomain display-only' => [
'remoteDomain',
[['DisplayName' => 'Contoso remote domain']],
'display_name_only',
],
'remoteDomain duplicate identity' => [
'remoteDomain',
[
spec436RemoteDomainPayload(['Guid' => 'duplicate', 'RemoteDomainId' => 'duplicate', 'Identity' => 'duplicate']),
spec436RemoteDomainPayload(['Guid' => 'duplicate', 'RemoteDomainId' => 'duplicate', 'Identity' => 'duplicate']),
],
'duplicate_stable_identity',
],
'remoteDomain conflicting aliases' => [
'remoteDomain',
[spec436RemoteDomainPayload(['Guid' => 'guid-a', 'RemoteDomainId' => 'guid-b', 'Identity' => 'guid-a'])],
'identity_conflict',
],
'inboundConnector missing identity' => [
'inboundConnector',
[['ConnectorType' => 'Partner', 'Enabled' => true]],
'missing_stable_external_id',
],
]);
it('Spec436 keeps protected inbound connector configuration out of OperationRun while persisting raw payload only in evidence', function (): void {
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = spec436ReadyProviderConnection($environment);
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
$run = spec436CaptureRun($environment, $user, $connection, resourceTypes: ['inboundConnector']);
app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: $run,
canonicalType: 'inboundConnector',
runnerResult: ExchangePowerShellInvocationResult::succeeded(
[spec436InboundConnectorPayload([
'SenderIPAddresses' => ['203.0.113.10'],
'SmartHosts' => ['mail.contoso.example'],
'TlsSenderCertificateName' => 'CN=secret-cert',
'Comment' => 'sensitive routing note',
])],
context: ['output_state' => 'structured_collection'],
),
);
$evidence = TenantConfigurationResourceEvidence::query()->sole();
$runJson = json_encode([
'context' => $run->refresh()->context,
'summary_counts' => $run->summary_counts,
'failure_summary' => $run->failure_summary,
], JSON_THROW_ON_ERROR);
$normalizedJson = json_encode($evidence->normalized_payload, JSON_THROW_ON_ERROR);
expect($evidence->raw_payload['SenderIPAddresses'])->toBe(['203.0.113.10'])
->and(data_get($evidence->normalized_payload, 'routing.sender_ip_addresses.value'))->toBe('[redacted]')
->and(data_get($evidence->normalized_payload, 'routing.smart_hosts.value'))->toBe('[redacted]')
->and(data_get($evidence->normalized_payload, 'routing.tls_sender_certificate_name.value'))->toBe('[redacted]')
->and(data_get($evidence->normalized_payload, 'routing.comment.value'))->toBe('[redacted]')
->and($runJson)
->not->toContain('203.0.113.10')
->not->toContain('mail.contoso.example')
->not->toContain('secret-cert')
->not->toContain('sensitive routing note')
->and($normalizedJson)
->not->toContain('203.0.113.10')
->not->toContain('mail.contoso.example')
->not->toContain('secret-cert')
->not->toContain('sensitive routing note');
});
it('Spec436 appends evidence without mutating prior payloads and updates the latest pointer', function (): void {
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = spec436ReadyProviderConnection($environment);
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
$run = spec436CaptureRun($environment, $user, $connection);
$adapter = app(ExchangePowerShellEvidenceCaptureAdapter::class);
$adapter->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: $run,
canonicalType: 'transportRule',
runnerResult: ExchangePowerShellInvocationResult::succeeded(
[spec436TransportRulePayload(['Enabled' => true, 'WhenChanged' => '2026-07-08T10:00:00Z'])],
context: ['output_state' => 'structured_collection'],
),
);
$firstEvidence = TenantConfigurationResourceEvidence::query()->sole();
$firstHash = $firstEvidence->payload_hash;
$firstPayload = $firstEvidence->normalized_payload;
$adapter->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: $run,
canonicalType: 'transportRule',
runnerResult: ExchangePowerShellInvocationResult::succeeded(
[spec436TransportRulePayload(['Enabled' => false, 'WhenChanged' => '2026-07-09T10:00:00Z'])],
context: ['output_state' => 'structured_collection'],
),
);
$resource = TenantConfigurationResource::query()->sole();
$evidenceRows = TenantConfigurationResourceEvidence::query()->orderBy('id')->get();
expect($evidenceRows)->toHaveCount(2)
->and($resource->latest_evidence_id)->toBe((int) $evidenceRows[1]->getKey())
->and($evidenceRows[0]->payload_hash)->toBe($firstHash)
->and($evidenceRows[0]->normalized_payload)->toBe($firstPayload)
->and($evidenceRows[1]->payload_hash)->not->toBe($firstHash);
});
it('Spec436 treats empty collections as zero item summaries without fake evidence', function (): void {
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = spec436ReadyProviderConnection($environment);
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
$run = spec436CaptureRun($environment, $user, $connection);
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: $run,
canonicalType: 'transportRule',
runnerResult: ExchangePowerShellInvocationResult::succeeded(
[],
context: ['output_state' => 'empty_collection'],
),
);
expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_EMPTY_COLLECTION)
->and($result['outcome'])->toBe(CaptureOutcome::Captured->value)
->and($result['item_count'])->toBe(0)
->and($result['summary_counts']['total'])->toBe(0)
->and(TenantConfigurationResource::query()->count())->toBe(0)
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
});
it('Spec436 readiness and output failures fail closed with no evidence', function (
string $case,
callable $arrange,
ExchangePowerShellInvocationResult $runnerResult,
): void {
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = spec436ReadyProviderConnection($environment);
$arrange($environment, $connection);
$run = spec436CaptureRun($environment, $user, $connection);
$result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: $run,
canonicalType: 'transportRule',
runnerResult: $runnerResult,
);
expect($case)->not->toBe('')
->and($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED)
->and(TenantConfigurationResource::query()->count())->toBe(0)
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
})->with([
'missing credential' => [
'missing credential',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {},
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'structured_collection']),
],
'client secret credential' => [
'client secret credential',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::ClientSecret->value);
},
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'structured_collection']),
],
'stale permission evidence' => [
'stale permission evidence',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
spec436SeedExchangePowerShellPermission($environment, $connection, [
'last_checked_at' => now()->subDays(31),
'details' => [
'observed_at' => now()->subDays(31)->toJSON(),
'verified_at' => now()->subDays(31)->toJSON(),
],
]);
},
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'structured_collection']),
],
'missing permission evidence' => [
'missing permission evidence',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
ManagedEnvironmentPermission::query()
->where('managed_environment_id', $environment->getKey())
->where('permission_key', 'Exchange.ManageAsApp')
->delete();
},
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'structured_collection']),
],
'wrong-scope permission evidence' => [
'wrong-scope permission evidence',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
spec436SeedExchangePowerShellPermission($environment, $connection, [
'details' => [
'provider_connection_id' => 999999,
],
]);
},
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'structured_collection']),
],
'warning-prefixed output' => [
'warning-prefixed output',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
},
ExchangePowerShellInvocationResult::succeeded([spec436TransportRulePayload()], context: ['output_state' => 'warning_prefixed']),
],
'authentication failure' => [
'authentication failure',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
},
ExchangePowerShellInvocationResult::failed(
reasonCode: 'authentication_failed',
failureCode: 'authentication_failed',
message: 'Authentication failed safely.',
context: ['output_state' => 'authentication_failed'],
),
],
'authorization permission failure' => [
'authorization permission failure',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
},
ExchangePowerShellInvocationResult::failed(
reasonCode: 'permission_denied',
failureCode: 'permission_denied',
message: 'Permission denied safely.',
context: ['output_state' => 'permission_denied'],
),
],
'source unavailable' => [
'source unavailable',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
},
ExchangePowerShellInvocationResult::failed(
reasonCode: 'source_unavailable',
failureCode: 'source_unavailable',
message: 'Source unavailable safely.',
context: ['output_state' => 'source_unavailable'],
),
],
'malformed output' => [
'malformed output',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
},
ExchangePowerShellInvocationResult::succeededPayload('not-json', context: ['output_state' => 'malformed_json_collection']),
],
'scalar output' => [
'scalar output',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
},
ExchangePowerShellInvocationResult::succeededPayload(42, context: ['output_state' => 'scalar_output']),
],
'binary output' => [
'binary output',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
},
ExchangePowerShellInvocationResult::succeededPayload([], context: ['output_state' => 'non_utf8_or_binary']),
],
'oversized output' => [
'oversized output',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
},
ExchangePowerShellInvocationResult::succeededPayload([], context: ['output_state' => 'stdout_oversized']),
],
'non-zero exit' => [
'non-zero exit',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
},
ExchangePowerShellInvocationResult::failed(
reasonCode: 'provider_failed',
failureCode: 'nonzero_exit',
message: 'Non-zero exit safely blocked.',
context: ['output_state' => 'nonzero_exit'],
),
],
'partial output' => [
'partial output',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
},
ExchangePowerShellInvocationResult::failed(
reasonCode: 'partial_output',
failureCode: 'partial_output',
message: 'Partial output safely blocked.',
context: ['output_state' => 'partial_output'],
),
],
'runner timeout' => [
'runner timeout',
function (ManagedEnvironment $environment, ProviderConnection $connection): void {
spec436Credential($connection, ProviderCredentialKind::Certificate->value);
},
ExchangePowerShellInvocationResult::failed(
reasonCode: 'provider_failed',
failureCode: 'timeout',
message: 'Timed out safely.',
context: ['output_state' => 'timeout'],
),
],
]);
it('Spec436 introduces no generic promotion path product surface trigger tenant-id ownership or mini-platform', function (): void {
$adapterSource = file_get_contents(app_path('Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php')) ?: '';
$runtimeFiles = [
app_path('Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.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/ExchangePowerShellHashInputBuilder.php'),
app_path('Services/TenantConfiguration/ExchangePowerShellStructuredOutputEnvelope.php'),
];
$runtimeSource = collect($runtimeFiles)
->map(fn (string $file): string => file_get_contents($file) ?: '')
->implode("\n");
$captureOutcomeValues = array_map(
static fn (CaptureOutcome $outcome): string => $outcome->value,
CaptureOutcome::cases(),
);
expect($adapterSource)
->not->toContain('GenericPayloadNormalizer')
->not->toContain('ExchangeTeamsComparablePayloadNormalizer')
->and($runtimeSource)
->not->toContain('ProviderGateway')
->not->toContain('fake_empty_success')
->not->toContain('customer_ready')
->not->toContain('restore_ready')
->and(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('Console/Commands/CaptureExchangePowerShellEvidence.php')))->toBeFalse()
->and($captureOutcomeValues)
->toContain(CaptureOutcome::Captured->value)
->not->toContain('customer_ready')
->not->toContain('certified')
->not->toContain('renderable')
->not->toContain('comparable');
});
/**
* @param array<string, mixed> $overrides
*/
function spec436ReadyProviderConnection(
ManagedEnvironment $environment,
array $overrides = [],
bool $seedExchangePowerShellPermission = true,
): ProviderConnection {
$connection = ProviderConnection::factory()
->dedicated()
->consentGranted()
->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'entra_tenant_id' => $environment->providerTenantContext(),
'is_default' => true,
'verification_status' => ProviderVerificationStatus::Healthy->value,
'last_health_check_at' => now(),
...$overrides,
]);
if ($seedExchangePowerShellPermission) {
spec436SeedExchangePowerShellPermission($environment, $connection);
}
return $connection;
}
/**
* @param array<string, mixed> $overrides
*/
function spec436SeedExchangePowerShellPermission(
ManagedEnvironment $environment,
ProviderConnection $connection,
array $overrides = [],
): ManagedEnvironmentPermission {
$details = [
'source' => 'provider_verification',
'observed_at' => now()->toJSON(),
'verified_at' => now()->toJSON(),
'evaluator' => 'exchange_powershell_permission_evidence',
'evaluator_version' => 'v1',
'workspace_id' => (int) $connection->workspace_id,
'managed_environment_id' => (int) $connection->managed_environment_id,
'provider' => (string) $connection->provider,
'provider_connection_id' => (int) $connection->getKey(),
];
if (is_array($overrides['details'] ?? null)) {
$details = array_replace($details, $overrides['details']);
}
unset($overrides['details']);
return ManagedEnvironmentPermission::query()->updateOrCreate(
[
'managed_environment_id' => (int) $environment->getKey(),
'permission_key' => 'Exchange.ManageAsApp',
'workspace_id' => (int) $environment->workspace_id,
],
[
'status' => 'granted',
'details' => $details,
'last_checked_at' => now(),
...$overrides,
],
);
}
/**
* @param array<string, mixed> $overrides
*/
function spec436Credential(ProviderConnection $connection, string $kind, array $overrides = []): ProviderCredential
{
return ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'type' => $kind,
'credential_kind' => $kind,
'source' => ProviderCredentialSource::DedicatedManual->value,
'last_rotated_at' => now(),
'expires_at' => now()->addYear(),
...$overrides,
]);
}
/**
* @param array<string, mixed> $overrides
* @param list<string> $resourceTypes
*/
function spec436CaptureRun(
ManagedEnvironment $environment,
App\Models\User $user,
ProviderConnection $connection,
array $overrides = [],
array $resourceTypes = ['transportRule'],
): OperationRun {
$context = [
'target_scope' => [
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
],
'resource_types' => $resourceTypes,
'required_capability' => 'exchange_powershell_invoke',
'runner_mode' => 'fake',
];
if (is_array($overrides['context'] ?? null)) {
$context = array_replace_recursive($context, $overrides['context']);
}
unset($overrides['context']);
return OperationRun::factory()->withUser($user)->forTenant($environment)->create([
'type' => OperationRunType::TenantConfigurationCapture->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => $context,
...$overrides,
]);
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function spec436TransportRulePayload(array $overrides = []): array
{
return [
'RuleId' => 'spec436-rule-1',
'Guid' => 'spec436-rule-1',
'DisplayName' => 'Spec 436 transport rule',
'Name' => 'Spec 436 transport rule',
'Priority' => 0,
'Mode' => 'Enforce',
'State' => 'Enabled',
'Enabled' => true,
'Conditions' => ['SenderDomainIs' => ['contoso.example']],
'Actions' => ['RedirectMessageTo' => ['security@contoso.example']],
'WhenChanged' => 'volatile',
...$overrides,
];
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function spec436RemoteDomainPayload(array $overrides = []): array
{
return [
'RemoteDomainId' => 'spec436-remote-domain-1',
'Guid' => 'spec436-remote-domain-1',
'Identity' => 'spec436-remote-domain-1',
'DomainName' => 'contoso.example',
'DisplayName' => 'Contoso remote domain',
'IsDefault' => false,
'AllowedOOFType' => 'External',
'AutoReplyEnabled' => true,
...$overrides,
];
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function spec436InboundConnectorPayload(array $overrides = []): array
{
return [
'ConnectorId' => 'spec436-inbound-connector-1',
'Guid' => 'spec436-inbound-connector-1',
'Identity' => 'spec436-inbound-connector-1',
'DisplayName' => 'Spec 436 inbound connector',
'Name' => 'Spec 436 inbound connector',
'ConnectorType' => 'Partner',
'Enabled' => true,
'RequireTls' => true,
...$overrides,
];
}

View File

@ -0,0 +1,187 @@
# Requirements Checklist: Exchange Content-Backed Evidence Promotion Slice 1
**Purpose**: Validate Spec 436 scope, prerequisites, safety gates, no-promotion posture, and preparation completeness before implementation.
**Created**: 2026-07-08
**Feature**: [spec.md](../spec.md)
## Prerequisites
- [x] Spec 435 PASS/merge-ready is required.
- [x] Spec 435 structured output envelope exists.
- [x] Spec 435 normalizers exist.
- [x] Spec 435 hash builder exists.
- [x] Spec 434 adapter boundary exists.
- [x] Spec 433 readiness exists.
- [x] Spec 432 runner boundary exists.
- [x] Spec 430 command contracts exist.
## Scope
- [x] Only `transportRule`.
- [x] Only `remoteDomain`.
- [x] Only `inboundConnector`.
- [x] No `outboundConnector`.
- [x] No `acceptedDomain`.
- [x] No `organizationConfig`.
- [x] No Teams.
- [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.
## OperationRun
- [x] Uses `tenant_configuration.capture`.
- [x] Invocation operation remains runner truth only.
- [x] No new OperationRun type.
- [x] Safe context only.
- [x] Existing summary keys or tested/amended narrow keys.
- [x] No raw payload.
- [x] No normalizer/hash preview.
- [x] No raw stdout/stderr.
- [x] No credential material.
## Evidence
- [x] Append-only evidence.
- [x] Raw payload in evidence store only.
- [x] Raw payload is structured provider item/envelope.
- [x] Raw stdout/stderr is not evidence.
- [x] Normalized payload persisted.
- [x] Payload hash persisted.
- [x] Persisted `capture_outcome` uses existing repo-allowed evidence outcome values.
- [x] OperationRun linked.
- [x] `workspace_id` set.
- [x] `managed_environment_id` set.
- [x] `provider_connection_id` set.
- [x] No `tenant_id`.
## Normalizer / Hash
- [x] Uses Spec 435 target normalizers.
- [x] Uses Spec 435 hash builder.
- [x] Does not use `GenericPayloadNormalizer`.
- [x] Does not use `ExchangeTeamsComparablePayloadNormalizer`.
- [x] `transportRule` normalized deterministically.
- [x] `remoteDomain` normalized deterministically.
- [x] `inboundConnector` normalized deterministically.
- [x] Volatile fields excluded/demoted.
- [x] Sensitive fields handled.
- [x] Collection order deterministic.
- [x] Hash deterministic.
## Identity
- [x] `CanonicalIdentityResolver` used directly or through Spec 434 identity gate.
- [x] Stable identity required by default.
- [x] Display-name-only stable identity impossible.
- [x] `remoteDomain` domain-only identity blocked.
- [x] `inboundConnector` missing connector identity blocked.
- [x] Duplicate identity blocks.
- [x] Conflicting identity blocks.
- [x] Missing identity blocks.
- [x] Writer not called on unsafe identity.
## Blocking
- [x] Missing credential readiness blocks.
- [x] Client secret blocks.
- [x] Missing permission readiness blocks.
- [x] Wrong-scope permission blocks.
- [x] Stale permission blocks.
- [x] Unsupported provider capability blocks.
- [x] Auth failure blocks.
- [x] Permission failure blocks.
- [x] Source unavailable blocks.
- [x] Malformed output blocks.
- [x] Source unavailable is not treated as empty collection.
- [x] Partial output is not treated as empty collection or success.
- [x] Empty collection creates no fake evidence.
## Redaction
- [x] No raw payload in OperationRun.
- [x] No raw stdout/stderr in OperationRun.
- [x] No normalizer/hash preview in OperationRun.
- [x] No credential material.
- [x] No provider payload in summaries.
- [x] No customer output.
## No Promotion
- [x] Coverage max level `content_backed`.
- [x] Claim state internal-only/customer-blocked.
- [x] No comparable state.
- [x] No renderable state.
- [x] No certified state.
- [x] No restore-ready state.
- [x] No customer-ready state.
- [x] No report output.
- [x] No Review Pack output.
## Product Surface / Trigger
- [x] No routes.
- [x] No Filament pages.
- [x] No Livewire components.
- [x] No navigation.
- [x] No asset changes.
- [x] No global search changes.
- [x] No provider registration changes.
- [x] No destructive UI actions.
- [x] No job trigger.
- [x] No schedule trigger.
- [x] No listener trigger.
- [x] Browser N/A.
## Architecture
- [x] Uses Coverage v2 evidence architecture.
- [x] Uses Spec 434 adapter.
- [x] Uses Spec 435 normalizers/hash builder.
- [x] Uses Spec 433 readiness evaluator.
- [x] Uses Spec 432 runner boundary.
- [x] No Graph gateway fallback.
- [x] No fake Graph endpoint.
- [x] No Exchange-specific evidence table.
- [x] No `tenant_id` ownership truth.
- [x] No Exchange mini-platform.
- [x] No legacy shim.
- [x] No fallback reader.
- [x] No migration.
## Product Surface Contract
- [x] CHK034 The spec references `docs/product/standards/product-surface-contract.md`.
- [x] CHK035 No-legacy posture is explicit.
- [x] CHK036 Product Surface Impact records N/A values with rationale.
- [x] CHK037 Browser proof is `N/A - no rendered UI surface changed`.
- [x] CHK038 Human Product Sanity is N/A.
- [x] CHK039 Implementation report will include Livewire v4, provider registration, global search, destructive/high-impact action, asset, tests/browser, deployment, no completed-spec rewrite, and follow-up fields.
- [x] CHK040 Completed historical specs were not rewritten.
## Validation
- [x] Focused unit tests pass.
- [x] Focused feature tests pass.
- [x] Regressions pass.
- [x] Pint passes.
- [x] `git diff --check` passes.
- [x] `git status --short` documented.
## Review Outcome
- [x] Preparation review outcome class: acceptable-special-case.
- [x] Preparation workflow outcome: keep.
- [x] Final note location: implementation report.
## Final Candidate Gate
Preparation result: PASS.
Implementation PASS requires all runtime validation items above to be checked with evidence. PASS WITH CONDITIONS is allowed only for bounded follow-ups that do not weaken evidence integrity, identity safety, normalization determinism, redaction, readiness gating, no-promotion posture, no-product-surface posture, ownership, or no-mini-platform posture.
FAIL if Spec 435 is not merge-ready, credential/permission gates can be bypassed, fake evidence is created, permission denied is treated as empty data, malformed output is treated as success, unsafe identity creates evidence, raw stdout/stderr is treated as evidence, raw payload leaks outside evidence storage, OperationRun stores raw provider output or normalizer/hash preview, coverage promotes to comparable/renderable/certified, restore/customer claims appear, 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 content-backed evidence safety.

View File

@ -0,0 +1,263 @@
# Implementation Report: Exchange Content-Backed Evidence Promotion Slice 1
Date: 2026-07-09
## Result
- **Status**: PASS.
- **Active spec**: `specs/436-exchange-content-backed-evidence-promotion-slice-1/`.
- **Branch**: `feat/436-exchange-content-backed-evidence-promotion-slice-1`.
- **HEAD at implementation start/end**: `a09cc6ca Spec 435: Exchange structured output readiness (#502)`.
- **Implementation/fix iterations**: 2 in-scope findings fixed, then no remaining in-scope findings.
## Candidate Gate Result
PASS. Spec 435 is present and records PASS/merge-ready. Specs 430-435 were used as read-only prerequisite context. No hard-gate stop condition was met.
## Branch And Dirty State
- Branch at implementation start: `feat/436-exchange-content-backed-evidence-promotion-slice-1`.
- HEAD at implementation start: `a09cc6ca Spec 435: Exchange structured output readiness (#502)`.
- Initial dirty state: untracked active Spec 436 package only.
- Final dirty state: intended Spec 436 runtime/test/spec files plus Spec 434 regression test alignment.
## Files Changed
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellTargetEvidenceNormalizer.php`
- `apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php`
- `apps/platform/tests/Feature/TenantConfiguration/Spec436ExchangeContentBackedEvidenceFeatureTest.php`
- `specs/436-exchange-content-backed-evidence-promotion-slice-1/checklists/requirements.md`
- `specs/436-exchange-content-backed-evidence-promotion-slice-1/implementation-report.md`
- `specs/436-exchange-content-backed-evidence-promotion-slice-1/tasks.md`
## Prerequisite Proof
- Spec 435 prerequisite proof: `specs/435-exchange-structured-output-target-normalizer-readiness/implementation-report.md` records PASS and merge-ready.
- Spec 434 prerequisite proof: `cd apps/platform && php artisan test --filter=Spec434 --compact` passed after updating fixture aliases to current Spec435 identity semantics.
- Spec 433 prerequisite proof: `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec433 --compact` passed.
- Spec 432 prerequisite proof: `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec432|Spec431|Spec430' --compact` passed.
- Spec 430 prerequisite proof: covered in the same Spec430-432 regression command.
## Target Type Outcomes
| Type | Command | Capture Outcome | Evidence? | Blocker |
|---|---|---|---|---|
| `transportRule` | `Get-TransportRule` | `captured` | yes, content-backed only | none |
| `remoteDomain` | `Get-RemoteDomain` | `captured` | yes, content-backed only | domain-only, display-only, duplicate, and conflicting identities block |
| `inboundConnector` | `Get-InboundConnector` | `captured` | yes, content-backed only | missing and unsafe identities block |
## Evidence Matrix
| Type | Raw Payload | Normalized Payload | Hash | Identity | OperationRun | Content-backed? |
|---|---|---|---|---|---|---|
| `transportRule` | evidence row only | target normalizer output plus source metadata | Spec435 hash builder over persisted normalized payload | stable source identity required | `tenant_configuration.capture` linked | yes |
| `remoteDomain` | evidence row only | target normalizer output plus source metadata | Spec435 hash builder over persisted normalized payload | stable source identity required | `tenant_configuration.capture` linked | yes |
| `inboundConnector` | evidence row only | target normalizer output plus source metadata | Spec435 hash builder over persisted normalized payload | stable source identity required | `tenant_configuration.capture` linked | yes |
## No-Promotion Matrix
| Area | State |
|---|---|
| Compare | No |
| Render | No |
| Certification | No |
| Restore | No |
| Customer claim | No |
| UI | No |
| Jobs/Schedules/Listeners | No |
## OperationRun Proof
- Evidence append is gated to `OperationRunType::TenantConfigurationCapture` through the existing eligibility gate.
- Exchange PowerShell invocation runs remain runner truth only and do not own evidence.
- OperationRun context, `summary_counts`, and `failure_summary` are asserted to exclude raw payload, raw stdout/stderr, normalizer previews, hash previews, access tokens, client secrets, and protected inbound connector routing values.
- Summary counts are updated through existing `SummaryCountsNormalizer` behavior only.
## Evidence Persistence Proof
Spec436 feature tests assert one successful fake capture for each target creates one `tenant_configuration_resources` row and one `tenant_configuration_resource_evidence` row with:
- `workspace_id`
- `managed_environment_id`
- `provider_connection_id`
- `operation_run_id`
- target-specific raw payload
- target-specific normalized payload
- source endpoint and source contract metadata
- deterministic payload hash
- permission context
Persisted `capture_outcome` repo-allowed value proof: successful evidence persists `CaptureOutcome::Captured`.
Adapter-only outcome labels absent from persisted evidence proof: prerequisite, identity, empty collection, and sanitized failure adapter labels are returned only in adapter results and do not create evidence rows.
## Append-Only Proof
Spec436 feature coverage runs two captures for the same target and asserts a second evidence row is inserted while the prior evidence row and first payload hash remain unchanged.
## Latest Evidence Pointer Proof
The same append-only test asserts `latest_evidence_id`, `latest_payload_hash`, and `latest_captured_at` advance to the newest evidence row according to the existing writer/upserter pattern.
## Raw Payload Location Proof
Raw provider item payload is persisted only in `tenant_configuration_resource_evidence.raw_payload`. OperationRun state and summaries are tested to exclude raw provider output. Raw stdout/stderr are never used as evidence.
## Structured Envelope Proof
`ExchangePowerShellEvidenceCaptureAdapter` now converts accepted runner output into `ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(...)` using a verified `ExchangePowerShellCommandContract` before normalizer readiness, identity gating, resource upsert, and evidence append.
## Normalizer Integration Proof
- `ExchangePowerShellEvidenceNormalizer::normalizedEvidenceItems()` dispatches to the target normalizer for exactly `transportRule`, `remoteDomain`, and `inboundConnector`.
- `ExchangePowerShellTargetEvidenceNormalizer::normalizedEvidenceItems()` prepares the same validated, sorted, target-normalized items used by readiness.
- The adapter no longer depends on `GenericPayloadNormalizer` or `ExchangeTeamsComparablePayloadNormalizer` for Exchange evidence promotion.
## Hash Proof
Persisted `payload_hash` is computed through `ExchangePowerShellHashInputBuilder` using target type, source surface, command contract name/version, payload shape version, normalizer version, and the persisted normalized payload with source metadata.
## Identity Proof
Spec436 tests prove unsafe identity states block before writer use with no resource/evidence rows:
- remoteDomain domain-only identity
- remoteDomain display-only identity
- duplicate identity
- conflicting aliases
- inboundConnector missing identity
Spec434 regression fixtures were updated so legacy adapter tests use non-conflicting alias values under the current Spec435 identity contract.
## Credential Readiness Proof
Spec436 tests prove missing credential and client-secret credential readiness failures return prerequisite-blocked outcomes with zero summary counts and no evidence rows.
## Permission Readiness Proof
Spec436 tests prove missing permission, stale permission, and wrong-scope permission evidence block with no evidence. The readiness gate remains the source of permission freshness truth.
## Runtime Readiness Proof
Spec436 tests prove warning-prefixed structured output, authentication failure, authorization/permission failure, source unavailable, malformed output, scalar output, binary/non-UTF8 output, oversized output, non-zero exit, partial output, and timeout/non-success runner states block with no evidence.
## Failure Blocking Proof
- Unsupported provider capability blocker proof: covered by Spec434/Spec433 regression commands and provider capability tests.
- Source unavailable is not empty collection proof: covered directly in Spec436 failure-blocking assertions.
- Partial output is not empty collection or success proof: covered directly in Spec436 failure-blocking assertions.
- Empty collections return zero-item summaries and create no resource/evidence rows, covered directly in Spec436.
## Redaction Proof
Inbound connector protected routing values stay in raw evidence only when present in the source payload. Target-normalized payload and OperationRun state use redacted sentinel values for sender IPs, smart hosts, certificate names, and comments.
## Forbidden Path Proof
- No `GenericPayloadNormalizer` for Exchange evidence promotion: static guard test asserts the adapter source excludes it.
- No `ExchangeTeamsComparablePayloadNormalizer` for Exchange evidence promotion: static guard test asserts the adapter source excludes it.
- No Graph gateway or fake Graph endpoint: static guard test asserts no Graph gateway/fake endpoint path was introduced.
- No compare/render/certification: content-only guard and static assertions cover this.
- No restore/customer claim: content-only guard and static assertions cover this.
- No UI/route/job/schedule/listener: static guard test asserts no route, UI, Livewire, job, console, or migration trigger was added for the slice.
- No `tenant_id`: static/schema guard asserts no `tenant_id` ownership path was introduced.
- No mini-platform: static guard asserts no Exchange-specific table/dashboard/fallback reader/mini-platform was introduced.
## Product Surface Close-Out
- **Runtime UI files changed**: no.
- **UI impact**: N/A - no rendered UI surface changed.
- **Product Surface exceptions**: none.
- **Browser proof**: N/A - no rendered UI surface changed.
- **Human Product Sanity**: N/A - no product surface changed.
- **Visible complexity outcome**: neutral.
- **No-legacy confirmation**: canonical replacement; no aliases, fallback readers, hidden routes, duplicate UI, or compatibility shim.
- **Completed-spec rewrite assertion**: completed Specs 430-435 were not rewritten.
- **Livewire v4 compliance**: unchanged; no Livewire code changed.
- **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 trigger was 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
- `php -l` on changed PHP files
- Result: PASS for adapter, dispatcher, target normalizer, and Spec436 feature test.
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec436 --compact`
- Result: failed before completion with Symfony Process signal 9 after final output/permission cases. Non-Docker fallback used.
- `cd apps/platform && php artisan test --filter=Spec436 --compact`
- Result: PASS, 28 tests / 307 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec435 --compact`
- Result: PASS, 28 tests / 167 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec434 --compact`
- Result: failed before completion with Symfony Process signal 9. Non-Docker fallback used.
- `cd apps/platform && php artisan test --filter=Spec434 --compact`
- Result: PASS, 25 tests / 154 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec433 --compact`
- Result: PASS, 48 tests / 211 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec432|Spec431|Spec430' --compact`
- Result: PASS, 173 tests / 1357 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec415|Spec417|Spec419|Spec420|Spec426|Spec427' --compact`
- Result: failed before completion with Symfony Process signal 9 after partial progress. Non-Docker fallback used.
- `cd apps/platform && php artisan test --filter='Spec415|Spec417|Spec419|Spec420|Spec426|Spec427' --compact`
- Result: PASS, 202 passed / 8 skipped / 2137 assertions. Skips are existing PostgreSQL-specific skips. Existing browser tests in this filter passed.
- `cd apps/platform && ./vendor/bin/sail artisan test --filter='ProviderCapabilityRegistryTest|ProviderCapabilityEvaluationTest' --compact`
- Result: PASS, 7 tests / 30 assertions.
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- Result: PASS.
- `git diff --check`
- Result: PASS.
- `git status --short`
- Result: intended Spec436 runtime/test/spec files plus Spec434 regression test alignment.
## Post-Implementation Analysis
Iteration 1 confirmed and fixed one in-scope regression-alignment finding:
- **Medium**: Spec434 regression fixtures still used conflicting Exchange alias IDs and expected older display-only reason labels. Updated fixtures and expectations to match the stricter Spec435 identity contract while keeping the adapter fail-closed.
Iteration 2 confirmed and fixed one in-scope test-coverage finding:
- **Medium**: Spec436 initially relied on prerequisite regressions for several named output failure modes and did not directly prove empty collections create no fake evidence at the adapter boundary. Added direct Spec436 cases for empty collection, authentication failure, authorization/permission failure, source unavailable, malformed output, scalar output, binary/non-UTF8 output, oversized output, non-zero exit, partial output, and timeout.
Iteration 3 found no remaining in-scope findings.
Remaining in-scope findings: none.
Residual risks: none for this slice. Comparable/renderable promotion, Teams PowerShell evidence slices, certified compare packs, customer output, and restore/apply flows remain deferred.
## Deferred Work
- Spec 437 Exchange comparable/renderable promotion.
- Teams PowerShell evidence slices.
- Certified compare pack.
- Customer output and claim guard.
- Restore/apply flows.
## 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 `436-exchange-content-backed-evidence-promotion-slice-1` streng repo-basiert durch.
Ziel:
Pruefe, ob die Implementierung nach dem Agenten-Loop wirklich merge-ready ist.
Wichtig:
- Keine Implementierung.
- Keine Codeaenderungen.
- Keine Scope-Erweiterung.
- Pruefe gegen spec.md, plan.md, tasks.md und constitution.md.
- Pruefe die geaenderten Dateien, Tests, Browser-Smoke-Test-Ergebnis, RBAC, Workspace-/Tenant-Isolation, Auditability, UX und OperationRun-Semantik, soweit relevant.
- Benenne nur konkrete Findings mit Repo-Beleg.
- Gib am Ende eine klare Entscheidung: Merge-ready, merge-ready with notes, oder not merge-ready.
```

View File

@ -0,0 +1,225 @@
# Implementation Plan: Exchange Content-Backed Evidence Promotion Slice 1
**Branch**: `feat/436-exchange-content-backed-evidence-promotion-slice-1` | **Date**: 2026-07-08 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/436-exchange-content-backed-evidence-promotion-slice-1/spec.md`
## Summary
Promote exactly `transportRule`, `remoteDomain`, and `inboundConnector` to append-only internal content-backed Coverage v2 evidence through the Exchange PowerShell capture adapter. The implementation must replace the current generic/comparable normalizer write path with Spec 435 structured envelopes, target-specific normalizers, and hash builder; preserve Spec 434 identity/content-only guards and empty-collection policy; consume Spec 433 readiness, Spec 432 runner boundary, and Spec 430 command contracts; and add no UI, migration, trigger, compare/render, certification, restore, or customer claim.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament 5, Livewire 4
**Primary Dependencies**: Existing Laravel services, Coverage v2 models, Pest 4, Exchange PowerShell support classes
**Storage**: PostgreSQL through existing `tenant_configuration_resources` and `tenant_configuration_resource_evidence`; no migration planned
**Testing**: Pest 4 Unit and Feature tests
**Validation Lanes**: fast-feedback/confidence; PostgreSQL lane only if implementation unexpectedly touches migration/schema behavior and the spec is amended
**Target Platform**: Laravel platform app under `apps/platform`
**Project Type**: web monolith backend-only slice
**Performance Goals**: deterministic per-item normalization/hash without external calls during tests; no UI render path or browser cost
**Constraints**: no raw stdout/stderr evidence, no raw payload outside evidence storage, no generic/comparable normalizer promotion path, no evidence on unsafe identity or failed gates
**Scale/Scope**: exactly three Exchange resource types and fake-runner test coverage
## UI / Surface Guardrail Plan
- **Guardrail scope**: no operator-facing surface change.
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: N/A.
- **No-impact class, if applicable**: backend-only.
- **Native vs custom classification summary**: N/A.
- **Shared-family relevance**: backend evidence/OperationRun only; no rendered shared interaction family.
- **State layers in scope**: backend capture/evidence only.
- **Audience modes in scope**: N/A.
- **Decision/diagnostic/raw hierarchy plan**: raw provider payload remains evidence-storage-only; diagnostics stay internal; no product layer.
- **Raw/support gating plan**: raw payload stored only in evidence storage; no UI support/raw surface.
- **One-primary-action / duplicate-truth control**: N/A.
- **Handling modes by drift class or surface**: hard-stop-candidate for any UI, route, trigger, report, Review Pack, PDF, or customer output.
- **Repository-signal treatment**: review-mandatory for any guarded path change; hard-stop for UI/migration/trigger scope.
- **Special surface test profiles**: N/A.
- **Required tests or manual smoke**: browser `N/A - no rendered UI surface changed`.
- **Exception path and spread control**: none.
- **Active feature PR close-out entry**: `N/A - no rendered UI surface changed`.
- **UI/Productization coverage decision**: No UI surface impact.
- **Coverage artifacts to update**: none.
- **No-impact rationale**: backend-only evidence promotion through existing tables and services.
- **Navigation / Filament provider-panel handling**: checked no-impact; no provider registration/global search/asset changes.
- **Screenshot or page-report need**: no.
## Product Surface Contract Plan
- **Product Surface Contract reference**: `docs/product/standards/product-surface-contract.md`.
- **No-legacy posture**: canonical replacement; no compatibility exception.
- **Page archetype and surface budget plan**: N/A.
- **Technical Annex and deep-link demotion plan**: OperationRun details, raw evidence payloads, IDs, source keys, payloads, normalized payloads, and hashes stay internal and are not rendered.
- **Canonical status vocabulary plan**: N/A for UI.
- **Product Surface exceptions**: none.
- **Browser verification plan**: `N/A - no rendered UI surface changed`.
- **Human Product Sanity plan**: N/A.
- **Visible complexity outcome target**: neutral.
- **Implementation report target**: `specs/436-exchange-content-backed-evidence-promotion-slice-1/implementation-report.md`.
## Filament / Livewire / Deployment Posture
- **Livewire v4 compliance**: unchanged; no Livewire runtime code planned.
- **Panel provider registration location**: no panel change; Laravel provider registration remains `apps/platform/bootstrap/providers.php`.
- **Global search posture**: no Resource/global search change.
- **Destructive/high-impact action posture**: none; no UI action or start surface added.
- **Asset strategy**: no assets; `filament:assets` is not newly required.
- **Testing plan**: Unit/Feature for adapter wiring, target normalizers, hash determinism, identity blocking, append-only evidence, OperationRun safety, redaction, no-promotion, no-surface, and regressions.
- **Deployment impact**: no env vars, migrations, queues, scheduler, storage, assets, or Dokploy runtime changes planned.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes, backend evidence/capture only.
- **Systems touched**: Exchange PowerShell adapter/eligibility/identity/content-only guard, structured output envelope, target normalizers, hash builder, Coverage v2 writer/upserter, OperationRun safe context, provider readiness.
- **Shared abstractions reused**: `tenant_configuration.capture`, `OperationSummaryKeys`, `ExchangePowerShellStructuredOutputEnvelope`, `ExchangePowerShellEvidenceNormalizer`, `ExchangePowerShellHashInputBuilder`, `CanonicalIdentityResolver`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`.
- **New abstraction introduced? why?**: none planned. Use existing Spec 434/435 classes. If a helper is needed, keep it private/narrow and document in the report.
- **Why the existing abstraction was sufficient or insufficient**: Coverage v2 storage and Spec 434/435 services are sufficient. The current adapter normalizer path is insufficient because it still uses `GenericPayloadNormalizer` and `ExchangeTeamsComparablePayloadNormalizer`.
- **Bounded deviation / spread control**: Exchange-specific normalization stays provider-owned and does not alter platform-core evidence table semantics.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no rendered UX; internal OperationRun ownership/context only.
- **Central contract reused**: existing `tenant_configuration.capture` lifecycle conventions.
- **Delegated UX behaviors**: N/A.
- **Surface-owned behavior kept local**: none.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: unchanged.
- **Exception path**: none; adding a new operation/start UX requires spec amendment.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes.
- **Provider-owned seams**: Exchange PowerShell command contracts, source surface, structured item shape, target-specific normalizers, protected field handling.
- **Platform-core seams**: Coverage v2 evidence/resource storage, OperationRun truth, workspace/managed-environment/provider scope, identity hard-stop, redaction/no-customer-output, no-promotion posture.
- **Neutral platform terms / contracts preserved**: provider connection, managed environment, evidence, capture outcome, content-backed, operation run, canonical identity, payload hash.
- **Retained provider-specific semantics and why**: `transportRule`, `remoteDomain`, `inboundConnector`, and Exchange PowerShell contract names are required for this bounded first Exchange slice.
- **Bounded extraction or follow-up path**: Spec 437 can promote comparable/renderable only after Spec 436 passes.
## Constitution Check
| Principle | Plan Result | Notes |
|---|---|---|
| Inventory-first / evidence truth | PASS | Writes existing Coverage v2 evidence as internal source-backed proof; no customer/product truth. |
| Read/write separation | PASS | Backend evidence append only through trusted flow; no destructive or restore/apply action. |
| Graph contract path | PASS | Exchange PowerShell path must not route through Graph gateway or fake Graph endpoints. |
| Deterministic capabilities | PASS | Spec 433 readiness and provider capability stay gate truth. |
| RBAC / workspace isolation | PASS | Existing scope fields and provider connection scope required; no route/action change. |
| OperationRun truth | PASS | `tenant_configuration.capture` is evidence owner; invocation operation remains runner truth. |
| Summary counts | PASS | Use allowed keys or amend spec before adding keys. |
| Data minimization | PASS | Raw structured provider item only in evidence storage; no raw stdout/stderr/transcripts. |
| Test governance | PASS | Unit/Feature focused tests; Browser N/A. |
| Proportionality | PASS | No new table, OperationRun type, UI framework, or status family planned. |
| Provider boundary | PASS | Exchange semantics remain provider-owned. |
| Product Surface | PASS | N/A - no rendered UI surface changed. |
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for normalizer/hash/identity blockers; Feature for DB-backed evidence append, OperationRun safety, append-only/latest-pointer, redaction, no-surface guard.
- **Affected validation lane(s)**: fast-feedback/confidence. Browser N/A.
- **Fixture/helper/factory/seed/context cost**: reuse existing Spec 430-435 fake runner/readiness fixtures; keep provider/workspace setup explicit.
- **Heavy-governance or browser family impact**: none.
- **Narrowest reviewer command**: focused `Spec436` tests plus listed regression filters.
- **Budget/baseline/trend follow-up**: none expected.
- **Review outcome target**: `document-in-feature` for any bounded non-blocking conditions; `reject-or-split` for UI/migration/trigger/target expansion/no-promotion violations.
## Technical Approach
1. Re-check Spec 435 PASS/merge-ready state and current branch/dirty state.
2. Add failing focused tests for adapter wiring, target-specific normalizer usage, hash determinism, identity blocking, append-only evidence, raw payload location, OperationRun sanitization, no-promotion, no-surface, and no-tenant-id behavior.
3. Update `ExchangePowerShellEvidenceCaptureAdapter` so non-empty eligible Exchange captures build a Spec 435 envelope from runner output, evaluate target-specific normalizer readiness, use normalized target payloads and `ExchangePowerShellHashInputBuilder` hash output, and append evidence with content-only cap.
4. Remove or bypass `GenericPayloadNormalizer` and `ExchangeTeamsComparablePayloadNormalizer` for Exchange evidence promotion; preserve generic Graph capture path.
5. Keep Spec 434 eligibility, identity, content-only, and empty-collection behavior intact.
6. Ensure OperationRun context/summary remains sanitized and uses allowed summary keys.
7. Run focused tests, regressions, Pint, `git diff --check`, and complete the implementation report.
## Domain / Model Implications
- No new models, migrations, tables, columns, enum/status family, OperationRun type, UI state, or customer output artifact.
- Existing `TenantConfigurationResource` latest pointer update remains the source for latest evidence state.
- Existing `TenantConfigurationResourceEvidence` stores raw structured provider item, normalized payload, hash, source metadata, operation, scope, and content-backed coverage state.
## Data / Migration Plan
No migration. Runtime must use existing Coverage v2 tables. If a schema gap is discovered, implementation must stop and amend spec/plan/tasks before proceeding.
## Security / RBAC / Redaction Plan
- Reuse trusted backend flow and provider/capture authorization.
- Preserve workspace, managed environment, and provider connection scope checks.
- Require combined Exchange readiness before evidence append.
- Keep protected Exchange config out of OperationRun context/logs/summaries/customer output.
- Store raw structured provider item only in evidence storage.
- Add sentinel tests for credential, token, stdout/stderr, transcript, normalizer preview, hash preview, and protected connector/config fields.
## Implementation Phases
### Phase 0 - Preflight
- Capture branch, HEAD, and `git status --short`.
- Confirm Spec 435 PASS/merge-ready and prerequisite proof from Specs 430-435.
- Confirm no UI/routes/jobs/schedules/listeners/migration/tenant_id scope.
### Phase 1 - Adapter Integration Update
- Wire Exchange adapter to Spec 435 structured envelope.
- Wire Exchange adapter to Spec 435 target normalizers.
- Wire Exchange adapter to Spec 435 hash input builder.
- Remove/avoid generic/Teams comparable normalizer path for Exchange evidence.
- Preserve Graph/generic capture path.
### Phase 2 - Evidence Persistence
- Use Coverage v2 evidence writer.
- Persist raw structured provider payload in evidence storage only.
- Persist normalized target payload.
- Persist payload hash.
- Link OperationRun.
- Preserve append-only behavior.
- Update latest pointer according to repo pattern.
### Phase 3 - Target Evidence Promotion
- Implement `transportRule` capture.
- Implement `remoteDomain` capture with stable identity only.
- Implement `inboundConnector` capture with stable identity/redaction only.
- Keep blocked states explicit.
### Phase 4 - Blocking / Empty / Failure Semantics
- Block readiness failures.
- Block unsupported provider capability.
- Block output failures.
- Block unsafe identity.
- Preserve empty collection no-evidence policy.
- Add exact blocker outcomes and prove persisted evidence `capture_outcome` values stay within existing repo-allowed values.
### Phase 5 - Redaction / No-Promotion
- Prove OperationRun context sanitized.
- Prove raw payload only in evidence storage.
- Prove content-backed-only coverage.
- Prove no UI/jobs/schedules/listeners/customer output.
### Phase 6 - Regression / Report
- Run focused tests and regressions.
- Run Pint.
- Run `git diff --check`.
- Complete implementation report.
## Rollout Considerations
- Staging/production deployment impact: none beyond code/test deployment.
- No env vars, migrations, queues, scheduler, storage, asset build, reverse proxy, or Dokploy change planned.
- Rollback is reverting the adapter/test changes and the Spec 436 artifacts.
## Stop Conditions
Stop and amend/split if implementation needs:
- UI, routes, Filament, Livewire, assets, global search, provider registration, reports, Review Packs, customer output, jobs, schedules, listeners, console commands, or browser proof.
- migration/schema changes, new evidence table, `tenant_id`, new OperationRun type, or new persisted outcome/summary key.
- target expansion beyond the three included types.
- compare/render/certification/restore/customer claim.
- Graph gateway fallback or fake Graph endpoints.
- durable collection-level empty proof.

View File

@ -0,0 +1,980 @@
# Feature Specification: Exchange Content-Backed Evidence Promotion Slice 1
**Feature Branch**: `feat/436-exchange-content-backed-evidence-promotion-slice-1`
**Created**: 2026-07-08
**Status**: Draft
**Type**: Coverage v2 / Microsoft 365 / Exchange / Content-Backed Evidence / Evidence Normalization / Evidence Hashing
**Priority**: P0
**Risk**: Critical
**Input**: User-provided Spec 436 draft for first Exchange PowerShell content-backed evidence promotion slice.
**Implementation Branch Gate**: Runtime implementation must remain on `feat/436-exchange-content-backed-evidence-promotion-slice-1` per `AGENTS.md`, or stop and record an explicit branch exception before changing runtime files.
## Preparation Selection
- **Selected candidate**: Spec 436 - Exchange Content-Backed Evidence Promotion Slice 1.
- **Source location**: Direct user-provided manual P0 candidate in the current request.
- **Why selected**: `docs/product/spec-candidates.md` has no safe automatic next-best-prep target, but the user supplied the next roadmap slice after merge-ready Spec 435. Spec 436 is the first bounded slice where the three proven Exchange targets may become internal content-backed evidence.
- **Close alternatives deferred**: Spec 437 comparable/renderable promotion, Teams PowerShell slices, certification, restore/apply, customer output, Review Packs, reports, UI/product surfaces, schedules, jobs, and target expansion remain deferred because Spec 436 must first prove safe append-only evidence.
- **Roadmap relationship**: Continues the corrected sequence 429 through 442. Specs 430-435 create command, invocation, runner, readiness, adapter, structured output, target normalizer, and hash-readiness prerequisites. Spec 436 consumes those prerequisites for first Exchange content-backed evidence only.
- **Completed-spec guardrail result**: Specs 430-435 are completed or implementation-closed read-only context and were not modified during preparation. Spec 435 implementation report records PASS and merge-ready. No existing `specs/436-*` package or branch was found before preparation.
- **Smallest viable implementation slice**: Wire the existing Exchange capture adapter to Spec 435 structured envelopes, target-specific normalizers, and hash input builder for exactly `transportRule`, `remoteDomain`, and `inboundConnector`; append internal content-backed evidence only when all gates pass; fail closed otherwise.
- **Feature description fed into Spec Kit**: Promote `transportRule`, `remoteDomain`, and `inboundConnector` to append-only internal content-backed Coverage v2 evidence through the existing Exchange PowerShell capture adapter, using Spec 435 structured envelopes, target normalizers, and hash input builder; Spec 434 identity/content-only guards; Spec 433 readiness; Spec 432 runner boundary; and Spec 430 command contracts, with no UI, no migration, no jobs, no compare/render/certification/restore/customer claims, no `tenant_id`, and no Exchange mini-platform.
## Activated Skills And Gates
- **Activated Codex skill**: `spec-kit-next-best-prep` because this turn prepares Spec Kit artifacts only and explicitly stops before application implementation.
- **Repo skill router result**: No runtime implementation skill is activated in this preparation turn.
- **Planning gates carried into artifacts**: spec readiness, workspace-scope safety, RBAC/action safety, OperationRun truth, evidence-anchor contract, provider freshness semantics, customer-output gate, Product Surface gate, and temporary Coverage v2 / TCM cutover guard.
- **Preparation hard-gate stop conditions**: none. Runtime implementation must stop if it needs UI, routes, jobs, schedules, listeners, migrations, a new OperationRun type, `tenant_id`, Graph gateway fallback, fake Graph endpoints, generic/Teams comparable normalizers for Exchange promotion, compare/render/certification/restore/customer claims, or an Exchange-specific evidence table.
## Spec Candidate Check
- **Problem**: TenantPilot has verified Exchange command contracts, readiness gates, a runner boundary, an adapter boundary, identity guards, structured output envelopes, target-specific normalizer readiness, and hash-readiness proof, but it still has no actual Coverage v2 evidence rows for the first eligible Exchange targets.
- **Today's failure**: Exchange runner success can be proven internally, but the product cannot yet create source-backed append-only evidence for `transportRule`, `remoteDomain`, or `inboundConnector`. If implemented loosely, it could overclaim readiness, persist unsafe identity, treat raw stdout as evidence, leak protected Exchange configuration into OperationRun context, or promote directly to comparable/renderable/certified/customer-ready states.
- **User-visible improvement**: Future operators and reviewers get honest internal evidence truth: selected Exchange resources are content-backed only when readiness, structured output, target normalization, deterministic hashing, canonical identity, redaction, and scope gates all pass. Any blocker creates no evidence and produces safe internal context.
- **Smallest enterprise-capable version**: A backend-only internal capture path for exactly three Exchange resource types, appending existing Coverage v2 evidence rows and updating the latest evidence pointer only when gates pass. No UI, trigger, migration, customer output, restore, certification, compare/render, target expansion, or new operation type.
- **Explicit non-goals**: No compare output, render models, certification, restore/apply, Review Pack/PDF/report output, customer claims, Filament pages, Livewire components, routes, navigation, capture buttons, jobs, schedules, listeners, Teams PowerShell, Exchange Admin API, outboundConnector, acceptedDomain, organizationConfig, mailboxPlan, sharingPolicy, arbitrary commands, mutation commands, Exchange-specific evidence tables, `tenant_id`, legacy shims, fallback readers, or durable collection-level empty proof.
- **Permanent complexity imported**: Adapter wiring changes, possible constructor dependency changes, target-specific evidence promotion tests, feature tests for append-only evidence, and a preparation/implementation report. No new persisted table, OperationRun type, UI framework, product taxonomy, or provider mini-platform.
- **Why now**: Spec 435 is PASS and merge-ready, and Spec 437 comparable/renderable work must not begin until content-backed evidence exists and is proven safe for the eligible targets.
- **Why not local**: The current adapter still has a generic/comparable normalizer path. This safety-critical evidence boundary must be explicit, tested, and reusable by the immediate next Exchange roadmap slice.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: New evidence promotion wiring and foundation-like sequence. Defense: the slice is bounded to three already-proven targets, uses existing Coverage v2 tables, removes unsafe generic promotion paths, and prevents false customer/product claims.
- **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 evidence capture under existing workspace, managed environment, provider connection, OperationRun, and Coverage v2 boundaries.
- **Primary Routes**: none.
- **Data Ownership**: Existing Coverage v2 ownership remains `workspace_id`, `managed_environment_id`, and `provider_connection_id`. `tenant_configuration.capture` is the evidence-owning OperationRun. No `tenant_id` ownership truth and no Exchange-specific evidence table.
- **RBAC**: No new user action or route. Capture must remain reachable only through trusted backend flows that already satisfy provider/capture authorization, readiness, and operation scope gates.
For canonical-view specs: N/A, no canonical UI view is introduced or changed.
## 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**: TenantPilot is pre-production for this path. Spec 436 must replace unsafe generic Exchange promotion behavior rather than preserve it.
## 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 436 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. OperationRun, raw evidence payloads, IDs, source keys, provider diagnostics, protected config, normalized payloads, and hashes stay internal and are not rendered.
- **Canonical status vocabulary**: N/A for UI. Internal capture outcomes 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, visible complexity outcome, and no completed-spec rewrite assertion.
## Cross-Cutting / Shared Pattern Reuse
- **Cross-cutting feature?**: yes, backend evidence/capture readiness only.
- **Interaction class(es)**: evidence persistence, OperationRun execution truth, provider readiness, identity evaluation, capture outcome summarization, redaction.
- **Systems touched**: existing TenantConfiguration/Coverage v2 Exchange PowerShell runner, output guard, capture adapter, target normalizers, hash input builder, canonical identity, evidence writer, provider capability readiness, and OperationRun summary-count conventions.
- **Existing pattern(s) to extend**: `ExchangePowerShellEvidenceCaptureAdapter`, `ExchangePowerShellStructuredOutputEnvelope`, `ExchangePowerShellEvidenceNormalizer`, `ExchangePowerShellTargetEvidenceNormalizer`, `ExchangePowerShellHashInputBuilder`, `CanonicalIdentityResolver`, `ExchangePowerShellIdentityEvidenceGate`, `ExchangePowerShellContentOnlyEvidenceGuard`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, and `OperationSummaryKeys`.
- **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 Coverage v2 resource/evidence storage is sufficient. The current adapter normalization path is insufficient because it still routes through `GenericPayloadNormalizer` and `ExchangeTeamsComparablePayloadNormalizer`, which Spec 436 forbids for Exchange evidence promotion.
- **Allowed deviation and why**: bounded provider-owned Exchange normalizer path is required because Exchange PowerShell object shape is provider-specific. It must remain behind Exchange-named classes and must not become platform-core truth.
- **Consistency impact**: OperationRun remains execution truth, evidence rows are append-only, scope remains workspace + managed environment + provider connection, and customer/product claims remain blocked.
- **Review focus**: verify no Graph gateway fallback, no fake Graph endpoints, no generic/comparable normalizer promotion path, no raw stdout/stderr evidence, no target expansion, and no customer claim.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no rendered UX. It touches internal OperationRun ownership/context requirements for capture evidence.
- **Shared OperationRun UX contract/layer reused**: N/A for UI. Runtime must use existing `tenant_configuration.capture` operation truth and existing OperationRun lifecycle conventions.
- **Delegated start/completion UX behaviors**: N/A.
- **Local surface-owned behavior that remains**: none.
- **Queued DB-notification policy**: N/A. No new queue/start UI or notification opt-in.
- **Terminal notification path**: unchanged central lifecycle behavior for `tenant_configuration.capture`.
- **Exception required?**: none. If implementation needs a new OperationRun type, queued notification, start surface, run link behavior, or UI path, stop and amend/split the spec.
## Provider Boundary / Platform Core Check
- **Shared provider/platform boundary touched?**: yes.
- **Boundary classification**: mixed. Exchange PowerShell command/output/normalizer semantics are provider-owned. Coverage v2 evidence safety, OperationRun truth, identity safety, redaction, scope, and customer-claim boundaries are platform-core.
- **Seams affected**: command contract selection, runner-result acceptance, structured output envelope, target normalizer dispatch, hash input rules, identity resolution, evidence writer maximum level, OperationRun context/summary counts, provider readiness and `exchange_powershell_invoke` capability gates.
- **Neutral platform terms preserved or introduced**: provider connection, managed environment, resource type, evidence, operation run, capture outcome, normalized payload, payload hash, canonical identity, content-backed.
- **Provider-specific semantics retained and why**: `Get-TransportRule`, `Get-RemoteDomain`, `Get-InboundConnector`, `exchange_online_powershell_rest`, and target-specific protected fields remain bounded provider-owned semantics.
- **Why this does not deepen provider coupling accidentally**: provider-specific shape handling stays behind Exchange-named services/tests and persists only through existing provider-neutral Coverage v2 evidence tables.
- **Follow-up path**: Spec 437 may promote comparable/renderable semantics only after Spec 436 proves append-only content-backed evidence safety.
## 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 content-backed evidence promotion | no | N/A | backend evidence/OperationRun only | backend capture/evidence only | no | `N/A - no rendered UI surface changed` |
## Proportionality Review
- **New source of truth?**: no. Evidence truth remains existing Coverage v2 evidence rows; execution truth remains `OperationRun`.
- **New persisted entity/table/artifact?**: no new table or persisted artifact family. Spec 436 writes existing evidence rows only.
- **New abstraction?**: no broad new abstraction is planned. Runtime may add narrow private helper methods or tests, but must prefer existing Spec 434/435 classes.
- **New enum/state/reason family?**: no persisted enum/status family. If a new capture outcome or summary key is unavoidable, implementation must amend this spec before continuing.
- **New cross-domain UI framework/taxonomy?**: no.
- **Current operator problem**: Future Exchange readiness and compare/render claims would be false unless source-backed evidence exists and is safely scoped, normalized, hashed, and identity-stable.
- **Existing structure is insufficient because**: Spec 434 created the adapter/guard but still uses generic/Teams comparable normalizer paths for actual write-time payload normalization; Spec 435 provides target-specific readiness classes that now must be wired into persistence.
- **Narrowest correct implementation**: replace the adapter's Exchange write path with Spec 435 structured envelope, target normalizers, and hash builder for exactly three targets while retaining existing Coverage v2 tables and guards.
- **Ownership cost**: maintain target-specific Exchange evidence promotion tests and no-promotion guard tests until comparable/renderable promotion is separately approved.
- **Alternative intentionally rejected**: reuse `GenericPayloadNormalizer` or `ExchangeTeamsComparablePayloadNormalizer` because that would blur evidence promotion with generic or comparable/renderable semantics.
- **Release truth**: current-release prerequisite for immediate next Exchange comparable/renderable slice.
## 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**: Adapter wiring, normalization/hash determinism, identity blocking, append-only evidence, OperationRun sanitization, and no-surface assertions can be proven with fake runner output and DB-backed feature tests.
- **New or expanded test families**: focused Spec 436 TenantConfiguration unit/feature tests.
- **Fixture / helper cost impact**: local Exchange structured output fixtures and existing provider readiness fixtures only; no 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 content-backed evidence safety without UI, trigger, migration, `tenant_id`, Graph fallback, raw stdout/stderr evidence, compare/render/certification/restore/customer claims, or mini-platform drift.
- **Budget / baseline / trend impact**: expected focused additions only; no heavy-governance or browser budget impact.
- **Escalation needed**: reject-or-split for evidence scope expansion, UI, migration, target expansion, compare/render, certification, restore, customer output, new operation type, or durable empty-collection proof.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage: N/A no rendered UI surface changed.
## Executive Summary
Spec 430 created verified Exchange PowerShell command contracts for `transportRule`, `remoteDomain`, and `inboundConnector`.
Spec 431 registered and gated the Exchange PowerShell invocation operation.
Spec 432 created the production-runner boundary and runtime gate.
Spec 433 created Exchange credential and permission evidence readiness.
Spec 434 created the internal Exchange evidence capture adapter boundary, identity hard-stop, content-only guard, and empty-collection no-evidence policy.
Spec 435 proved structured Exchange output and target-specific normalizer readiness for the three targets.
Spec 436 is the first actual Exchange content-backed evidence promotion slice. It promotes only `transportRule`, `remoteDomain`, and `inboundConnector` to internal content-backed evidence when all gates pass.
Spec 436 must wire the Exchange evidence capture adapter to:
- Spec 435 structured output envelope.
- Spec 435 target-specific normalizers.
- Spec 435 hash input builder.
- Spec 434 identity hard-stop.
- Spec 434 content-only writer guard.
- Spec 433 readiness evaluator.
- Spec 432 production runner boundary.
- Spec 430 verified command contracts.
Spec 436 does not implement compare/render, certification, restore/apply, UI, routes, Filament pages, Livewire components, jobs, schedules, listeners, reports, review packs, customer claims, or target expansion.
## Prerequisites
Spec 436 may begin runtime implementation only after Spec 435 is PASS and merge-ready.
Required proof:
- Spec 435 structured Exchange output envelope exists and excludes raw stdout/stderr.
- Spec 435 normalizer readiness is proven for `transportRule`, `remoteDomain`, and `inboundConnector`.
- Spec 435 hash input rules are deterministic.
- Spec 434 adapter boundary exists and uses `tenant_configuration.capture` as evidence owner.
- Spec 434 identity hard-stop, content-only guard, and empty-collection no-evidence policy exist.
- Spec 433 combined credential/permission readiness exists and blocks client-secret/admin-consent-only paths.
- Spec 432 production runner boundary, output guards, timeout/concurrency guards, and sanitized context exist.
- Spec 430 command contracts exist for `Get-TransportRule`, `Get-RemoteDomain`, and `Get-InboundConnector`; mutation/raw commands are rejected.
Hard stop if runtime implementation:
- Uses `GenericPayloadNormalizer` for Exchange evidence promotion.
- Uses `ExchangeTeamsComparablePayloadNormalizer` for Exchange evidence promotion.
- Routes Exchange capture through Graph provider gateway or fake Graph endpoints.
- Uses `tenant_configuration.exchange_powershell_invocation` as evidence owner.
- Adds a new OperationRun type.
- Bypasses readiness, runner boundary, structured envelope, target normalizer, hash builder, identity hard-stop, or content-only guard.
- Adds UI, routes, Filament, Livewire, jobs, schedules, listeners, migrations, `tenant_id`, compare/render/certification/restore/customer state, target expansion, or an Exchange mini-platform.
## Current Problem
TenantPilot can prove command contracts, invocation gates, production runner safety, credential/permission readiness, adapter boundary safety, structured output, target normalizer readiness, identity readiness, redaction readiness, and hash input readiness. It still cannot create append-only Coverage v2 evidence rows for the first three eligible Exchange targets through the safe Exchange capture path.
## Goal
Implement append-only content-backed evidence for:
- `transportRule`
- `remoteDomain`
- `inboundConnector`
Spec 436 must:
1. Use the existing Spec 434 Exchange evidence capture adapter.
2. Reuse `tenant_configuration.capture` as the evidence-owning OperationRun.
3. Keep `tenant_configuration.exchange_powershell_invocation` as runner/invocation truth only.
4. Use Spec 435 structured output envelope, target normalizers, and hash input builder.
5. Remove or bypass generic/Teams comparable normalizer paths for Exchange evidence promotion.
6. Use Spec 433 combined readiness, Spec 432 runner boundary, and Spec 430 command contracts.
7. Capture only through trusted application flow.
8. Persist raw structured provider item/envelope only in Coverage v2 evidence storage.
9. Persist deterministic normalized payload and deterministic payload hash.
10. Use `CanonicalIdentityResolver` and require safe/stable identity before evidence append.
11. Preserve empty collection no-evidence policy.
12. Keep coverage capped at `content_backed`.
13. Keep claims internal-only/customer-blocked.
14. Prevent compare/render/certification/restore/customer promotion.
## Target Scope
Included resource types:
- `transportRule`
- `remoteDomain`
- `inboundConnector`
Included commands:
- `Get-TransportRule`
- `Get-RemoteDomain`
- `Get-InboundConnector`
Explicitly excluded:
- `outboundConnector`
- `acceptedDomain`
- `organizationConfig`
- `mailboxPlan`
- `sharingPolicy`
- all Teams types
- all Security & Compliance types
- all mutation commands
- all restore commands
- all customer-facing surfaces
No target expansion is allowed.
## Target Eligibility
### transportRule
Eligible when all gates pass.
Required:
- stable identity through `id`, `sourceId`, `Guid`, or `RuleId`
- Spec 435 `ExchangeTransportRuleEvidenceNormalizer`
- redaction for conditions/actions/exceptions
- deterministic hash input
- content-only writer guard
Block if stable identity is missing, duplicate, conflicting, unsafe, normalizer/hash/readiness fails, or output shape is unsafe.
### remoteDomain
Eligible only when stable source identity is present.
Allowed stable identity candidates:
- `id`
- `sourceId`
- `Guid`
- `RemoteDomainId`
- `Identity`
Domain-only identity, display-only identity, and derived-only identity are blocked unless repo rules explicitly prove stability and tests prove no ambiguity. Spec 436 defaults to blocking them.
### inboundConnector
Eligible only when stable connector identity and redaction safety are present.
Allowed stable identity candidates:
- `id`
- `sourceId`
- `Guid`
- `ConnectorId`
- `Identity`
Protected configuration includes IPs, hosts, smart hosts, certificate names, comments, routing metadata, TLS settings, and connector details. Protected configuration must not enter OperationRun context/logs/summaries/customer output.
## Required Final State
For each included type, when all gates pass:
| State | Required value |
|---|---|
| source_contract_verified | true |
| credential_readiness | ready |
| permission_readiness | ready |
| runtime_readiness | ready |
| runner_result | success |
| structured_output_valid | true |
| target_normalizer | target_specific |
| identity_state | stable |
| raw_payload_persisted_in_evidence_store | true |
| normalized_payload_persisted | true |
| payload_hash_persisted | true |
| operation_run_id_linked | true |
| coverage_level | content_backed |
| claim_state | internal_only_or_customer_blocked |
| comparable | false |
| renderable | false |
| certified | false |
| restore_ready | false |
| customer_claimable | false |
When any gate fails:
- no resource row unless repo pattern already created one before the hard-stop and the implementation report proves no evidence was appended
- no evidence row
- exact safe blocker state
- safe OperationRun summary/context only
- no content-backed promotion
- no compare/render/certification/restore/customer claim
PASS WITH CONDITIONS is allowed only if one or two target types become content-backed while another remains fail-closed with exact blocker reason. No fake evidence is allowed.
## Capture Flow
1. Start or receive `tenant_configuration.capture` OperationRun through a trusted flow.
2. Resolve target Exchange command contract.
3. Verify target is in Spec 436 scope.
4. Verify Spec 433 combined readiness.
5. Verify provider scope.
6. Verify runtime readiness.
7. Invoke through Spec 432 runner boundary or fake runner in tests.
8. Convert runner result to Spec 435 structured output envelope.
9. Validate output envelope.
10. Apply Spec 435 target-specific normalizer.
11. Build hash input through Spec 435 hash builder.
12. Extract identity candidates.
13. Run `CanonicalIdentityResolver` or the Spec 434 identity gate that delegates to it.
14. Hard-stop unsafe identity before evidence append.
15. Persist raw structured provider item/envelope in Coverage v2 evidence storage.
16. Persist normalized payload.
17. Persist deterministic hash.
18. Append evidence through Coverage v2 writer with content-only cap.
19. Update safe OperationRun summary/context only.
20. Do not create compare/render/certification/restore/customer state.
No step may bypass OperationRun, readiness, runner boundary, structured envelope, target normalizer, hash builder, identity hard-stop, or content-only guard.
## OperationRun Requirements
Use:
- `OperationRunType::TenantConfigurationCapture` (`tenant_configuration.capture`)
Do not use as evidence owner:
- `OperationRunType::TenantConfigurationExchangePowerShellInvocation` (`tenant_configuration.exchange_powershell_invocation`)
Do not add a new OperationRun type.
OperationRun may store safe context only:
- target resource types
- command contracts
- capture outcomes
- safe blocker reason
- summary counts using allowed `OperationSummaryKeys`
- credential readiness state
- permission readiness state
- runtime readiness state
- `provider_connection_id` if the existing repo pattern stores it in context
OperationRun must not store raw payload, normalized payload, raw stdout, raw stderr, serialized Exchange objects, transcripts, credential material, provider response bodies, mail/message/file content, secret-like metadata, normalizer previews, or hash previews.
## Evidence Persistence Requirements
Use existing Coverage v2 resource/evidence architecture:
- `tenant_configuration_resources`
- `tenant_configuration_resource_evidence`
- `TenantConfigurationResource`
- `TenantConfigurationResourceEvidence`
- `CoverageResourceUpserter`
- `CoverageEvidenceWriter`
Repo-real evidence fields or equivalents:
- `resource_id` as the repo equivalent of tenant configuration resource ID
- `workspace_id`
- `managed_environment_id`
- `provider_connection_id`
- `resource_type_id`
- `operation_run_id`
- `source_contract_key`
- `source_endpoint`
- `source_version`
- `source_schema_hash`
- `source_metadata`
- `raw_payload` jsonb
- `normalized_payload` jsonb
- `payload_hash`
- `permission_context`
- `evidence_state`
- `coverage_level`
- `capture_outcome`
- `captured_at`
Persisted `capture_outcome` values must use existing repo-allowed evidence outcome values. Adapter-only safe outcome strings may remain service/result context, but must not be persisted to evidence rows unless the spec is amended before implementation.
Repo-real resource fields or equivalents:
- `latest_evidence_id`
- `latest_evidence_state`
- `latest_identity_state`
- `latest_claim_state`
- `latest_payload_hash`
- `latest_captured_at`
- canonical/source identity fields and diagnostics
No `tenant_id`.
### Append-Only Rule
Allowed:
- insert new evidence row
- update `latest_evidence_id` pointer according to repo pattern
- derive latest evidence by `latest_evidence_id` or `captured_at`
Forbidden:
- overwrite prior evidence payload
- mutate prior payload hash
- delete previous evidence
- replace evidence in place
### Raw Payload Rule
Raw provider payload means structured provider item/envelope from the Spec 435 output path. It never means raw stdout, raw stderr, PowerShell transcript, terminal output, or serialized shell output.
Raw provider payload may exist only in Coverage v2 evidence storage.
Forbidden locations:
- OperationRun context
- logs
- summary counts
- UI
- reports
- Review Packs
- customer output
- exception messages
## Normalization Requirements
Spec 436 must use Spec 435 target-specific evidence normalizers:
- `ExchangeTransportRuleEvidenceNormalizer`
- `ExchangeRemoteDomainEvidenceNormalizer`
- `ExchangeInboundConnectorEvidenceNormalizer`
- `ExchangePowerShellEvidenceNormalizer`
- `ExchangePowerShellTargetEvidenceNormalizer`
Forbidden normalizers for Exchange evidence promotion:
- `GenericPayloadNormalizer`
- `ExchangeTeamsComparablePayloadNormalizer`
- generic Teams comparable normalizer
- compare/render normalizers
Every normalized payload must include or map to:
- resource type
- workload/source surface
- source contract metadata
- command contract metadata
- source identity
- canonical identity candidate
- payload shape version
- normalizer version
- captured-at context where repo pattern places it outside hash input
Volatile fields must be excluded or demoted. Collection items must be sorted deterministically by stable identity key. Provider ordering must not affect payload hash unless ordering is semantically meaningful.
## Hashing Requirements
Spec 436 must use `ExchangePowerShellHashInputBuilder`.
Required behavior:
- same material normalized payload produces same hash
- material change produces different hash
- volatile field change produces same hash
- provider order changes do not change hash when order is not meaningful
- target type is included in hash context
- normalizer version is included or handled according to repo pattern
Hash must not be based on raw stdout, raw stderr, OperationRun context, runtime timestamps, PowerShell session metadata, credential metadata, or permission metadata.
Hash is persisted only with evidence row unless repo policy already explicitly allows safe hash context.
## Identity Requirements
Spec 436 must use `CanonicalIdentityResolver` directly or via the existing Spec 434 identity gate, and tests must prove the resolver is used before evidence append.
Stable identity is required by default.
Blocked identity states:
- `identity_conflict`
- `missing_external_id`
- `unsupported_identity`
- `display_name_only`
- `duplicate_identity`
- `derived_only_unproven`
Derived identity is blocked by default unless Spec 417 rules explicitly allow it for non-certifying content-backed evidence and target-specific tests prove no ambiguity.
Tests must prove display-name-only, duplicate, conflicting, missing, and unsafe identities do not call the writer and do not create evidence rows.
## Empty Collection Policy
Spec 436 preserves the Spec 434/435 empty-collection policy:
- safe zero-item OperationRun summary allowed
- no resource row
- no evidence row
- no content-backed resource evidence
- durable collection-level empty proof deferred
Permission denied, source unavailable, malformed output, and partial output must never be treated as empty collection.
Forbidden:
- `fake_empty_success`
- fake resource row
- fake evidence row
- empty collection marked as content-backed resource
## Capture Outcomes
Spec 436 may expose safe adapter/result outcomes equivalent to:
- `capture_succeeded_content_backed`
- `capture_succeeded_zero_items_no_resource_evidence`
- `capture_blocked_contract_not_verified`
- `capture_blocked_missing_credential_readiness`
- `capture_blocked_missing_permission_readiness`
- `capture_blocked_runtime_not_ready`
- `capture_blocked_provider_scope`
- `capture_blocked_identity_unsafe`
- `capture_blocked_response_shape_unsafe`
- `capture_blocked_redaction_unsafe`
- `capture_blocked_normalizer_unready`
- `capture_blocked_hash_unready`
- `capture_failed_authentication`
- `capture_failed_authorization`
- `capture_failed_permission`
- `capture_failed_timeout`
- `capture_failed_throttled`
- `capture_failed_output_too_large`
- `capture_failed_output_encoding_unsafe`
- `capture_failed_shape_unsafe`
- `capture_failed_unexpected`
Persisted evidence `capture_outcome` must remain within existing repo enum/constraint values unless this spec is amended before runtime implementation. Existing adapter-level safe outcome strings may be returned in service results and OperationRun safe context only when tested and sanitized.
Implementation tests must prove both successful and blocked captures either persist only repo-allowed `capture_outcome` values or create no evidence row. Adapter-only outcome labels must not leak into persisted evidence when no matching repo value exists.
Forbidden outcomes:
- `fake_empty_success`
- `meta_fallback`
- `policy_record_missing`
- `foundation_not_policy_backed`
- `raw_gap_count`
- `primary_gap_count`
- `customer_ready`
- `certified`
- `renderable`
- `comparable`
## Redaction Requirements
Never persist outside evidence storage:
- raw provider payload
- raw stdout
- raw stderr
- serialized Exchange object
- PowerShell transcript
- credential material
- certificate material
- client secret
- token
- authorization header
- cookie
- mail body
- message content
- file content
OperationRun context must contain only safe states/counts. Logs must not contain raw provider payload. Reports, Review Packs, PDF, UI, and customer output remain unchanged.
## Data / Migration Impact
Default: no migration.
Spec 436 must use existing Coverage v2 evidence/resource tables.
Forbidden:
- Exchange-specific evidence table
- `tenant_id` column
- raw payload table outside existing evidence architecture
- customer output table
- UI state table
- legacy snapshot table
If migration is required, stop and amend the spec before implementation.
## Likely Affected Files
Spec artifacts:
- `specs/436-exchange-content-backed-evidence-promotion-slice-1/spec.md`
- `specs/436-exchange-content-backed-evidence-promotion-slice-1/plan.md`
- `specs/436-exchange-content-backed-evidence-promotion-slice-1/tasks.md`
- `specs/436-exchange-content-backed-evidence-promotion-slice-1/checklists/requirements.md`
- `specs/436-exchange-content-backed-evidence-promotion-slice-1/implementation-report.md`
Likely runtime files:
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCaptureEligibilityGate.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellTargetEvidenceNormalizer.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellHashInputBuilder.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellStructuredOutputEnvelope.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellProductionRunner.php`
- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellOutputGuard.php`
- `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php`
- `apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php`
- `apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResolver.php`
- `apps/platform/app/Support/OpsUx/OperationSummaryKeys.php` only if new summary keys are unavoidable and the spec is amended
Likely new test files or repo-canonical equivalents:
- `apps/platform/tests/Unit/Support/TenantConfiguration/Spec436ExchangeEvidencePromotionTest.php`
- `apps/platform/tests/Feature/TenantConfiguration/Spec436ExchangeContentBackedEvidenceFeatureTest.php`
Files that must not change without spec amendment:
- `apps/platform/routes/**`
- `apps/platform/app/Filament/**`
- `apps/platform/app/Livewire/**`
- `apps/platform/resources/views/**`
- `apps/platform/app/Jobs/**`
- `apps/platform/app/Listeners/**`
- `apps/platform/app/Console/**`
- `apps/platform/database/migrations/**`
- customer report / review-pack code
- `apps/platform/app/Support/OperationRunType.php` for adding a new type
- `apps/platform/app/Support/OperationCatalog.php` for adding a new operation type
## User Scenarios And Testing
### User Story 1 - Content-Backed transportRule Evidence (Priority: P1)
As a platform reviewer, I need successful gated `Get-TransportRule` output to append content-backed evidence, so the first Exchange target has source-backed internal proof without product overclaim.
**Independent Test**: Fake a successful structured `transportRule` collection through the Exchange adapter and assert one resource row, one evidence row, raw payload in evidence storage only, normalized target-specific payload, deterministic hash, linked OperationRun, content-backed coverage only, and sanitized OperationRun context.
### User Story 2 - Content-Backed remoteDomain Evidence With Stable Identity Only (Priority: P1)
As a platform reviewer, I need `remoteDomain` evidence to be created only when stable source identity is present, so domain/display-name-only payloads cannot become ambiguous evidence.
**Independent Test**: Fake stable `remoteDomain` output and assert content-backed evidence; fake domain-only, display-only, missing, duplicate, or conflicting identity and assert no writer call and no evidence row.
### User Story 3 - Content-Backed inboundConnector Evidence With Redaction Safety (Priority: P1)
As a platform reviewer, I need `inboundConnector` evidence to preserve raw source configuration only in evidence storage and keep protected routing/config data out of OperationRun/logs/summaries.
**Independent Test**: Fake stable `inboundConnector` output with protected config and assert content-backed evidence plus redaction-safe context/log behavior; fake missing/duplicate identity or redaction-unsafe output and assert no evidence.
### User Story 4 - Fail-Closed Blocking And No-Promotion Safety (Priority: P1)
As a release reviewer, I need every readiness, output, identity, empty, and failure state to fail closed without fake evidence or product promotion.
**Independent Test**: Run focused unit/feature tests for missing credential readiness, client secret, missing/stale/wrong-scope permission, runtime readiness, provider scope, auth/permission/source/output failures, malformed/scalar/warning/binary/oversized/non-zero/timeout output, unsafe identity, empty collection, no UI/triggers, no `tenant_id`, and no compare/render/certification/restore/customer state.
## Non-Functional Requirements
- **NFR-436-001**: Evidence promotion must be deterministic. Equivalent normalized payloads must produce equivalent hashes independent of provider item order when ordering is not meaningful.
- **NFR-436-002**: Evidence promotion must be fail-closed. Any readiness, output, identity, redaction, hash, scope, or runtime uncertainty must create no evidence row.
- **NFR-436-003**: Sensitive Exchange/provider data must remain minimized. Raw structured provider payload is allowed only in Coverage v2 evidence storage; OperationRun context, logs, summaries, reports, UI, and customer output must remain sanitized.
- **NFR-436-004**: The implementation must not add any rendered UI, trigger, migration, queue/scheduler surface, customer output, or deployment/runtime dependency.
- **NFR-436-005**: Test additions must stay in the narrow Unit/Feature lane unless a spec amendment justifies broader PostgreSQL/browser/heavy-governance coverage.
## Functional Requirements
- **FR-436-001**: Runtime MUST use `tenant_configuration.capture` as the evidence-owning OperationRun and MUST NOT add a new OperationRun type.
- **FR-436-002**: Runtime MUST keep `tenant_configuration.exchange_powershell_invocation` as runner/invocation truth only.
- **FR-436-003**: Runtime MUST scope Exchange promotion to `transportRule`, `remoteDomain`, and `inboundConnector`.
- **FR-436-004**: Runtime MUST use Spec 435 structured output envelope before normalization or evidence append.
- **FR-436-005**: Runtime MUST use Spec 435 target-specific normalizers and hash builder for Exchange evidence promotion.
- **FR-436-006**: Runtime MUST NOT use `GenericPayloadNormalizer` or `ExchangeTeamsComparablePayloadNormalizer` for Exchange evidence promotion.
- **FR-436-007**: Runtime MUST require Spec 433 combined readiness and provider capability support before runner/capture success can append evidence.
- **FR-436-008**: Runtime MUST require Spec 432 runner boundary and accepted structured collection output.
- **FR-436-009**: Runtime MUST require Spec 430 verified command contracts.
- **FR-436-010**: Runtime MUST use `CanonicalIdentityResolver` or the Spec 434 identity gate before resource upsert/evidence append.
- **FR-436-011**: Unsafe identity MUST create no evidence row and MUST not call the evidence writer.
- **FR-436-012**: Empty collection MUST create a zero-item safe OperationRun summary and no resource/evidence row.
- **FR-436-013**: Raw payload MUST mean structured provider item/envelope and MUST be persisted only in Coverage v2 evidence storage.
- **FR-436-014**: Raw stdout/stderr/transcripts MUST NOT be evidence and MUST NOT enter OperationRun context, logs, summaries, UI, reports, Review Packs, or customer output.
- **FR-436-015**: Normalized payload and payload hash MUST be deterministic and persisted on each evidence row.
- **FR-436-016**: Evidence rows MUST be append-only and previous payload/hash values MUST NOT be mutated or deleted.
- **FR-436-017**: Latest evidence pointer MUST update according to existing repo pattern.
- **FR-436-018**: Evidence coverage level MUST remain capped at `content_backed`.
- **FR-436-019**: Resource claim state MUST remain internal-only/customer-blocked according to existing repo pattern.
- **FR-436-020**: Runtime MUST not create comparable, renderable, certified, restore-ready, customer-ready, report, Review Pack, PDF, UI, route, job, schedule, listener, migration, `tenant_id`, Graph fallback, fake endpoint, legacy shim, fallback reader, or Exchange mini-platform state.
- **FR-436-021**: OperationRun context and summary counts MUST use safe allowed keys only and MUST exclude raw payloads, normalized payloads, normalizer previews, hash previews, credential material, provider response bodies, stdout/stderr, and protected Exchange config.
- **FR-436-022**: Implementation report MUST include prerequisite proof, target outcomes, evidence persistence proof, append-only/latest-pointer proof, raw payload location proof, structured envelope proof, normalizer/hash/identity/readiness proof, failure blocking proof, redaction proof, no-generic/no-comparable-normalizer proof, no Graph gateway proof, no promotion/customer/restore proof, no UI/trigger proof, no `tenant_id` proof, tests run, and deferred work.
## Acceptance Criteria
### Target Scope
- Only `transportRule`, `remoteDomain`, and `inboundConnector` are in scope.
- No outboundConnector, acceptedDomain, organizationConfig, Teams, mutation commands, restore commands, or customer-facing surfaces.
### Evidence Capture
- Successful gated capture creates append-only content-backed evidence.
- Raw payload is stored only in evidence storage.
- Normalized payload and deterministic hash are stored.
- Persisted `capture_outcome` uses an existing repo-allowed evidence outcome value.
- OperationRun, workspace, managed environment, and provider connection scope are linked.
- Coverage maximum is `content_backed`.
- Claim state remains internal-only/customer-blocked.
### Target Normalization
- `transportRule`, `remoteDomain`, and `inboundConnector` use Spec 435 target normalizers.
- `GenericPayloadNormalizer` is not used for Exchange evidence promotion.
- `ExchangeTeamsComparablePayloadNormalizer` is not used for Exchange evidence promotion.
- Volatile fields are excluded/demoted.
- Collection order is deterministic.
- Sensitive fields are handled according to policy.
### Identity
- `CanonicalIdentityResolver` is used.
- Stable identity is required by default.
- Display-name-only, duplicate, conflicting, missing, remoteDomain domain-only, and inboundConnector missing connector identity block.
- Unsafe identity creates no evidence.
### Blocking
- Missing credential readiness, client secret, missing/stale/wrong-scope permission, unsupported provider capability, runtime readiness failure, provider scope failure, auth/permission/source/output failures, malformed output, unsafe identity, and empty collection create no evidence row.
- Permission denied, source unavailable, malformed output, and partial output are never treated as empty collection.
### OperationRun Safety
- `tenant_configuration.capture` is evidence owner.
- Exchange invocation operation remains runner truth only.
- No new OperationRun type.
- OperationRun context has no raw payload, normalized payload, normalizer/hash preview, raw stdout/stderr, credential material, or protected Exchange config.
- Summary counts use allowed keys or the spec is amended before adding keys.
### Redaction
- Logs have no raw payload.
- Summary counts have no raw payload.
- Credential material is absent.
- Raw provider payload exists only in evidence store.
- No customer/report/review-pack output.
### No Product Promotion
- No comparable, renderable, certified, restore-ready, customer-ready, report, Review Pack, PDF, or customer claim state appears.
### Product Surface / Trigger
- No route, Filament page, Livewire component, navigation, asset, global search, provider registration, destructive UI action, job trigger, schedule trigger, listener trigger, or browser path.
### Architecture
- Uses Coverage v2 evidence architecture.
- Uses Spec 434 adapter/guards.
- Uses Spec 435 normalizers/hash builder.
- Uses Spec 433 readiness evaluator.
- Uses Spec 432 runner boundary.
- No Graph gateway fallback, fake Graph endpoint, Exchange-specific evidence table, `tenant_id`, Exchange mini-platform, legacy shim, fallback reader, or migration.
### Validation
- Focused unit tests pass.
- Focused feature tests pass.
- Required regressions pass.
- Pint passes.
- `git diff --check` passes.
- `git status --short` is documented.
## Implementation Report Requirements
Implementation report must include:
- Candidate gate result.
- Branch and HEAD.
- Dirty state before/after.
- Files changed.
- Spec 435 prerequisite proof.
- Target type outcomes.
- OperationRun proof.
- Evidence persistence proof.
- Append-only proof.
- Latest evidence pointer proof.
- Raw payload location proof.
- Structured envelope proof.
- Normalizer integration proof.
- Hash proof.
- Identity proof.
- Credential readiness proof.
- Permission readiness proof.
- Runtime readiness proof.
- Failure blocking proof.
- Redaction proof.
- No `GenericPayloadNormalizer` proof.
- No `ExchangeTeamsComparablePayloadNormalizer` proof.
- No Graph gateway/fake endpoint proof.
- No compare/render/certification proof.
- No restore/customer claim proof.
- No UI/route/job/schedule/listener proof.
- No `tenant_id` proof.
- No mini-platform proof.
- Tests run.
- Deferred work.
Required target matrix:
| Type | Command | Capture Outcome | Evidence? | Blocker |
|---|---|---|---|---|
| transportRule | Get-TransportRule | captured | yes, content-backed only | none |
| remoteDomain | Get-RemoteDomain | captured | yes, content-backed only | domain-only, display-only, duplicate, and conflicting identities block |
| inboundConnector | Get-InboundConnector | captured | yes, content-backed only | missing and unsafe identities block |
Required evidence matrix:
| Type | Raw Payload | Normalized Payload | Hash | Identity | OperationRun | Content-backed? |
|---|---|---|---|---|---|---|
| transportRule | evidence row only | target normalizer output plus source metadata | Spec435 hash builder over persisted normalized payload | stable source identity required | tenant_configuration.capture linked | yes |
| remoteDomain | evidence row only | target normalizer output plus source metadata | Spec435 hash builder over persisted normalized payload | stable source identity required | tenant_configuration.capture linked | yes |
| inboundConnector | evidence row only | target normalizer output plus source metadata | Spec435 hash builder over persisted normalized payload | stable source identity required | tenant_configuration.capture linked | yes |
Required no-promotion matrix:
| Area | State |
|---|---|
| Compare | No |
| Render | No |
| Certification | No |
| Restore | No |
| Customer claim | No |
| UI | No |
| Jobs/Schedules/Listeners | No |
## Risks
- **R-436-001**: Current adapter uses generic/comparable normalizer dependencies. Mitigation: make replacing/bypassing those paths a hard implementation task and test assertion.
- **R-436-002**: Existing evidence writer can promote renderable coverage if summary builders match. Mitigation: keep Spec 434 content-only cap and assert resulting evidence/resource states.
- **R-436-003**: OperationRun context could accidentally store protected Exchange config. Mitigation: add redaction tests and context/log guard tests for protected config sentinels.
- **R-436-004**: Empty collection could be mistaken for durable proof. Mitigation: keep zero-item summary only and assert no resource/evidence rows.
- **R-436-005**: New outcome strings could conflict with DB constraints. Mitigation: persisted `capture_outcome` stays repo-canonical unless spec is amended.
## Assumptions
- Spec 435 is merge-ready and present at HEAD before implementation.
- Existing Coverage v2 tables are sufficient.
- Existing OperationRun summary keys are sufficient, or implementation stops to amend the spec before adding keys.
- Existing fake runner/test fixtures are sufficient to prove promotion without live Exchange calls.
- `tenant_configuration.capture` remains the canonical evidence-owning OperationRun type.
## Open Questions
None blocking preparation. Runtime implementation must stop and amend if it needs a migration, new summary key, new persisted outcome, new OperationRun type, UI/trigger surface, target expansion, or durable empty-collection proof.
## Follow-Up Spec Candidates
- Spec 437 - Exchange Comparable / Renderable Promotion Slice 1.
- Teams PowerShell Adapter Contract Slice 1.
- Exchange/Teams Certified Core Compare Pack.
- M365 Customer Output & Claim Guard.
## Candidate Selection Gate
PASS. The selected candidate is directly provided by the user, not covered by an existing active or completed Spec 436 package, follows merge-ready Spec 435, aligns with the corrected Exchange roadmap sequence, is narrow enough for a bounded implementation loop, and defers comparable/renderable/customer/product surfaces.
## Spec Readiness Gate
PASS for preparation. `spec.md`, `plan.md`, `tasks.md`, `checklists/requirements.md`, and `implementation-report.md` define a bounded, testable, no-UI, no-migration, no-application-implementation package. Runtime implementation must re-check repo state, Spec 435 merge-readiness, and all hard gates before changing application code.
## Final Candidate Gate
PASS requires:
- Spec 435 is merge-ready.
- Only `transportRule`, `remoteDomain`, and `inboundConnector` are in scope.
- Adapter uses Spec 435 structured envelope, target normalizers, and hash builder.
- `GenericPayloadNormalizer` is not used for Exchange evidence.
- `ExchangeTeamsComparablePayloadNormalizer` is not used for Exchange evidence.
- Evidence owner is `tenant_configuration.capture`.
- Exchange invocation operation remains runner truth only.
- Successful eligible capture creates append-only content-backed evidence.
- Raw payload is persisted only in evidence storage.
- Raw stdout/stderr is not evidence.
- Normalized payload and deterministic hash are persisted.
- `CanonicalIdentityResolver` is used.
- Unsafe identity creates no evidence.
- Blocked states create no evidence.
- Empty collection creates no fake evidence.
- Coverage remains `content_backed` only.
- No compare/render/certification/restore/customer state appears.
- No UI/routes/jobs/schedules/listeners are added.
- No migration is added.
- No `tenant_id` ownership truth is introduced.
- No Exchange mini-platform appears.
- Focused tests and regressions pass.
PASS WITH CONDITIONS is allowed only for bounded follow-ups that do not weaken evidence integrity, identity safety, normalization determinism, redaction, readiness gating, no-promotion posture, no-product-surface posture, ownership, or no-mini-platform posture.
FAIL if credential/permission gates can be bypassed, fake evidence is created, permission denied is treated as empty data, malformed output is treated as success, unsafe identity creates evidence, raw stdout/stderr is treated as evidence, raw payload leaks outside evidence storage, OperationRun stores raw provider output or normalizer/hash preview, coverage promotes to comparable/renderable/certified, restore/customer claims appear, 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 content-backed evidence safety.

View File

@ -0,0 +1,238 @@
# Tasks: Exchange Content-Backed Evidence Promotion Slice 1
**Input**: Design documents from `specs/436-exchange-content-backed-evidence-promotion-slice-1/`
**Prerequisites**: [spec.md](./spec.md), [plan.md](./plan.md), [checklists/requirements.md](./checklists/requirements.md)
**Tests**: Required. Runtime behavior changes must be covered with Pest 4 Unit and Feature tests. Browser proof is `N/A - no rendered UI surface changed`.
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
- [x] Browser proof is explicitly `N/A - no rendered UI surface changed`.
- [x] Human Product Sanity and Product Surface implementation-report close-out are planned as N/A.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the implementation report.
## Phase 0 - Preflight
- [x] T001 Capture branch, HEAD, and `git status --short` in `implementation-report.md`.
- [x] T002 Confirm Spec 435 PASS/merge-ready and record prerequisite proof from `specs/435-exchange-structured-output-target-normalizer-readiness/implementation-report.md`.
- [x] T003 Confirm Specs 430-434 are completed read-only context and are not edited.
- [x] T004 Confirm target types are exactly `transportRule`, `remoteDomain`, and `inboundConnector`.
- [x] T005 Confirm evidence model uses existing `tenant_configuration_resources` and `tenant_configuration_resource_evidence`.
- [x] T006 Confirm OperationRun owner is `tenant_configuration.capture` and invocation operation remains runner truth only.
- [x] T007 Confirm no UI/routes/jobs/schedules/listeners/migration/tenant_id/target-expansion scope.
## Phase 1 - Tests First: Adapter Wiring
- [x] T008 Add or update focused unit tests proving the Exchange adapter creates a Spec 435 `ExchangePowerShellStructuredOutputEnvelope` before evidence append.
- [x] T009 Add or update unit tests proving `ExchangePowerShellEvidenceNormalizer` dispatches target-specific normalizers for all three targets.
- [x] T010 Add static/behavioral tests proving `GenericPayloadNormalizer` is not used for Exchange evidence promotion.
- [x] T011 Add static/behavioral tests proving `ExchangeTeamsComparablePayloadNormalizer` is not used for Exchange evidence promotion.
- [x] T012 Add tests proving the generic Graph capture path remains intact and is not routed through Exchange-specific promotion.
## Phase 2 - Adapter Normalizer Wiring
- [x] T013 Update `ExchangePowerShellEvidenceCaptureAdapter` to use Spec 435 structured envelope for accepted runner output.
- [x] T014 Update the adapter to use `ExchangeTransportRuleEvidenceNormalizer` through `ExchangePowerShellEvidenceNormalizer` for `transportRule`.
- [x] T015 Update the adapter to use `ExchangeRemoteDomainEvidenceNormalizer` through `ExchangePowerShellEvidenceNormalizer` for `remoteDomain`.
- [x] T016 Update the adapter to use `ExchangeInboundConnectorEvidenceNormalizer` through `ExchangePowerShellEvidenceNormalizer` for `inboundConnector`.
- [x] T017 Update the adapter to use `ExchangePowerShellHashInputBuilder` for persisted payload hashes.
- [x] T018 Remove or bypass the adapter's Exchange write-time dependency on `GenericPayloadNormalizer`.
- [x] T019 Remove or bypass the adapter's Exchange write-time dependency on `ExchangeTeamsComparablePayloadNormalizer`.
## Phase 3 - OperationRun Ownership And Context
- [x] T020 Ensure evidence append requires `OperationRunType::TenantConfigurationCapture`.
- [x] T021 Add tests proving `OperationRunType::TenantConfigurationExchangePowerShellInvocation` cannot own evidence.
- [x] T022 Confirm no new OperationRun type is added.
- [x] T023 Keep OperationRun context safe and exclude raw payload, normalized payload, stdout, stderr, transcript, normalizer preview, hash preview, credential material, and provider response body.
- [x] T024 Use existing allowed `OperationSummaryKeys` only, or stop and amend the spec before adding keys.
## Phase 4 - transportRule Evidence Capture
- [x] T025 Add focused tests where successful fake `Get-TransportRule` capture creates one resource row and one evidence row.
- [x] T026 Assert `raw_payload` is persisted only in `tenant_configuration_resource_evidence`.
- [x] T027 Assert target-specific normalized payload is persisted.
- [x] T028 Assert deterministic payload hash is persisted.
- [x] T029 Assert `operation_run_id`, `workspace_id`, `managed_environment_id`, and `provider_connection_id` are linked.
- [x] T030 Assert coverage remains `content_backed` only, claim state remains internal-only/customer-blocked, and persisted `capture_outcome` uses an existing repo-allowed value.
## Phase 5 - remoteDomain Evidence Capture
- [x] T031 Add focused tests where successful fake `Get-RemoteDomain` capture creates content-backed evidence with stable source identity.
- [x] T032 Assert domain-only identity blocks with no writer call and no evidence row.
- [x] T033 Assert display-only identity blocks with no writer call and no evidence row.
- [x] T034 Assert duplicate identity blocks with no evidence row.
- [x] T035 Assert conflicting identity blocks with no evidence row.
- [x] T036 Assert raw payload, normalized payload, deterministic hash, OperationRun link, repo-allowed persisted `capture_outcome`, and content-backed-only state for successful stable identity.
## Phase 6 - inboundConnector Evidence Capture
- [x] T037 Add focused tests where successful fake `Get-InboundConnector` capture creates content-backed evidence with stable connector identity.
- [x] T038 Assert missing connector identity blocks with no evidence row.
- [x] T039 Assert duplicate identity blocks with no evidence row.
- [x] T040 Assert protected config sentinels do not enter OperationRun context/logs/summaries.
- [x] T041 Assert raw payload, normalized payload, deterministic hash, OperationRun link, repo-allowed persisted `capture_outcome`, and content-backed-only state for successful stable identity.
## Phase 7 - Append-Only Evidence
- [x] T042 Add feature tests proving existing evidence is not overwritten.
- [x] T043 Add feature tests proving a second successful capture inserts a new evidence row.
- [x] T044 Assert `latest_evidence_id`, `latest_payload_hash`, and `latest_captured_at` update according to repo pattern.
- [x] T045 Assert prior payload hash and payload columns are not mutated.
## Phase 8 - Hash Determinism
- [x] T046 Add unit tests proving the same material normalized payload produces the same hash.
- [x] T047 Add unit tests proving material changes change the hash.
- [x] T048 Add unit tests proving volatile changes do not change the hash.
- [x] T049 Add unit tests proving provider ordering does not change hash when order is not meaningful.
- [x] T050 Assert target type, source surface, command contract, payload shape version, and normalizer version participate in hash input according to Spec 435.
## Phase 9 - Readiness Failure Blocking
- [x] T051 Add tests proving missing credential readiness blocks with no evidence.
- [x] T052 Add tests proving client-secret readiness blocks with no evidence.
- [x] T053 Add tests proving missing permission readiness blocks with no evidence.
- [x] T054 Add tests proving stale permission evidence blocks with no evidence.
- [x] T055 Add tests proving wrong-scope permission evidence blocks with no evidence.
- [x] T056 Add tests proving runtime readiness failure blocks with no evidence.
- [x] T057 Add tests proving provider scope failure and unsupported provider capability both block with no evidence.
## Phase 10 - Output Failure Blocking
- [x] T058 Add tests proving authentication failure blocks with no evidence.
- [x] T059 Add tests proving authorization/permission failure blocks with no evidence.
- [x] T060 Add tests proving source unavailable blocks with no evidence.
- [x] T061 Add tests proving malformed output blocks with no evidence.
- [x] T062 Add tests proving scalar output blocks with no evidence.
- [x] T063 Add tests proving warning-prefixed output blocks with no evidence.
- [x] T064 Add tests proving binary/non-UTF8 output blocks with no evidence.
- [x] T065 Add tests proving oversized output blocks with no evidence.
- [x] T066 Add tests proving non-zero exit blocks with no evidence.
- [x] T067 Add tests proving timeout blocks with no evidence and failure outcomes do not persist adapter-only outcome labels outside existing repo-allowed evidence values.
## Phase 11 - Identity Blocking
- [x] T068 Add tests proving missing identity blocks.
- [x] T069 Add tests proving duplicate identity blocks.
- [x] T070 Add tests proving conflicting identity blocks.
- [x] T071 Add tests proving display-name-only blocks.
- [x] T072 Add tests proving derived-only unproven identity blocks.
- [x] T073 Assert the writer is not called on unsafe identity.
## Phase 12 - Empty Collection Policy
- [x] T074 Add tests proving empty collection produces zero-item summary.
- [x] T075 Assert empty collection creates no resource row.
- [x] T076 Assert empty collection creates no evidence row.
- [x] T077 Assert permission denied and source unavailable are not treated as empty.
- [x] T078 Assert malformed output and partial output are not treated as empty or success.
## Phase 13 - Redaction
- [x] T079 Add tests proving raw payload exists only in evidence storage.
- [x] T080 Add tests proving OperationRun context is sanitized.
- [x] T081 Add tests proving no raw stdout/stderr in context.
- [x] T082 Add tests proving no normalizer/hash preview in context.
- [x] T083 Add tests proving no credential material in context/logs/results.
- [x] T084 Add tests proving no provider payload in summaries.
- [x] T085 Add guard/static tests proving no customer output path is touched.
## Phase 14 - Content-Only Guard And No Promotion
- [x] T086 Assert coverage max level is `content_backed`.
- [x] T087 Assert no comparable state.
- [x] T088 Assert no renderable state.
- [x] T089 Assert no certified state.
- [x] T090 Assert no restore-ready state.
- [x] T091 Assert no customer-ready/customer-claimable state.
## Phase 15 - No Product Surface / Trigger
- [x] T092 Add or update guard tests proving no route changed or added.
- [x] T093 Add or update guard tests proving no Filament page/resource/widget changed or added.
- [x] T094 Add or update guard tests proving no Livewire component changed or added.
- [x] T095 Add or update guard tests proving no navigation, asset, global search, or provider registration change.
- [x] T096 Add or update guard tests proving no destructive UI action exists.
- [x] T097 Add or update guard tests proving no job, schedule, listener, console trigger, report, Review Pack, or PDF output was added.
- [x] T098 Record browser proof as `N/A - no rendered UI surface changed`.
## Phase 16 - No Tenant / Mini-Platform
- [x] T099 Assert no `tenant_id` ownership column/path is introduced.
- [x] T100 Assert no Exchange-specific evidence table exists.
- [x] T101 Assert no Exchange dashboard, legacy shim, fallback reader, or separate Exchange mini-platform exists.
- [x] T102 Assert no migration file was added.
## Phase 17 - Regression Tests
- [x] T103 Run Spec 435 regression tests.
- [x] T104 Run Spec 434 regression tests.
- [x] T105 Run Spec 433 regression tests.
- [x] T106 Run Spec 432 regression tests.
- [x] T107 Run Spec 431 regression tests.
- [x] T108 Run Spec 430 regression tests.
- [x] T109 Run Spec 415 regression tests.
- [x] T110 Run Spec 417 regression tests.
- [x] T111 Run Spec 419 regression tests if available.
- [x] T112 Run Spec 420 regression tests.
- [x] T113 Run Spec 426 regression tests.
- [x] T114 Run Spec 427 regression tests.
- [x] T115 Run `ProviderCapabilityRegistryTest` or repo equivalent.
## Phase 18 - Validation
- [x] T116 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` or document fallback if Sail fails.
- [x] T117 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec436 --compact` or document fallback if Sail fails.
- [x] T118 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec435 --compact` or document fallback if Sail fails.
- [x] T119 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec434 --compact` or document fallback if Sail fails.
- [x] T120 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec433 --compact` or document fallback if Sail fails.
- [x] T121 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec432|Spec431|Spec430' --compact` or document fallback if Sail fails.
- [x] T122 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec415|Spec417|Spec419|Spec420|Spec426|Spec427' --compact` or document fallback if Sail fails.
- [x] T123 Run `cd apps/platform && ./vendor/bin/sail artisan test --filter='ProviderCapabilityRegistryTest|ProviderCapabilityEvaluationTest' --compact` or document fallback if Sail fails.
- [x] T124 Run `git diff --check`.
- [x] T125 Run `git status --short`.
## Phase 19 - Implementation Report
- [x] T126 Record candidate gate result.
- [x] T127 Record branch, HEAD, and dirty state before/after.
- [x] T128 Record files changed.
- [x] T129 Record Spec 435 prerequisite proof.
- [x] T130 Complete target type outcomes matrix.
- [x] T131 Record OperationRun proof.
- [x] T132 Record evidence persistence proof, including persisted `capture_outcome` value proof.
- [x] T133 Record append-only and latest pointer proof.
- [x] T134 Record raw payload location proof.
- [x] T135 Record structured envelope proof.
- [x] T136 Record normalizer integration proof.
- [x] T137 Record hash proof.
- [x] T138 Record identity proof.
- [x] T139 Record credential/permission/runtime readiness proof.
- [x] T140 Record failure blocking proof.
- [x] T141 Record redaction proof.
- [x] T142 Record no `GenericPayloadNormalizer` proof.
- [x] T143 Record no `ExchangeTeamsComparablePayloadNormalizer` proof.
- [x] T144 Record no Graph gateway/fake endpoint proof.
- [x] T145 Record no promotion/restore/customer proof.
- [x] T146 Record no UI/trigger proof.
- [x] T147 Record no `tenant_id` and no mini-platform proof.
- [x] T148 Record tests run and deferred work.
## Dependencies And Ordering
- T001-T007 must complete before runtime edits.
- T008-T012 should be written before T013-T019.
- T020-T024 must pass before evidence persistence tasks are considered complete.
- Target-specific phases can be worked independently after adapter wiring, but append-only/no-promotion validations must run after all successful-target paths exist.
- Phase 17-19 are final validation and report tasks.
## Parallelization Notes
- Unit tests for hash determinism, identity blockers, and target normalizers can run in parallel if they touch separate files.
- Feature tests for each target can run in parallel after shared fixtures are in place.
- No UI/browser work is parallelizable because it is out of scope.