diff --git a/apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php b/apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php index e791d06a..42e2c38b 100644 --- a/apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php +++ b/apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php @@ -39,6 +39,7 @@ public function append( array $normalizedPayload, string $payloadHash, array $permissionContext = [], + ?CoverageLevel $maximumCoverageLevel = null, ): TenantConfigurationResourceEvidence { if (! $decision->capturable()) { throw new InvalidArgumentException('Cannot append captured evidence for a non-capturable source contract decision.'); @@ -57,10 +58,14 @@ public function append( $normalizedPayload, $payloadHash, $permissionContext, + $maximumCoverageLevel, ): TenantConfigurationResourceEvidence { $capturedAt = now(); - $coverageLevel = $this->coverageLevelFor($resourceType, $normalizedPayload); + $coverageLevel = $this->cappedCoverageLevel( + $this->coverageLevelFor($resourceType, $normalizedPayload), + $maximumCoverageLevel, + ); $evidence = TenantConfigurationResourceEvidence::query()->create([ 'resource_id' => (int) $resource->getKey(), @@ -99,6 +104,29 @@ public function append( return $evidence; } + private function cappedCoverageLevel(CoverageLevel $coverageLevel, ?CoverageLevel $maximumCoverageLevel): CoverageLevel + { + if (! $maximumCoverageLevel instanceof CoverageLevel) { + return $coverageLevel; + } + + return $this->coverageLevelRank($coverageLevel) <= $this->coverageLevelRank($maximumCoverageLevel) + ? $coverageLevel + : $maximumCoverageLevel; + } + + private function coverageLevelRank(CoverageLevel $coverageLevel): int + { + return match ($coverageLevel) { + CoverageLevel::Detected => 10, + CoverageLevel::ContentBacked => 20, + CoverageLevel::Comparable => 30, + CoverageLevel::Renderable => 40, + CoverageLevel::Restorable => 50, + CoverageLevel::Certified => 60, + }; + } + /** * @param array $normalizedPayload */ diff --git a/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCaptureEligibilityGate.php b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCaptureEligibilityGate.php new file mode 100644 index 00000000..aa3ea6cd --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCaptureEligibilityGate.php @@ -0,0 +1,156 @@ + + */ + private const ACCEPTED_OUTPUT_STATES = [ + 'structured_collection', + 'empty_collection', + ]; + + public function __construct( + private readonly ResourceTypeRegistry $resourceTypes, + private readonly CoverageSourceContractResolver $contractResolver, + private readonly ExchangePowerShellCommandContracts $commandContracts, + private readonly ExchangePowerShellInvocationReadinessEvaluator $readiness, + private readonly ProviderCapabilityEvaluator $providerCapabilities, + ) {} + + /** + * @return array{ + * allowed: bool, + * resource_type?: TenantConfigurationResourceType, + * decision?: CoverageSourceContractDecision, + * contract?: array, + * failure_code?: string, + * reason_code?: string, + * outcome?: CaptureOutcome + * } + */ + public function evaluate( + ManagedEnvironment $tenant, + ProviderConnection $providerConnection, + OperationRun $operationRun, + string $canonicalType, + ExchangePowerShellInvocationResult $runnerResult, + ): array { + $canonicalType = trim($canonicalType); + + if (! in_array($canonicalType, $this->commandContracts->includedCanonicalTypes(), true)) { + return $this->blocked('unsupported_exchange_capture_type', CaptureOutcome::BlockedUnsupported); + } + + if (! $this->sameScope($tenant, $providerConnection, $operationRun)) { + return $this->blocked('scope_mismatch', CaptureOutcome::BlockedUnsupported); + } + + if ((string) $operationRun->type !== OperationRunType::TenantConfigurationCapture->value) { + return $this->blocked('operation_type_mismatch', CaptureOutcome::BlockedUnsupported); + } + + $resourceType = $this->resourceTypes->findActive($canonicalType); + + if (! $resourceType instanceof TenantConfigurationResourceType) { + return $this->blocked('resource_type_missing', CaptureOutcome::BlockedMissingContract); + } + + $decision = $this->contractResolver->resolve($resourceType); + $contract = $this->commandContracts->contractForCanonicalType($canonicalType); + + if (! $this->verifiedPendingCaptureContract($decision, $contract)) { + return $this->blocked('source_contract_not_verified', CaptureOutcome::BlockedMissingContract); + } + + $readiness = $this->readiness->evaluate($tenant, $providerConnection); + + if (! $readiness->allowed) { + return $this->blocked($readiness->failureCode ?? 'exchange_readiness_blocked', CaptureOutcome::BlockedPermission); + } + + $capability = $this->providerCapabilities->evaluate($tenant, $providerConnection, 'exchange_powershell_invoke'); + + if ($capability->status !== ProviderCapabilityStatus::Supported) { + return $this->blocked($capability->reasonCode ?? 'exchange_capability_not_supported', CaptureOutcome::BlockedPermission); + } + + if (! $this->acceptedRunnerResult($runnerResult)) { + return $this->blocked($runnerResult->failureCode ?? 'runner_result_not_accepted', CaptureOutcome::Failed); + } + + return [ + 'allowed' => true, + 'resource_type' => $resourceType, + 'decision' => $decision, + 'contract' => $contract, + ]; + } + + /** + * @return array{allowed: false, failure_code: string, reason_code: string, outcome: CaptureOutcome} + */ + private function blocked(string $reasonCode, CaptureOutcome $outcome): array + { + return [ + 'allowed' => false, + 'failure_code' => self::FAILURE_CODE, + 'reason_code' => $reasonCode, + 'outcome' => $outcome, + ]; + } + + private function sameScope( + ManagedEnvironment $tenant, + ProviderConnection $providerConnection, + OperationRun $operationRun, + ): bool { + return (int) $providerConnection->workspace_id === (int) $tenant->workspace_id + && (int) $providerConnection->managed_environment_id === (int) $tenant->getKey() + && (int) $operationRun->workspace_id === (int) $tenant->workspace_id + && (int) $operationRun->managed_environment_id === (int) $tenant->getKey() + && (int) data_get($operationRun->context, 'target_scope.workspace_id') === (int) $tenant->workspace_id + && (int) data_get($operationRun->context, 'target_scope.managed_environment_id') === (int) $tenant->getKey() + && (int) data_get($operationRun->context, 'target_scope.provider_connection_id') === (int) $providerConnection->getKey(); + } + + /** + * @param array|null $contract + */ + private function verifiedPendingCaptureContract(?CoverageSourceContractDecision $decision, ?array $contract): bool + { + return $decision instanceof CoverageSourceContractDecision + && is_array($contract) + && ($decision->sourceContractState ?? data_get($decision->sourceMetadata, 'source_contract_state')) === CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE + && data_get($decision->sourceMetadata, 'provider_adapter_state') === 'adapter_contract_available' + && data_get($decision->sourceMetadata, 'provider_calls_allowed') === false + && data_get($contract, 'source_contract_state') === CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE + && data_get($contract, 'read_only') === true; + } + + private function acceptedRunnerResult(ExchangePowerShellInvocationResult $runnerResult): bool + { + if (! $runnerResult->successful || ! is_array($runnerResult->collection) || ! array_is_list($runnerResult->collection)) { + return false; + } + + $outputState = $runnerResult->context['output_state'] ?? null; + + return is_string($outputState) && in_array($outputState, self::ACCEPTED_OUTPUT_STATES, true); + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellContentOnlyEvidenceGuard.php b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellContentOnlyEvidenceGuard.php new file mode 100644 index 00000000..b1b069a9 --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellContentOnlyEvidenceGuard.php @@ -0,0 +1,37 @@ +coverage_level !== CoverageLevel::ContentBacked) { + throw new InvalidArgumentException('Exchange PowerShell evidence exceeded the content-backed maximum.'); + } + + if ($evidence->evidence_state !== EvidenceState::ContentBacked) { + throw new InvalidArgumentException('Exchange PowerShell evidence state must remain content-backed.'); + } + + if ($resource->latest_claim_state !== ClaimState::InternalOnly) { + throw new InvalidArgumentException('Exchange PowerShell evidence must remain internal-only for this slice.'); + } + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php new file mode 100644 index 00000000..3e8fb86b --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php @@ -0,0 +1,381 @@ +, + * summary_counts: array + * } + */ + public function capture( + ManagedEnvironment $tenant, + ProviderConnection $providerConnection, + OperationRun $operationRun, + string $canonicalType, + ExchangePowerShellInvocationResult $runnerResult, + ): array { + $canonicalType = trim($canonicalType); + $eligibility = $this->eligibility->evaluate($tenant, $providerConnection, $operationRun, $canonicalType, $runnerResult); + + if (($eligibility['allowed'] ?? false) !== true) { + $summaryCounts = $this->zeroSummaryCounts(); + $this->recordSummaryCounts($operationRun, $summaryCounts); + + return $this->result( + canonicalType: $canonicalType, + outcome: $eligibility['outcome'] ?? CaptureOutcome::BlockedUnsupported, + adapterOutcome: self::OUTCOME_PREREQUISITE_BLOCKED, + itemCount: 0, + reasonCode: $eligibility['reason_code'] ?? 'exchange_capture_prerequisite_blocked', + sourceContractKey: null, + evidenceIds: [], + summaryCounts: $summaryCounts, + ); + } + + /** @var TenantConfigurationResourceType $resourceType */ + $resourceType = $eligibility['resource_type']; + /** @var CoverageSourceContractDecision $sourceDecision */ + $sourceDecision = $eligibility['decision']; + /** @var array $contract */ + $contract = $eligibility['contract']; + /** @var list> $items */ + $items = $runnerResult->collection; + + if ($items === []) { + $summaryCounts = $this->zeroSummaryCounts(); + $this->recordSummaryCounts($operationRun, $summaryCounts); + + return $this->result( + canonicalType: $canonicalType, + outcome: CaptureOutcome::Captured, + adapterOutcome: self::OUTCOME_EMPTY_COLLECTION, + itemCount: 0, + reasonCode: self::OUTCOME_EMPTY_COLLECTION, + sourceContractKey: $sourceDecision->contractKey, + evidenceIds: [], + summaryCounts: $summaryCounts, + ); + } + + $decision = $this->capturableDecision($sourceDecision, $contract); + $identity = $this->identityGate->evaluateCollection( + tenant: $tenant, + providerConnection: $providerConnection, + resourceType: $resourceType, + items: $items, + sourceMetadata: $decision->sourceMetadata, + ); + + if (($identity['allowed'] ?? false) !== true) { + $summaryCounts = $this->zeroSummaryCounts(); + $this->recordSummaryCounts($operationRun, $summaryCounts); + + return $this->result( + canonicalType: $canonicalType, + outcome: CaptureOutcome::BlockedUnsupported, + adapterOutcome: self::OUTCOME_IDENTITY_BLOCKED, + itemCount: 0, + reasonCode: $identity['reason_code'] ?? self::OUTCOME_IDENTITY_BLOCKED, + sourceContractKey: $sourceDecision->contractKey, + evidenceIds: [], + summaryCounts: $summaryCounts, + ); + } + + $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); + $resource = $this->resourceUpserter->upsert( + tenant: $tenant, + providerConnection: $providerConnection, + resourceType: $resourceType, + payload: $item, + sourceMetadata: $decision->sourceMetadata, + ); + + $evidence = $this->evidenceWriter->append( + resource: $resource, + resourceType: $resourceType, + providerConnection: $providerConnection, + operationRun: $operationRun, + decision: $decision, + rawPayload: $item, + normalizedPayload: $normalizedPayload, + payloadHash: $payloadHash, + permissionContext: $permissionContext, + maximumCoverageLevel: $this->contentOnlyGuard->maximumCoverageLevel(), + ); + + $this->contentOnlyGuard->assertContentOnly($evidence, $resource->refresh()); + $evidenceIds[] = (int) $evidence->getKey(); + } + + $summaryCounts = $this->successSummaryCounts(count($evidenceIds)); + $this->recordSummaryCounts($operationRun, $summaryCounts); + + return $this->result( + canonicalType: $canonicalType, + outcome: CaptureOutcome::Captured, + adapterOutcome: CaptureOutcome::Captured->value, + itemCount: count($evidenceIds), + reasonCode: null, + sourceContractKey: $decision->contractKey, + evidenceIds: $evidenceIds, + summaryCounts: $summaryCounts, + ); + } + + /** + * @param array $contract + */ + private function capturableDecision(CoverageSourceContractDecision $sourceDecision, array $contract): CoverageSourceContractDecision + { + $commandName = (string) $contract['command_name']; + $sourceEndpoint = ExchangePowerShellCommandContracts::SOURCE_SURFACE.':'.$commandName; + $sourceMetadata = array_filter([ + ...$sourceDecision->sourceMetadata, + 'source_endpoint' => $sourceEndpoint, + 'source_contract_state' => CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE, + 'capture_adapter' => 'exchange_powershell_evidence_capture_adapter', + 'capture_eligibility_state' => 'content_backed_only', + 'content_level_maximum' => 'content_backed', + 'evidence_promotion_allowed' => false, + 'customer_claims_allowed' => false, + 'restore_allowed' => false, + 'certification_allowed' => false, + ], static fn (mixed $value): bool => $value !== null && $value !== ''); + + return new CoverageSourceContractDecision( + canonicalType: $sourceDecision->canonicalType, + outcome: CaptureOutcome::Captured, + contractKey: $sourceDecision->contractKey, + sourceEndpoint: $sourceEndpoint, + sourceVersion: $sourceDecision->sourceVersion, + sourceSchemaHash: $sourceDecision->sourceSchemaHash, + reasonCode: null, + sourceContractState: CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE, + contract: $contract, + sourceMetadata: $sourceMetadata, + ); + } + + /** + * @param array $payload + * @param list $volatileFields + * @return array + */ + private function normalizedPayload( + TenantConfigurationResourceType $resourceType, + array $payload, + array $volatileFields, + CoverageSourceContractDecision $decision, + ): 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_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 $this->redactedPayload($normalized); + } + + /** + * @param array $payload + * @return array + */ + private function redactedPayload(array $payload): array + { + $redacted = $this->redactor->redact($payload); + + return is_array($redacted) ? $redacted : []; + } + + /** + * @param array $contract + * @return list + */ + private function volatileFields(array $contract): array + { + $fields = data_get($contract, 'response_shape.volatile_fields', []); + + if (! is_array($fields)) { + return []; + } + + return array_values(array_filter( + array_map(static fn (mixed $field): string => is_string($field) ? trim($field) : '', $fields), + static fn (string $field): bool => $field !== '', + )); + } + + /** + * @return array + */ + private function permissionContext(ProviderConnection $providerConnection): array + { + return $this->redactedPayload([ + 'provider_connection_id' => (int) $providerConnection->getKey(), + 'provider' => (string) $providerConnection->provider, + 'connection_type' => $this->stringValue($providerConnection->connection_type), + 'consent_status' => $this->stringValue($providerConnection->consent_status), + 'verification_status' => $this->stringValue($providerConnection->verification_status), + ]); + } + + /** + * @return array + */ + private function zeroSummaryCounts(): array + { + return [ + 'total' => 0, + 'processed' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, + 'items' => 0, + ]; + } + + /** + * @return array + */ + private function successSummaryCounts(int $count): array + { + $count = max(0, $count); + + return [ + 'total' => $count, + 'processed' => $count, + 'succeeded' => $count, + 'failed' => 0, + 'skipped' => 0, + 'items' => $count, + ]; + } + + /** + * @param array $summaryCounts + */ + private function recordSummaryCounts(OperationRun $operationRun, array $summaryCounts): void + { + $operationRun->forceFill([ + 'summary_counts' => SummaryCountsNormalizer::normalize($summaryCounts), + ])->save(); + } + + private function stringValue(mixed $value): ?string + { + if ($value instanceof \BackedEnum) { + return (string) $value->value; + } + + if (is_scalar($value)) { + $value = trim((string) $value); + + return $value !== '' ? $value : null; + } + + return null; + } + + /** + * @param list $evidenceIds + * @param array $summaryCounts + * @return array{ + * canonical_type: string, + * outcome: string, + * adapter_outcome: string, + * item_count: int, + * reason_code?: string|null, + * source_contract_key?: string|null, + * evidence_ids: list, + * summary_counts: array + * } + */ + private function result( + string $canonicalType, + CaptureOutcome $outcome, + string $adapterOutcome, + int $itemCount, + ?string $reasonCode, + ?string $sourceContractKey, + array $evidenceIds, + array $summaryCounts, + ): array { + return [ + 'canonical_type' => $canonicalType, + 'outcome' => $outcome->value, + 'adapter_outcome' => $adapterOutcome, + 'item_count' => max(0, $itemCount), + 'reason_code' => $reasonCode, + 'source_contract_key' => $sourceContractKey, + 'evidence_ids' => $evidenceIds, + 'summary_counts' => $summaryCounts, + ]; + } +} diff --git a/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellIdentityEvidenceGate.php b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellIdentityEvidenceGate.php new file mode 100644 index 00000000..5b3ebc47 --- /dev/null +++ b/apps/platform/app/Services/TenantConfiguration/ExchangePowerShellIdentityEvidenceGate.php @@ -0,0 +1,80 @@ +> $items + * @param array $sourceMetadata + * @return array{allowed: bool, failure_code?: string, reason_code?: string, blocked_index?: int, identity_state?: string, duplicate_identity?: bool} + */ + public function evaluateCollection( + ManagedEnvironment $tenant, + ProviderConnection $providerConnection, + TenantConfigurationResourceType $resourceType, + array $items, + array $sourceMetadata, + ): array { + $seenKeys = []; + + foreach ($items as $index => $item) { + $identity = $this->identityEvaluator->evaluate( + result: $this->identityResolver->resolve($resourceType, $item, $sourceMetadata), + tenant: $tenant, + providerConnection: $providerConnection, + resourceType: $resourceType, + ); + + if ($identity->identityState !== IdentityState::Stable) { + return [ + 'allowed' => false, + 'failure_code' => self::FAILURE_CODE, + 'reason_code' => $this->reasonCodeForIdentityState($identity->identityState), + 'blocked_index' => $index, + 'identity_state' => $identity->identityState->value, + ]; + } + + if (isset($seenKeys[$identity->canonicalResourceKey])) { + return [ + 'allowed' => false, + 'failure_code' => self::FAILURE_CODE, + 'reason_code' => 'duplicate_stable_identity', + 'blocked_index' => $index, + 'identity_state' => IdentityState::IdentityConflict->value, + 'duplicate_identity' => true, + ]; + } + + $seenKeys[$identity->canonicalResourceKey] = true; + } + + return ['allowed' => true]; + } + + private function reasonCodeForIdentityState(IdentityState $identityState): string + { + return match ($identityState) { + IdentityState::IdentityConflict => 'identity_conflict', + IdentityState::MissingExternalId => 'missing_stable_external_id', + IdentityState::UnsupportedIdentity => 'unsupported_identity', + IdentityState::Derived => 'derived_identity_blocked', + IdentityState::Stable => 'stable_identity', + }; + } +} diff --git a/apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php b/apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php new file mode 100644 index 00000000..0231da8e --- /dev/null +++ b/apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php @@ -0,0 +1,736 @@ +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('Spec434 captures accepted Exchange PowerShell output as internal content-backed evidence only', function (): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = spec434ReadyProviderConnection($environment); + spec434Credential($connection, ProviderCredentialKind::Certificate->value); + $run = spec434CaptureRun($environment, $user, $connection); + + $result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalType: 'transportRule', + runnerResult: ExchangePowerShellInvocationResult::succeeded( + [spec434TransportRulePayload()], + context: ['output_state' => 'structured_collection'], + ), + ); + + $run->refresh(); + $resource = TenantConfigurationResource::query()->sole(); + $evidence = TenantConfigurationResourceEvidence::query()->sole(); + + expect($result) + ->toMatchArray([ + 'canonical_type' => 'transportRule', + 'outcome' => CaptureOutcome::Captured->value, + 'adapter_outcome' => CaptureOutcome::Captured->value, + 'item_count' => 1, + 'reason_code' => null, + 'source_contract_key' => 'exchange_powershell.transportRule', + 'summary_counts' => [ + 'total' => 1, + 'processed' => 1, + 'succeeded' => 1, + 'failed' => 0, + 'skipped' => 0, + 'items' => 1, + ], + ]) + ->and($result['evidence_ids'])->toBe([(int) $evidence->getKey()]) + ->and($run->summary_counts)->toBe([ + 'total' => 1, + 'processed' => 1, + 'succeeded' => 1, + 'failed' => 0, + 'skipped' => 0, + 'items' => 1, + ]) + ->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_identity_state)->toBe(IdentityState::Stable) + ->and($resource->latest_claim_state)->toBe(ClaimState::InternalOnly) + ->and($resource->latest_evidence_state)->toBe(EvidenceState::ContentBacked) + ->and($evidence->coverage_level)->toBe(CoverageLevel::ContentBacked) + ->and($evidence->evidence_state)->toBe(EvidenceState::ContentBacked) + ->and($evidence->capture_outcome)->toBe(CaptureOutcome::Captured) + ->and($evidence->source_contract_key)->toBe('exchange_powershell.transportRule') + ->and($evidence->source_endpoint)->toBe(ExchangePowerShellCommandContracts::SOURCE_SURFACE.':Get-TransportRule') + ->and($evidence->source_metadata['capture_adapter'])->toBe('exchange_powershell_evidence_capture_adapter') + ->and($evidence->source_metadata['content_level_maximum'])->toBe('content_backed') + ->and($evidence->source_metadata['customer_claims_allowed'])->toBeFalse() + ->and($evidence->source_metadata['restore_allowed'])->toBeFalse() + ->and($evidence->source_metadata['certification_allowed'])->toBeFalse() + ->and($evidence->normalized_payload['source']['source_surface'])->toBe(ExchangePowerShellCommandContracts::SOURCE_SURFACE) + ->and($evidence->permission_context['provider_connection_id'])->toBe((int) $connection->getKey()); + + $persistedRun = json_encode([ + 'context' => $run->context, + 'summary_counts' => $run->summary_counts, + 'failure_summary' => $run->failure_summary, + ], JSON_THROW_ON_ERROR); + + expect($persistedRun) + ->not->toContain('raw_shell_stdout') + ->not->toContain('raw_shell_stderr') + ->not->toContain('provider_response') + ->not->toContain('client_secret') + ->not->toContain('access_token'); +}); + +it('Spec434 accepts only the included Exchange PowerShell capture targets', function (string $canonicalType): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = spec434ReadyProviderConnection($environment); + spec434Credential($connection, ProviderCredentialKind::Certificate->value); + $run = spec434CaptureRun($environment, $user, $connection, resourceTypes: [$canonicalType]); + + $result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalType: $canonicalType, + runnerResult: ExchangePowerShellInvocationResult::succeeded( + [spec434PayloadFor($canonicalType)], + context: ['output_state' => 'structured_collection'], + ), + ); + + expect($result['outcome'])->toBe(CaptureOutcome::Captured->value) + ->and($result['item_count'])->toBe(1) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(1); +})->with([ + 'transportRule', + 'remoteDomain', + 'inboundConnector', +]); + +it('Spec434 blocks unsupported Exchange and Teams targets before evidence append', function (string $canonicalType): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = spec434ReadyProviderConnection($environment); + spec434Credential($connection, ProviderCredentialKind::Certificate->value); + $run = spec434CaptureRun($environment, $user, $connection, resourceTypes: [$canonicalType]); + + $result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalType: $canonicalType, + runnerResult: ExchangePowerShellInvocationResult::succeeded( + [['id' => 'unsupported-1', 'DisplayName' => 'Unsupported']], + context: ['output_state' => 'structured_collection'], + ), + ); + + expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED) + ->and($result['reason_code'])->toBe('unsupported_exchange_capture_type') + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +})->with([ + 'acceptedDomain', + 'organizationConfig', + 'meetingPolicy', +]); + +it('Spec434 blocks missing operation runs through the typed adapter boundary', function (): void { + [, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = spec434ReadyProviderConnection($environment); + spec434Credential($connection, ProviderCredentialKind::Certificate->value); + + expect(fn () => app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: null, + canonicalType: 'transportRule', + runnerResult: ExchangePowerShellInvocationResult::succeeded( + [spec434TransportRulePayload()], + context: ['output_state' => 'structured_collection'], + ), + ))->toThrow(TypeError::class); + + expect(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec434 blocks wrong operation type and same-scope mismatches before evidence append', function (string $case): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = spec434ReadyProviderConnection($environment); + spec434Credential($connection, ProviderCredentialKind::Certificate->value); + $run = spec434CaptureRun($environment, $user, $connection); + + if ($case === 'wrong_operation_type') { + $run->forceFill(['type' => OperationRunType::TenantConfigurationExchangePowerShellInvocation->value])->save(); + } + + if ($case === 'wrong_workspace_context') { + $run->forceFill(['context' => array_replace_recursive($run->context, [ + 'target_scope' => ['workspace_id' => (int) $environment->workspace_id + 999], + ])])->save(); + } + + if ($case === 'wrong_managed_environment_context') { + $run->forceFill(['context' => array_replace_recursive($run->context, [ + 'target_scope' => ['managed_environment_id' => (int) $environment->getKey() + 999], + ])])->save(); + } + + if ($case === 'wrong_provider_connection_context') { + $run->forceFill(['context' => array_replace_recursive($run->context, [ + 'target_scope' => ['provider_connection_id' => (int) $connection->getKey() + 999], + ])])->save(); + } + + $result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run->refresh(), + canonicalType: 'transportRule', + runnerResult: ExchangePowerShellInvocationResult::succeeded( + [spec434TransportRulePayload()], + context: ['output_state' => 'structured_collection'], + ), + ); + + expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED) + ->and($result['reason_code'])->toBe($case === 'wrong_operation_type' ? 'operation_type_mismatch' : 'scope_mismatch') + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +})->with([ + 'wrong_operation_type', + 'wrong_workspace_context', + 'wrong_managed_environment_context', + 'wrong_provider_connection_context', +]); + +it('Spec434 blocks cross-scope provider connections before evidence append', function (): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + [, $otherEnvironment] = createMinimalUserWithTenant(role: 'owner'); + $foreignConnection = spec434ReadyProviderConnection($otherEnvironment); + spec434Credential($foreignConnection, ProviderCredentialKind::Certificate->value); + $run = spec434CaptureRun($environment, $user, $foreignConnection); + + $result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture( + tenant: $environment, + providerConnection: $foreignConnection, + operationRun: $run, + canonicalType: 'transportRule', + runnerResult: ExchangePowerShellInvocationResult::succeeded( + [spec434TransportRulePayload()], + context: ['output_state' => 'structured_collection'], + ), + ); + + expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED) + ->and($result['reason_code'])->toBe('scope_mismatch') + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec434 blocks missing readiness and non-supported provider capability before evidence append', function (string $case): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = match ($case) { + 'missing_readiness' => spec434ReadyProviderConnection($environment, seedExchangePowerShellPermission: false), + 'capability_not_supported' => spec434ReadyProviderConnection($environment, [ + 'consent_status' => ProviderConsentStatus::Required->value, + 'consent_granted_at' => null, + ]), + default => throw new InvalidArgumentException('Unsupported Spec434 readiness case.'), + }; + + spec434Credential($connection, ProviderCredentialKind::Certificate->value); + $run = spec434CaptureRun($environment, $user, $connection); + + $result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalType: 'transportRule', + runnerResult: ExchangePowerShellInvocationResult::succeeded( + [spec434TransportRulePayload()], + context: ['output_state' => 'structured_collection'], + ), + ); + + expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED) + ->and($result['reason_code'])->not->toBeNull() + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +})->with([ + 'missing_readiness', + 'capability_not_supported', +]); + +it('Spec434 blocks runner results that are not accepted structured or empty collections', function (ExchangePowerShellInvocationResult $runnerResult): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = spec434ReadyProviderConnection($environment); + spec434Credential($connection, ProviderCredentialKind::Certificate->value); + $run = spec434CaptureRun($environment, $user, $connection); + + $result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalType: 'transportRule', + runnerResult: $runnerResult, + ); + + expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_PREREQUISITE_BLOCKED) + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +})->with([ + 'missing output state' => fn (): ExchangePowerShellInvocationResult => ExchangePowerShellInvocationResult::succeeded([spec434TransportRulePayload()]), + 'associative payload' => fn (): ExchangePowerShellInvocationResult => ExchangePowerShellInvocationResult::succeededPayload( + ['Guid' => 'not-a-list'], + ['output_state' => 'structured_collection'], + ), + 'failed runner' => fn (): ExchangePowerShellInvocationResult => ExchangePowerShellInvocationResult::failed( + reasonCode: 'provider_failed', + failureCode: 'runner_failed', + message: 'Rejected safely.', + ), +]); + +it('Spec434 blocks unsafe identity before resource or evidence writes', function (string $canonicalType, array $payload, string $reasonCode): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = spec434ReadyProviderConnection($environment); + spec434Credential($connection, ProviderCredentialKind::Certificate->value); + $run = spec434CaptureRun($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'], + ), + ); + + expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_IDENTITY_BLOCKED) + ->and($result['reason_code'])->toBe($reasonCode) + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +})->with([ + 'transport rule display-name only' => [ + 'transportRule', + ['DisplayName' => 'Display names are not stable identifiers'], + 'missing_stable_external_id', + ], + 'remote domain derived-only domain name' => [ + 'remoteDomain', + ['DomainName' => 'contoso.example', 'DisplayName' => 'Contoso'], + 'missing_stable_external_id', + ], +]); + +it('Spec434 blocks duplicate stable identities before resource or evidence writes', function (): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = spec434ReadyProviderConnection($environment); + spec434Credential($connection, ProviderCredentialKind::Certificate->value); + $run = spec434CaptureRun($environment, $user, $connection); + + $result = app(ExchangePowerShellEvidenceCaptureAdapter::class)->capture( + tenant: $environment, + providerConnection: $connection, + operationRun: $run, + canonicalType: 'transportRule', + runnerResult: ExchangePowerShellInvocationResult::succeeded( + [ + spec434TransportRulePayload(['DisplayName' => 'Duplicate A']), + spec434TransportRulePayload(['DisplayName' => 'Duplicate B']), + ], + context: ['output_state' => 'structured_collection'], + ), + ); + + expect($result['adapter_outcome'])->toBe(ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_IDENTITY_BLOCKED) + ->and($result['reason_code'])->toBe('duplicate_stable_identity') + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec434 identity gate detects existing same-scope identity conflict before writer use', function (): void { + [, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = spec434ReadyProviderConnection($environment); + $resourceType = app(ResourceTypeRegistry::class)->findActive('transportRule'); + + expect($resourceType)->toBeInstanceOf(TenantConfigurationResourceType::class); + + $sourceMetadata = [ + 'source_contract_key' => 'exchange_powershell.transportRule', + 'source_version' => ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION, + ]; + $payload = ['DisplayName' => 'Collision without stable id']; + $identity = app(CanonicalIdentityResolver::class)->resolve($resourceType, $payload, $sourceMetadata); + + TenantConfigurationResource::factory()->create([ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + 'resource_type_id' => (int) $resourceType->getKey(), + 'canonical_type' => 'transportRule', + 'canonical_resource_key' => $identity->canonicalResourceKey, + 'source_identity' => $identity->sourceIdentity, + 'secondary_identity_keys' => $identity->secondaryKeys, + 'latest_identity_state' => IdentityState::MissingExternalId->value, + 'latest_claim_state' => ClaimState::ClaimBlocked->value, + ]); + + $result = app(ExchangePowerShellIdentityEvidenceGate::class)->evaluateCollection( + tenant: $environment, + providerConnection: $connection, + resourceType: $resourceType, + items: [$payload], + sourceMetadata: $sourceMetadata, + ); + + $resource = TenantConfigurationResource::query()->sole(); + + expect($result['allowed'])->toBeFalse() + ->and($result['reason_code'])->toBe('identity_conflict') + ->and($resource->refresh()->latest_identity_state)->toBe(IdentityState::IdentityConflict) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec434 identity gate reports unsupported identity states without writer use', function (): void { + [, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = spec434ReadyProviderConnection($environment); + $resourceType = TenantConfigurationResourceType::factory()->create([ + 'canonical_type' => 'unsupportedExchangeIdentity', + ]); + + $result = app(ExchangePowerShellIdentityEvidenceGate::class)->evaluateCollection( + tenant: $environment, + providerConnection: $connection, + resourceType: $resourceType, + items: [['id' => 'unsupported-identity']], + sourceMetadata: [], + ); + + expect($result['allowed'])->toBeFalse() + ->and($result['reason_code'])->toBe('unsupported_identity') + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec434 empty collections persist only zero summary counts and no durable collection proof', function (): void { + [$user, $environment] = createMinimalUserWithTenant(role: 'owner'); + $connection = spec434ReadyProviderConnection($environment); + spec434Credential($connection, ProviderCredentialKind::Certificate->value); + $run = spec434CaptureRun($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) + ->toMatchArray([ + 'outcome' => CaptureOutcome::Captured->value, + 'adapter_outcome' => ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_EMPTY_COLLECTION, + 'item_count' => 0, + 'reason_code' => ExchangePowerShellEvidenceCaptureAdapter::OUTCOME_EMPTY_COLLECTION, + 'summary_counts' => [ + 'total' => 0, + 'processed' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, + 'items' => 0, + ], + ]) + ->and($run->refresh()->summary_counts)->toBe([ + 'total' => 0, + 'processed' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'skipped' => 0, + 'items' => 0, + ]) + ->and(TenantConfigurationResource::query()->count())->toBe(0) + ->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0); +}); + +it('Spec434 implementation has no Graph fallback path, product surface, tenant-id scope, or mini-platform', function (): void { + $runtimeFiles = [ + app_path('Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php'), + app_path('Services/TenantConfiguration/ExchangePowerShellCaptureEligibilityGate.php'), + app_path('Services/TenantConfiguration/ExchangePowerShellIdentityEvidenceGate.php'), + app_path('Services/TenantConfiguration/ExchangePowerShellContentOnlyEvidenceGuard.php'), + app_path('Services/TenantConfiguration/CoverageEvidenceWriter.php'), + ]; + $runtimeSource = collect($runtimeFiles) + ->map(fn (string $file): string => file_get_contents($file) ?: '') + ->implode("\n"); + + expect(preg_match('/\btenant_id\b/', $runtimeSource))->toBe(0) + ->and($runtimeSource) + ->not->toContain('ProviderGateway') + ->not->toContain('GenericContentEvidenceCaptureService') + ->not->toContain('listPolicies(') + ->not->toContain('/deviceManagement') + ->not->toContain('fake_empty_success') + ->not->toContain('fake_captured') + ->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(); + + $captureOutcomeValues = array_map( + static fn (CaptureOutcome $outcome): string => $outcome->value, + CaptureOutcome::cases(), + ); + + expect($captureOutcomeValues) + ->toContain(CaptureOutcome::Captured->value) + ->not->toContain('fake_captured') + ->not->toContain('fake_empty_success'); +}); + +/** + * @param array $overrides + */ +function spec434ReadyProviderConnection( + 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) { + spec434SeedExchangePowerShellPermission($environment, $connection); + } + + return $connection; +} + +/** + * @param array $overrides + */ +function spec434SeedExchangePowerShellPermission( + 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 $overrides + */ +function spec434Credential(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 $overrides + * @param list $resourceTypes + */ +function spec434CaptureRun( + ManagedEnvironment $environment, + 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', + ]; + + 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 $overrides + * @return array + */ +function spec434TransportRulePayload(array $overrides = []): array +{ + return [ + 'Guid' => '11111111-2222-3333-4444-555555555555', + 'RuleId' => 'spec434-rule-1', + 'DisplayName' => 'Spec 434 transport rule', + 'Name' => 'Spec 434 transport rule', + 'Priority' => 0, + 'Mode' => 'Enforce', + 'State' => 'Enabled', + 'Enabled' => true, + 'Conditions' => ['SenderDomainIs' => ['contoso.example']], + 'Actions' => ['RedirectMessageTo' => ['security@contoso.example']], + 'WhenChanged' => 'volatile', + ...$overrides, + ]; +} + +/** + * @return array + */ +function spec434RemoteDomainPayload(): array +{ + return [ + 'Guid' => '22222222-3333-4444-5555-666666666666', + 'RemoteDomainId' => 'spec434-remote-domain-1', + 'Identity' => 'spec434-remote-domain-1', + 'DomainName' => 'contoso.example', + 'DisplayName' => 'Contoso remote domain', + 'IsDefault' => false, + 'AllowedOOFType' => 'External', + 'AutoReplyEnabled' => true, + ]; +} + +/** + * @return array + */ +function spec434InboundConnectorPayload(): array +{ + return [ + 'Guid' => '33333333-4444-5555-6666-777777777777', + 'ConnectorId' => 'spec434-inbound-connector-1', + 'Identity' => 'spec434-inbound-connector-1', + 'DisplayName' => 'Spec 434 inbound connector', + 'Name' => 'Spec 434 inbound connector', + 'ConnectorType' => 'Partner', + 'Enabled' => true, + 'RequireTls' => true, + ]; +} + +/** + * @return array + */ +function spec434PayloadFor(string $canonicalType): array +{ + return match ($canonicalType) { + 'transportRule' => spec434TransportRulePayload(), + 'remoteDomain' => spec434RemoteDomainPayload(), + 'inboundConnector' => spec434InboundConnectorPayload(), + default => throw new InvalidArgumentException('Unsupported Spec434 payload type.'), + }; +} diff --git a/specs/434-exchange-evidence-capture-adapter-content-only-guard/checklists/requirements.md b/specs/434-exchange-evidence-capture-adapter-content-only-guard/checklists/requirements.md new file mode 100644 index 00000000..df1efd98 --- /dev/null +++ b/specs/434-exchange-evidence-capture-adapter-content-only-guard/checklists/requirements.md @@ -0,0 +1,212 @@ +# Requirements Checklist: Exchange Evidence Capture Adapter and Content-Only Guard + +**Feature**: `specs/434-exchange-evidence-capture-adapter-content-only-guard/` +**Mode**: Backend adapter/guard implementation close-out. Preparation completed earlier; implementation evidence is documented in `tasks.md` and `implementation-report.md`. + +## Implementation Evidence Note + +- Completed items below are marked only where supported by the implementation report, completed task evidence, focused Spec 434 feature coverage, regression results, or code review evidence. +- Unsafe-identity writer non-invocation is proven by adapter code order plus zero resource/evidence database assertions; Spec 434 did not add a literal writer spy. + +## Preparation Gate + +- [x] `spec.md` exists. +- [x] `plan.md` exists. +- [x] `tasks.md` exists. +- [x] `checklists/requirements.md` exists. +- [x] Candidate Selection Gate result is recorded. +- [x] Spec Readiness Gate result is recorded. +- [x] Completed-spec guardrail result for Specs 429-433 is recorded. +- [x] Spec 433 PASS WITH CONDITIONS dependency is recorded. +- [x] Product Surface impact is `N/A - no rendered product surface changed`. +- [x] Browser proof is `N/A - no rendered UI surface changed`. +- [x] Proportionality review is present for the adapter/guard abstraction. +- [x] No application runtime file was edited during preparation. + +## Prerequisites for Implementation + +- [x] Re-check current branch, HEAD, and dirty state before runtime changes. +- [x] Confirm Specs 429-433 remain read-only context. +- [x] Confirm Spec 433 has no merge-blocking readiness/redaction/no-promotion finding for this adapter slice. +- [x] Confirm Spec 430 verified command contracts still cover `transportRule`, `remoteDomain`, and `inboundConnector`. +- [x] Confirm Spec 432 runner boundary remains implemented and validated. +- [x] Confirm Spec 433 combined credential/permission readiness remains implemented and validated. +- [x] Confirm `exchange_powershell_invoke` provider capability evaluation remains implemented and validated. +- [x] Confirm no new UI/routes/jobs/schedules/listeners/migrations/customer-output scope is required. + +## Scope Requirements + +- [x] Adapter boundary only. +- [x] Content-only guard only. +- [x] Identity hard-stop before evidence append. +- [x] Empty collection no-fake-evidence policy. +- [x] No full target evidence promotion. +- [x] No compare/render. +- [x] No certification. +- [x] No restore. +- [x] No customer claim. +- [x] No UI. +- [x] No jobs/schedules/listeners/triggers. +- [x] No migration unless spec is amended. +- [x] No `tenant_id` ownership truth. + +## OperationRun Requirements + +- [x] Uses `tenant_configuration.capture`. +- [x] Does not add a new OperationRun type. +- [x] Requires same workspace and managed environment scope. +- [x] Requires matching provider connection scope. +- [x] Uses existing summary keys only; no `OperationSummaryKeys` expansion was required. +- [x] Summary counts are flat numeric values. +- [x] OperationRun context contains no raw payload. +- [x] OperationRun context contains no raw stdout/stderr. +- [x] OperationRun context contains no credential material, token, provider response body, or serialized Exchange object. + +## Adapter Boundary Requirements + +- [x] Exchange adapter exists or repo-canonical equivalent exists. +- [x] Adapter is internal/trusted only. +- [x] Adapter accepts only `transportRule`, `remoteDomain`, and `inboundConnector`. +- [x] Adapter requires Spec 433 readiness. +- [x] Adapter requires `exchange_powershell_invoke` provider capability status `Supported`. +- [x] Adapter requires Spec 432 runner boundary. +- [x] Adapter accepts only successful list-shaped `ExchangePowerShellInvocationResult` output with sanitized `structured_collection` or `empty_collection` context. +- [x] Adapter requires Spec 430 verified command contracts. +- [x] Adapter does not use `ProviderGateway` or the generic Graph list path. +- [x] Adapter does not fake Graph endpoints or fake captured outcomes. +- [x] Adapter-local outcome/failure codes are limited to the Spec 434 allowlist. +- [x] Persisted evidence outcomes reuse existing `CaptureOutcome` values only. + +## Identity Requirements + +- [x] Identity is evaluated before evidence append. +- [x] Identity conflict blocks. +- [x] Missing stable external ID blocks unless an existing repo-approved safe rule applies. +- [x] Unsupported identity blocks. +- [x] Duplicate identity blocks. +- [x] Display-name-only stable identity is impossible by default. +- [x] Unsafe identity blocks before the writer path by adapter code order plus zero resource/evidence assertions; no literal writer spy is used. +- [x] Unsafe identity creates no resource or evidence row. + +## Content-Only Guard Requirements + +- [x] Maximum Exchange adapter coverage level is `content_backed`. +- [x] Comparable is not set. +- [x] Renderable is not set. +- [x] Certified is not set. +- [x] Restore-ready is not set. +- [x] Customer-claimable is not set. +- [x] Existing Graph capture writer behavior remains unchanged. + +## Empty Collection Requirements + +- [x] Empty collection produces a safe zero-item outcome. +- [x] Empty collection uses `exchange_capture_empty_collection` or repo-canonical equivalent only as an internal sanitized outcome. +- [x] Empty collection may update safe summary counts only. +- [x] Empty collection creates no fake resource row. +- [x] Empty collection creates no fake evidence row. +- [x] Durable collection-level empty proof is deferred. + +## Redaction Requirements + +- [x] Raw provider payload stays out of OperationRun context, messages, logs, notifications, and summaries. +- [x] Raw stdout/stderr stays out of OperationRun context, messages, logs, notifications, and summaries. +- [x] Serialized Exchange objects stay out of OperationRun context, messages, logs, notifications, and summaries. +- [x] Credential material, tokens, authorization headers, cookies, and certificate material never appear in context/logs/tests. +- [x] Sensitive Exchange configuration examples are not rendered or summarized as product/customer output. + +## Product Surface / Trigger Requirements + +- [x] No routes. +- [x] No Filament pages/resources/widgets. +- [x] No Livewire components. +- [x] No Blade/view changes. +- [x] No navigation. +- [x] No asset changes. +- [x] No global search changes. +- [x] No job trigger. +- [x] No schedule trigger. +- [x] No listener trigger. +- [x] No customer output. +- [x] No report, Review Pack, PDF, restore, compare/render, or certification surface. +- [x] Browser result is `N/A - no rendered UI surface changed`. + +## Architecture Requirements + +- [x] Uses existing Coverage v2 evidence architecture. +- [x] Generic Graph capture remains intact. +- [x] No Exchange-specific evidence table. +- [x] No `tenant_id` ownership truth. +- [x] No new `CaptureOutcome` enum value. +- [x] No Exchange mini-platform. +- [x] No legacy shim. +- [x] No fallback reader. +- [x] No dual write. +- [x] No Coverage v1 bridge. + +## Validation Requirements + +- [x] Focused Spec 434 unit-test intent is covered by the DB-backed Spec 434 feature test file per the tasks implementation note. +- [x] Focused Spec 434 feature tests pass; implementation report records 25 tests / 154 assertions. +- [x] Spec 433 regression passes. +- [x] Spec 432/431/430 regression passes. +- [x] Coverage v2/generic capture/identity/provider regressions pass. +- [x] Provider capability regressions prove `exchange_powershell_invoke` must be `Supported` before adapter capture proceeds. +- [x] Laravel 12, PHP 8.4, PostgreSQL, and Pest 4 compatibility notes are recorded. +- [x] Pint passes. +- [x] `git diff --check` passes for tracked changes; untracked file content is not claimed as covered until staged/tracked and is listed through `git status --short`. +- [x] `git status --short` is documented. +- [x] Implementation report records all required matrices and deferred work. + +## Final Implementation Gate + +Choose one during implementation close-out: + +```text +PASS +PASS WITH CONDITIONS +FAIL +``` + +PASS requires: + +```text +Exchange capture adapter boundary exists. +tenant_configuration.capture is reused. +No new OperationRun type is added. +exchange_powershell_invoke provider capability is required and Supported. +Accepted runner output is an ExchangePowerShellInvocationResult with list-shaped structured or empty collection context. +Identity hard-stop prevents unsafe evidence append. +Content-only guard prevents promotion beyond content_backed. +Empty collection policy prevents fake evidence. +Generic Graph capture remains intact. +OperationRun context is sanitized. +Persisted outcomes use existing CaptureOutcome values and internal outcome/failure codes are allowlisted. +No compare/render/certification/restore/customer state appears. +No UI/routes/jobs/schedules/listeners are added. +No tenant_id ownership truth is introduced. +No Exchange-specific evidence table appears. +Focused tests and regressions pass. +``` + +PASS WITH CONDITIONS is allowed only for bounded follow-ups that do not weaken adapter safety, identity hard-stop, content-only guard, empty-collection safety, redaction, no-promotion posture, no-product-surface posture, ownership, or no-mini-platform posture. + +FAIL if: + +```text +Exchange adapter bypasses readiness gates. +Exchange adapter proceeds while exchange_powershell_invoke is not Supported. +Exchange adapter accepts malformed or unverified runner output. +Unsafe identity can append evidence. +CoverageEvidenceWriter can over-promote through the Exchange path. +Empty collection creates fake evidence. +New CaptureOutcome values are added without spec amendment. +Exchange routes through Graph gateway incorrectly. +OperationRun stores raw payload or credential/provider secrets. +Coverage promotes to comparable/renderable/certified. +Restore/customer claims appear. +UI/routes/jobs/schedules/listeners are added. +tenant_id ownership truth is introduced. +Exchange-specific evidence table appears. +Tests do not prove adapter and guard safety. +``` diff --git a/specs/434-exchange-evidence-capture-adapter-content-only-guard/implementation-report.md b/specs/434-exchange-evidence-capture-adapter-content-only-guard/implementation-report.md new file mode 100644 index 00000000..591c3355 --- /dev/null +++ b/specs/434-exchange-evidence-capture-adapter-content-only-guard/implementation-report.md @@ -0,0 +1,136 @@ +# Implementation Report: Exchange Evidence Capture Adapter and Content-Only Guard + +Date: 2026-07-08 + +## Preflight + +- Branch: `434-exchange-evidence-capture-adapter-content-only-guard` +- HEAD: `a6f45299 feat: add Exchange credential permission evidence readiness (#500)` +- Initial dirty state: active Spec 434 package was untracked; no completed Specs 429-433 were edited. +- Final dirty state: intended Spec 434 runtime/test files plus active Spec 434 package only. +- Activated gates/skills: `spec-kit-implementation-loop`, `pest-testing`, spec readiness, workspace scope safety, OperationRun truth, evidence anchor contract, provider freshness semantics, Product Surface gate, customer output gate, TCM cutover guard, RBAC/action safety. +- Hard-gate stop conditions: none. + +## Close-Out Dirty State + +- `git status --short --branch`: + - `## 434-exchange-evidence-capture-adapter-content-only-guard` + - ` M apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php` + - `?? apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCaptureEligibilityGate.php` + - `?? apps/platform/app/Services/TenantConfiguration/ExchangePowerShellContentOnlyEvidenceGuard.php` + - `?? apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php` + - `?? apps/platform/app/Services/TenantConfiguration/ExchangePowerShellIdentityEvidenceGate.php` + - `?? apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php` + - `?? specs/434-exchange-evidence-capture-adapter-content-only-guard/` +- `git diff --name-only`: `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php` +- Untracked Spec 434 package files are expanded in the Files Changed section below. + +## Completed-Spec Context + +- Specs 429-433 were treated as read-only context. +- Spec 433 implementation report state: PASS WITH CONDITIONS; the remaining condition was a broader existing provider readiness card and did not block the Spec 434 adapter. +- Spec 434 keeps the existing `tenant_configuration.capture` OperationRun as the only evidence-owning operation type for this slice. + +## Runtime Changes + +- Added `ExchangePowerShellEvidenceCaptureAdapter`. +- Added `ExchangePowerShellCaptureEligibilityGate`. +- Added `ExchangePowerShellIdentityEvidenceGate`. +- Added `ExchangePowerShellContentOnlyEvidenceGuard`. +- Extended `CoverageEvidenceWriter::append()` with an optional maximum coverage level cap. + +## Boundary Proof + +- Included capture targets only: `transportRule`, `remoteDomain`, `inboundConnector`. +- Unsupported Exchange/Teams targets block before resource/evidence append. +- Same-scope checks cover workspace, managed environment, provider connection, and `OperationRun.context.target_scope`. +- Wrong operation type blocks before evidence append. +- Spec 433 combined readiness is required. +- `ProviderCapabilityEvaluator` must return `Supported` for `exchange_powershell_invoke`. +- Spec 432 runner results are accepted only as successful list collections with `output_state` of `structured_collection` or `empty_collection`. +- Spec 430 verified pending command contracts are required. +- Adapter-owned endpoints use `exchange_online_powershell_rest:`, not Graph endpoints. +- No `ProviderGateway`, generic Graph list path, fake Graph endpoint, `fake_empty_success`, or fake captured outcome is used. + +## Evidence And Identity + +- Identity hard-stop runs before resource upsert/evidence append. +- Blocks missing stable ID, display-name-only identity, derived-only remote domain identity, duplicate stable identity, unsupported identity, and existing same-scope identity conflict. +- Empty collections write zero summary counts only and create no resource/evidence rows. +- Non-empty captures store internal content-backed evidence only. +- Exchange adapter calls `CoverageEvidenceWriter` with a `content_backed` maximum and verifies evidence state, coverage level, and latest claim state after write. +- The path does not promote to comparable, renderable, certified, restore-ready, or customer-claimable states. + +## Product Surface + +- Product Surface Impact: none. +- UI Surface Impact: none. +- No legacy posture exception required. +- Page archetype, surface budgets, Technical Annex/deep-link demotion, canonical status vocabulary: 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 rendered UI surface changed. +- Visible complexity outcome: neutral. + +## Filament / Livewire / Assets + +- Livewire v4 compliance unchanged; installed Livewire is v4.1.4. +- Provider registration unchanged under `apps/platform/bootstrap/providers.php`. +- Global search unchanged; no resources were added or modified. +- Destructive/high-impact actions: none. +- Asset strategy: none; no Filament assets added and no deploy-time `filament:assets` requirement introduced. + +## No-Scope-Expansion Proof + +- No UI, routes, navigation, Filament pages/resources/widgets, Livewire components, Blade views, assets, jobs, schedules, listeners, customer output, Review Pack, PDF, restore, certification, compare/render surface, migration, Exchange-specific evidence table, `tenant_id`, legacy shim, fallback reader, dual write, or Exchange mini-platform added. +- Laravel 12 / PHP 8.4 / PostgreSQL / Pest 4 compatibility unchanged. +- Deployment impact: none; no env vars, migrations, queues, cron, storage, assets, or Dokploy runtime changes. + +## Validation + +- `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec434 --compact`: failed before results with process signal 9; fallback used per task T053. +- `cd apps/platform && php artisan test --filter=Spec434 --compact`: PASS, 25 tests / 154 assertions. +- `cd apps/platform && php artisan test --filter=Spec433 --compact`: PASS, 48 tests / 211 assertions. +- `cd apps/platform && php artisan test --filter='Spec432|Spec431|Spec430' --compact`: PASS, 173 tests / 1357 assertions. +- `cd apps/platform && php artisan test --filter='Spec415|Spec417|Spec419|Spec420|Spec426|Spec427' --compact`: PASS, 202 tests / 2137 assertions, 8 existing PostgreSQL-specific skips. +- `cd apps/platform && php artisan test --filter='ProviderCapabilityRegistryTest|ProviderCapabilityEvaluationTest' --compact`: PASS, 7 tests / 30 assertions. +- `cd apps/platform && ./vendor/bin/pint --dirty --test`: PASS, 6 files. +- `git diff --check`: PASS for tracked changes. Close-out review note: because the new Spec 434 package and Exchange adapter/test files remain untracked in the current dirty state, `git diff --check` does not validate their content until they are staged/tracked; `git status --short` is the source of truth for the untracked file list. + +## Post-Implementation Analysis + +- Iteration 1 findings fixed: + - Captured `maximumCoverageLevel` into the writer transaction closure. + - Treated empty accepted collections as zero-item captured results, not unsupported failures. + - Reused `CoverageResourceIdentityEvaluator` in the pre-writer identity gate to catch existing unsafe same-scope conflicts. + - Fixed Pest dataset shape and Pint formatting. +- Remaining in-scope findings: none. +- Residual risk: the adapter is internal and not wired to a job/start surface in Spec 434; a future spec must explicitly design invocation-to-capture orchestration if needed. +- Close-out hotfix corrections: + - Requirements checklist synchronized to implementation evidence. + - Changed-files inventory corrected to include all active Spec 434 artifacts in the dirty state. + - Identity proof wording corrected: unsafe identity uses adapter code order plus zero resource/evidence assertions, not a literal writer spy. + - Static validation wording corrected so `git diff --check` is not claimed to validate untracked file content. + +## Files Changed + +- `apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php` +- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCaptureEligibilityGate.php` +- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellContentOnlyEvidenceGuard.php` +- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php` +- `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellIdentityEvidenceGate.php` +- `apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php` +- `specs/434-exchange-evidence-capture-adapter-content-only-guard/spec.md` +- `specs/434-exchange-evidence-capture-adapter-content-only-guard/plan.md` +- `specs/434-exchange-evidence-capture-adapter-content-only-guard/tasks.md` +- `specs/434-exchange-evidence-capture-adapter-content-only-guard/checklists/requirements.md` +- `specs/434-exchange-evidence-capture-adapter-content-only-guard/implementation-report.md` + +## Merge Readiness Gate + +Status: PASS. +Merge-ready: yes. + +Manual review prompt: + +Review Spec 434 for backend-only Exchange PowerShell evidence capture. Focus on the new adapter and gates, the optional writer coverage cap, same-scope/OperationRun truth, identity hard-stop behavior, empty collection behavior, and proof that no UI, jobs, routes, migrations, Graph fallback, customer output, restore/certification/compare/render path, mini-platform, or `tenant_id` ownership truth was introduced. diff --git a/specs/434-exchange-evidence-capture-adapter-content-only-guard/plan.md b/specs/434-exchange-evidence-capture-adapter-content-only-guard/plan.md new file mode 100644 index 00000000..0345e78c --- /dev/null +++ b/specs/434-exchange-evidence-capture-adapter-content-only-guard/plan.md @@ -0,0 +1,264 @@ +# Implementation Plan: Exchange Evidence Capture Adapter and Content-Only Guard + +**Branch**: `434-exchange-evidence-capture-adapter-content-only-guard` | **Date**: 2026-07-08 | **Spec**: `specs/434-exchange-evidence-capture-adapter-content-only-guard/spec.md` +**Input**: Feature specification from `specs/434-exchange-evidence-capture-adapter-content-only-guard/spec.md` + +## Summary + +Build the internal Exchange PowerShell evidence capture adapter and guard layer that lets TenantPilot safely bridge verified runner output into existing Coverage v2 evidence storage without promoting beyond `content_backed`. The plan reuses `tenant_configuration.capture`, requires Spec 433 readiness, `exchange_powershell_invoke` provider capability support, and Spec 432 runner safety, blocks unsafe identity before writer append, keeps empty collections as zero-item execution outcomes only, preserves generic Graph capture, and adds no UI, migration, trigger, customer output, restore, compare/render, certification, or `tenant_id` ownership truth. + +## Technical Context + +**Language/Version**: PHP 8.4, Laravel 12 +**Primary Dependencies**: Laravel services/jobs/models, existing TenantConfiguration services, `ProviderCapabilityEvaluator`, `ExchangePowerShellInvocationReadinessEvaluator`, `ExchangePowerShellCommandRunner`, `ExchangePowerShellInvocationResult`, OperationRun support +**Storage**: Existing PostgreSQL Coverage v2 tables only: `tenant_configuration_resources` and `tenant_configuration_resource_evidence`; no new table planned +**Testing**: Pest 4 unit/feature tests +**Validation Lanes**: fast-feedback/confidence focused tests; selected regressions; browser N/A +**Target Platform**: Laravel monolith under `apps/platform` +**Project Type**: Web application backend service slice +**Performance Goals**: Adapter uses fake/test runner output and existing resource/evidence writes; no live provider performance target +**Constraints**: No live Exchange calls in tests, no raw payload in OperationRun context/logs, summary counts flat numeric only, same-scope workspace/managed-environment/provider-connection enforcement, no new `CaptureOutcome` enum value without amending the spec +**Scale/Scope**: Three Exchange target types only: `transportRule`, `remoteDomain`, `inboundConnector` + +## 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 internal service/evidence guard. +- **Native vs custom classification summary**: N/A. +- **Shared-family relevance**: evidence/OperationRun/provider internals only; no rendered shared interaction family. +- **State layers in scope**: none for UI. +- **Audience modes in scope**: N/A. +- **Decision/diagnostic/raw hierarchy plan**: no rendered hierarchy; raw/support data remains internal. +- **Raw/support gating plan**: raw payloads remain only in existing evidence storage and must not enter OperationRun context, logs, notifications, or customer output. +- **One-primary-action / duplicate-truth control**: N/A. +- **Handling modes by drift class or surface**: hard-stop if implementation needs UI, route, navigation, trigger, report, customer output, or browser proof. +- **Repository-signal treatment**: report-only for static no-UI/no-route/no-trigger proof; hard-stop if violated. +- **Special surface test profiles**: N/A. +- **Required tests or manual smoke**: static/feature guard tests; browser `N/A - no rendered UI surface changed`. +- **Exception path and spread control**: none. +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage: `N/A - no rendered UI surface changed`. +- **UI/Productization coverage decision**: No UI surface impact. +- **Coverage artifacts to update**: none. +- **No-impact rationale**: Adapter/guard infrastructure only; no reachable UI surface changes. +- **Navigation / Filament provider-panel handling**: no panel/provider registration or navigation change. +- **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 addition only; no compatibility exception. +- **Page archetype and surface budget plan**: N/A - no page changed. +- **Technical Annex and deep-link demotion plan**: OperationRun, raw evidence, source keys, command metadata, payloads, stdout/stderr, provider diagnostics, and logs remain internal and not default-visible. +- **Canonical status vocabulary plan**: N/A for UI; internal failure/outcome labels remain backend-only. +- **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/434-exchange-evidence-capture-adapter-content-only-guard/implementation-report.md`. + +## Filament / Livewire / Deployment Posture + +- **Livewire v4 compliance**: unchanged repo baseline; no Livewire code planned. +- **Panel provider registration location**: no Filament panel provider change; Laravel panel providers remain under `apps/platform/bootstrap/providers.php`. +- **Global search posture**: no resource/global search behavior changed. +- **Destructive/high-impact action posture**: no UI action or start surface added. +- **Asset strategy**: no assets; `filament:assets` not required for this slice. +- **Testing plan**: no pages/widgets/relation managers/actions/browser smoke. Backend unit/feature and static guard tests only. +- **Deployment impact**: no env vars, migrations, queues, scheduler, storage, assets, or browser build planned. If implementation discovers one is required, stop and amend the spec/plan first. + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes, backend shared systems. +- **Systems touched**: `GenericContentEvidenceCaptureService`, `CoverageSourceContractResolver`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `ExchangePowerShellCommandContracts`, `ExchangePowerShellProductionRunner`, `ExchangePowerShellInvocationReadinessEvaluator`, `OperationSummaryKeys`, `SummaryCountsNormalizer`, `tenantpilot` Exchange config, and related tests. +- **Shared abstractions reused**: Coverage v2 models/writer path, `tenant_configuration.capture`, Spec 430 command contracts, Spec 432 runner output guard, Spec 433 readiness evaluator, provider capability/readiness gates, OperationRun summary count normalizer. +- **New abstraction introduced? why?**: likely a narrow Exchange evidence capture adapter and guard classes. They are justified because Exchange runner output is not Graph-backed and must not be forced through Graph capture or manual evidence writes. +- **Why the existing abstraction was sufficient or insufficient**: Existing Coverage v2 storage and operation truth are sufficient. Existing generic capture is Graph-oriented and insufficient for Exchange PowerShell runner output. Existing writer can over-promote, so the Exchange path needs a content-only maximum. +- **Bounded deviation / spread control**: Adapter is provider-owned and internal only. It must not become a generalized provider capture framework, UI vocabulary, persisted taxonomy, customer-output path, or Product Surface runtime framework. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: no rendered UX. It touches internal operation type/context requirements. +- **Central contract reused**: existing `tenant_configuration.capture` OperationRun lifecycle and summary-count conventions. +- **Delegated UX behaviors**: N/A. +- **Surface-owned behavior kept local**: none. +- **Queued DB-notification policy**: N/A; no queued notification change. +- **Terminal notification path**: unchanged central lifecycle behavior for capture operations. +- **Exception path**: none. New operation type, start UX, queued notification, or run link behavior requires spec amendment/split. + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes. +- **Provider-owned seams**: Exchange command contracts, Exchange runner output, Exchange response shape, Exchange-specific identity/redaction handoff. +- **Platform-core seams**: workspace/managed-environment/provider ownership, OperationRun truth, Coverage v2 evidence storage, identity safety, coverage level, claim boundary, customer-proof boundary. +- **Neutral platform terms / contracts preserved**: provider connection, operation, capture outcome, evidence state, coverage level, identity state, claim state. +- **Retained provider-specific semantics and why**: command names and source surface metadata are required to execute and audit the verified Exchange contracts safely. +- **Bounded extraction or follow-up path**: document-in-feature for adapter details; follow-up spec for target promotion and durable collection proof. + +## Constitution Check + +*GATE: Must pass before implementation. Re-check after implementation.* + +- Inventory-first: PASS. This slice creates only internal evidence capture infrastructure; later target promotion remains separate. +- Read/write separation: PASS. No Microsoft tenant writes; no restore/remediation. +- Graph contract path: PASS WITH NOTE. Graph capture remains via existing Graph contracts; Exchange must not fake Graph endpoints or use Graph provider gateway. +- Deterministic capabilities: PASS. Spec 433 readiness and provider capability checks remain required. +- RBAC-UX: PASS. No new UI/action; trusted internal flow must preserve existing authorization/scope. +- Workspace isolation: PASS. Same workspace, managed environment, and provider connection are required before capture/append. +- Tenant isolation: PASS. No `tenant_id`; environment-owned evidence stays workspace + managed-environment scoped. +- Run observability: PASS. Reuses `tenant_configuration.capture`; no new OperationRun type. +- OperationRun start UX: N/A for rendered UX. No local start UX. +- Ops-UX 3-surface feedback: N/A for new UX. Existing capture lifecycle unchanged. +- Ops-UX lifecycle: PASS. Runtime must use service-owned status/outcome transitions. +- Ops-UX summary counts: PASS. Existing flat numeric keys only. +- Ops-UX guards: PASS. Add or extend tests/static proof for summary/context safety. +- Data minimization: PASS. Raw output forbidden outside existing evidence storage. +- Test governance: PASS. Unit/Feature lanes only; browser N/A. +- Proportionality: PASS. New adapter/guards are security and evidence correctness infrastructure. +- No premature abstraction: PASS WITH CONDITION. Keep adapter local and narrow; no broad provider framework. +- Persisted truth: PASS. No new persisted entity/table/artifact planned. +- Behavioral state: PASS WITH CONDITION. New outcome/failure labels must change blocking/validation behavior and stay internal. +- UI semantics: PASS. No UI semantics introduced. +- Shared pattern first: PASS. Reuse Coverage v2, OperationRun, readiness, and summary patterns. +- Provider boundary: PASS. Exchange semantics remain provider-owned. +- V1 explicitness/few layers: PASS. Explicit local adapter/guards, no platform engine. +- Spec discipline / bloat check: PASS. Proportionality review is in `spec.md`. +- Product Surface Contract: PASS. No rendered UI surface changed; browser N/A. +- Temporary TCM/Coverage v2 cutover guard: PASS WITH CONDITION. No customer/operator proof, no legacy adapter, no fallback reader, no `tenant_id`, no raw evidence default display. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit and Feature for backend service/evidence behavior. +- **Affected validation lanes**: fast-feedback/confidence; browser N/A. +- **Why this lane mix is the narrowest sufficient proof**: Adapter/guard behavior is service/database behavior; no UI, browser, migration, or customer output. +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec434 --compact` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec433|Spec432|Spec431|Spec430|Spec415|Spec417|Spec419|Spec420|Spec426|Spec427|ProviderCapabilityRegistryTest|ProviderCapabilityEvaluationTest' --compact` + - `cd apps/platform && ./vendor/bin/pint --dirty --test` + - `git diff --check` +- **Fixture / helper / factory / seed / context cost risks**: keep fake runner output and adapter fixtures local; no broad workspace/provider default helpers. Unsafe-identity writer non-reachability is proven by code order plus zero resource/evidence assertions, not a literal writer spy. +- **Expensive defaults or shared helper growth introduced?**: no. +- **Heavy-family additions, promotions, or visibility changes**: none. +- **Surface-class relief / special coverage rule**: browser `N/A - no rendered UI surface changed`. +- **Closing validation and reviewer handoff**: Re-run Spec 434 focus and selected regressions; verify no UI/migration/trigger/customer output in diff. +- **Budget / baseline / trend follow-up**: none expected. +- **Review-stop questions**: stop if tests need live provider, shell, browser, broad fixture defaults, or schema. +- **Escalation path**: reject-or-split if scope expands beyond adapter/guard infrastructure. +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage. +- **Why no dedicated follow-up spec is needed**: This spec is the dedicated adapter/guard prerequisite. Target promotion is a separate follow-up. + +## Project Structure + +### Documentation (this feature) + +```text +specs/434-exchange-evidence-capture-adapter-content-only-guard/ +├── spec.md +├── plan.md +├── tasks.md +└── checklists/ + └── requirements.md +``` + +### Source Code (repository root) + +Likely existing runtime files: + +```text +apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php +apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php +apps/platform/app/Services/TenantConfiguration/CoverageSourceContractDecision.php +apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php +apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php +apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php +apps/platform/app/Services/TenantConfiguration/ExchangePowerShellProductionRunner.php +apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationReadinessEvaluator.php +apps/platform/app/Support/OpsUx/OperationSummaryKeys.php +apps/platform/app/Support/OpsUx/SummaryCountsNormalizer.php +apps/platform/config/tenantpilot.php +``` + +Likely new runtime files or repo-canonical equivalents: + +```text +apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php +apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCaptureEligibilityGate.php +apps/platform/app/Services/TenantConfiguration/ExchangePowerShellContentOnlyEvidenceGuard.php +apps/platform/app/Services/TenantConfiguration/ExchangePowerShellIdentityEvidenceGate.php +``` + +Implemented tests: + +```text +apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php +``` + +**Structure Decision**: Backend TenantConfiguration service slice. No UI, routes, migrations, jobs, schedules, listeners, or customer-output paths. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +| --- | --- | --- | +| New internal Exchange adapter/guard classes | Security/evidence correctness boundary between Exchange runner output and Coverage v2 writer | Reusing Graph capture would require fake endpoints/outcomes; manual evidence writes would bypass existing scope/redaction/storage rules | +| Limited internal outcome/failure labels | Tests and implementation report need distinct safe blockers for prerequisite, identity, empty collection, over-promotion, and sanitized local failure behavior | Generic failed/unknown outcomes would hide whether evidence was safely blocked before append | + +## Phase 0: Preflight and Scope Lock + +- Capture branch, HEAD, dirty state, and active spec path. +- Re-check Specs 429-433 as read-only context. +- Confirm Spec 433 PASS WITH CONDITIONS has no merge-blocking adapter safety issue. +- Confirm no existing `specs/434-*` package was overwritten. +- Confirm no UI/routes/jobs/schedules/listeners/migrations/customer output/tenant-id scope. + +## Phase 1: Adapter Boundary and Eligibility + +- Add Exchange adapter or repo-canonical equivalent. +- Accept only `transportRule`, `remoteDomain`, and `inboundConnector`. +- Require `tenant_configuration.capture`, same workspace/environment/provider scope, Spec 433 readiness, `ProviderCapabilityEvaluator` `exchange_powershell_invoke` status `Supported`, Spec 432 accepted `ExchangePowerShellInvocationResult`, and Spec 430 verified command contract. +- Add Exchange eligibility representation without fake Graph endpoint or fake captured outcome. +- Prove no `ProviderGateway` / generic Graph list route is used for Exchange. +- Preserve existing `CaptureOutcome` values for persisted evidence outcomes and keep adapter-local outcome/failure codes limited to the Spec 434 allowlist. + +## Phase 2: Identity Hard-Stop + +- Evaluate identity before evidence writer append. +- Block identity conflict, missing stable external ID, unsupported identity, duplicate identity, and display-name-only identity. +- Ensure blocked identity returns sanitized outcome and creates no evidence row. +- Prove writer non-reachability through adapter code order plus zero resource/evidence database assertions. + +## Phase 3: Content-Only Guard + +- Add a max coverage level guard for the Exchange adapter path. +- Prevent `CoverageEvidenceWriter` or adjacent code from promoting Exchange adapter evidence beyond `content_backed`. +- Prove no comparable/renderable/certified/restore-ready/customer-claimable state appears. + +## Phase 4: Empty Collection and Redaction + +- Treat empty Exchange runner collections as zero-item execution outcomes only. +- Do not create fake resource/evidence rows for empty collections. +- Keep raw stdout/stderr, payloads, serialized objects, credential material, tokens, and provider response bodies out of OperationRun context/logs/summary counts. +- Use existing evidence storage only for non-empty guarded fixture evidence. +- Map blocked/failure paths through sanitized ProviderReasonCodes or `ext.*` reason codes and the Spec 434 adapter-local code allowlist only. + +## Phase 5: Regression and Static Guards + +- Run Spec 434 focus tests. +- Run selected regressions for Specs 415, 417, 419, 420, 426, 427, 430, 431, 432, 433, provider capability registry/evaluator, and generic capture. +- Add static/no-product-surface tests proving no route, Filament, Livewire, resource view, job, schedule, listener, migration, customer-output, or `tenant_id` path is introduced. + +## Phase 6: Implementation Report and Close-Out + +- Create `implementation-report.md`. +- Record candidate gate, branch/HEAD, dirty state, files changed, prerequisite proof, OperationRun decision, adapter proof, provider-capability proof, accepted runner-result proof, outcome/failure-code proof, identity hard-stop proof, content-only guard proof, empty collection proof, generic capture regression proof, redaction proof, no-promotion proof, no-product-surface proof, tests run, browser N/A, Laravel/PHP/PostgreSQL/Pest compatibility notes, deployment impact, and deferred work. + +## Risk Controls + +- Stop if implementation requires new persistence for empty collections. +- Stop if implementation requires a new OperationRun type or start surface. +- Stop if implementation requires UI/product surface or customer output. +- Stop if live Exchange provider calls are needed to validate the slice. +- Stop if Graph capture must be modified in a way that changes existing Graph behavior. + +## Spec Readiness Result + +PASS for preparation. Implementation remains a separate step and must follow `tasks.md`. diff --git a/specs/434-exchange-evidence-capture-adapter-content-only-guard/spec.md b/specs/434-exchange-evidence-capture-adapter-content-only-guard/spec.md new file mode 100644 index 00000000..522c094b --- /dev/null +++ b/specs/434-exchange-evidence-capture-adapter-content-only-guard/spec.md @@ -0,0 +1,368 @@ +# Feature Specification: Exchange Evidence Capture Adapter and Content-Only Guard + +**Feature Branch**: `434-exchange-evidence-capture-adapter-content-only-guard` +**Created**: 2026-07-08 +**Status**: Draft +**Input**: User-provided draft `Spec 434 - Exchange Evidence Capture Adapter & Content-Only Guard`. + +## Preparation Selection + +- **Selected candidate**: Spec 434 - Exchange Evidence Capture Adapter and Content-Only Guard. +- **Source location**: User-provided attachment `/Users/ahmeddarrazi/.codex/attachments/0d15249e-465b-40bd-b2c6-307e6c0f895a/pasted-text.txt`. +- **Why selected**: The user supplied a direct P0 draft and current repo truth has no existing `specs/434-*` package. Specs 429-433 already establish the Exchange PowerShell sequence, and Spec 433 explicitly leaves content-backed Exchange evidence capture/promotion to Spec 434 or later. The draft correctly narrows the next slice from full evidence promotion to the adapter and guard infrastructure required before promotion can be honest. +- **Roadmap relationship**: Continues the Coverage v2 / Microsoft 365 / Exchange sequence after Specs 429-433. It prepares Spec 435-style target evidence promotion by creating a safe Exchange capture adapter path and preventing unsafe evidence append or over-promotion. +- **Close alternatives deferred**: The older "Exchange Content-Backed Evidence Promotion Slice 1" is deferred because current repo truth still needs adapter, identity, and content-only guard work first. Exchange comparable/renderable promotion, certification, restore, customer output, Teams PowerShell support, broader Exchange target types, and M365 claim guard remain follow-up candidates. +- **Completed-spec guardrail result**: Specs 429-433 are completed or implementation-closed context only and were not modified. Spec 433 records PASS WITH CONDITIONS; the condition concerns an existing broader provider-readiness card, while Exchange-specific capability slots and safety checks are canonical/redacted. That condition is not merge-blocking for this adapter-only prep, but implementation must carry it forward and must not create new UI or customer claims. No existing Spec 434 package or branch was found. +- **Smallest viable implementation slice**: Build an internal trusted Exchange PowerShell capture adapter boundary that accepts only `transportRule`, `remoteDomain`, and `inboundConnector`; reuses `tenant_configuration.capture`; consumes Spec 433 readiness, Spec 432 runner boundary, and Spec 430 verified command contracts; hard-stops unsafe identity before evidence append; limits the Exchange path to `content_backed`; records safe zero-item outcomes without fake evidence; and proves generic Graph capture remains unchanged. +- **Feature description fed into Spec Kit**: Prepare an internal Exchange PowerShell evidence capture adapter and content-only evidence guard for `transportRule`, `remoteDomain`, and `inboundConnector`, reusing `tenant_configuration.capture`, requiring existing readiness and runner gates, blocking unsafe identity before evidence append, preventing promotion beyond `content_backed`, keeping empty collections from becoming fake evidence, preserving sanitized OperationRun context, and adding no UI, routes, jobs, schedules, listeners, migrations, customer claims, restore, compare/render, certification, or `tenant_id` ownership truth. + +## Activated Skills and Gates + +- `spec-kit-next-best-prep`: selected because this is a preparation-only Spec Kit request. +- TenantPilot repo skills used as planning gates: spec readiness, workspace-scope safety, RBAC/action safety, OperationRun truth, evidence-anchor contract, provider freshness semantics, Product Surface gate, customer-output gate, and temporary TCM cutover guard. +- Hard-gate stop conditions at preparation time: none. Runtime implementation must stop if it introduces unscoped evidence, raw payload leakage, direct OperationRun status/outcome writes, unsafe provider readiness, UI/customer output, `tenant_id` ownership truth, legacy shims, fallback readers, or over-promotion beyond `content_backed`. + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: TenantPilot has verified Exchange PowerShell contracts, invocation gates, production runner safety, and credential/permission readiness, but it does not yet have a safe evidence capture adapter path from Exchange runner output into Coverage v2 evidence storage. +- **Today's failure**: A later implementation could treat Exchange command contracts as Graph contracts, append evidence after unsafe identity, create fake empty evidence, leak raw PowerShell output into OperationRun context, or let `CoverageEvidenceWriter` promote Exchange evidence to renderable/comparable/certified/customer-ready states before target-specific promotion is proven. +- **User-visible improvement**: Operators and reviewers get an honest internal safety boundary: Exchange capture infrastructure may produce only guarded internal content-backed fixture evidence, or it blocks with safe reasons. It does not create customer claims, UI readiness, compare/render support, restore readiness, or certification. +- **Smallest enterprise-capable version**: Add a trusted internal adapter and narrowly scoped guards around existing Coverage v2 resource/evidence paths. Do not add a new OperationRun type, schema, customer output, product surface, trigger, or Exchange mini-platform. +- **Explicit non-goals**: No full content-backed promotion for `transportRule`, `remoteDomain`, or `inboundConnector`; no live Exchange provider calls in tests; no arbitrary PowerShell; no compare/render/certification/restore/customer claim; no UI/routes/navigation/Filament/Livewire/assets/global search; no jobs/schedules/listeners/triggers; no Teams; no Exchange Admin API; no broader Exchange targets; no Exchange-specific evidence tables; no `tenant_id`; no legacy shim; no fallback reader. +- **Permanent complexity imported**: New internal service/guard classes or repo-canonical equivalents, focused unit/feature tests, and possibly one provider-neutral eligibility representation. No new persisted entity, table, customer-visible vocabulary, Product Surface runtime framework, or OperationRun type. +- **Why now**: Spec 433 closed the credential/permission readiness prerequisite. The next safe step is to bridge Exchange runner output into Coverage v2 without overclaiming evidence, identity, product readiness, or customer proof. +- **Why not local**: A local patch inside the generic Graph capture service or writer would either make Exchange look Graph-shaped or hide security-critical guard logic inside an existing path. The adapter boundary must be explicit and testable so future target promotion can stay small and safe. +- **Approval class**: Core Enterprise. +- **Red flags triggered**: New abstraction and limited outcome vocabulary. Defense: the abstraction is security/evidence-boundary critical and replaces an unsafe implicit bridge; outcome labels change blocking behavior and test proof rather than adding presentation taxonomy. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve as a narrow backend safety prerequisite for later Exchange content-backed evidence promotion. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace + managed-environment + provider-connection scoped internal evidence capture path. +- **Primary Routes**: N/A - no route, page, navigation, panel, action, report, download, browser-rendered surface, or customer surface changes. +- **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-facing action is added. The internal adapter must only run inside trusted application flow after existing capture operation authorization/scope has already established workspace, managed environment, and provider connection entitlement. Any future start surface must be a separate or amended spec. + +For canonical-view specs: + +- **Default filter behavior when tenant-context is active**: N/A - no canonical route or rendered query change. +- **Explicit entitlement checks preventing cross-tenant leakage**: Adapter and writer path must reject mismatched `workspace_id`, `managed_environment_id`, or `provider_connection_id` before capture, identity evaluation, or evidence append. + +## No Legacy / No Backward Compatibility Constraint *(mandatory)* + +TenantPilot is pre-production unless this spec explicitly records a compatibility exception. + +- **Compatibility posture**: canonical addition only. +- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no. +- **Why clean replacement is safe now**: Exchange evidence capture is not shipped as customer-facing proof. Unsafe or unproven states must block rather than be bridged through compatibility behavior. + +## UI Surface Impact *(mandatory - UI-COV-001)* + +Does this spec add, remove, rename, or materially change any reachable UI surface? + +- [x] No UI surface impact +- [ ] Existing page changed +- [ ] New page/route added +- [ ] Navigation changed +- [ ] Filament panel/provider surface changed +- [ ] New modal/drawer/wizard/action added +- [ ] New table/form/state added +- [ ] Customer-facing surface changed +- [ ] Dangerous action changed +- [ ] Status/evidence/review presentation changed +- [ ] Workspace/environment context presentation changed + +No-impact rationale: Spec 434 is backend adapter and guard infrastructure only. It must not edit runtime UI files, route files, Filament resources/pages/widgets, Livewire components, navigation, reports, downloads, customer outputs, or browser-rendered diagnostics. + +## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)* + +N/A - no reachable UI surface impact. Any future UI, readiness status, trigger, customer output, report, Review Pack, or evidence viewer change must amend this spec or use a separate spec and satisfy the Product Surface Contract. + +## Product Surface Impact *(mandatory for UI-affecting specs; otherwise write `N/A - no rendered product surface changed` plus rationale)* + +Reference: `docs/product/standards/product-surface-contract.md`. + +- **Product Surface Contract applies?**: no - no rendered product surface changes. +- **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 for rendered UI. OperationRun context, raw evidence, source keys, command metadata, provider diagnostics, stdout/stderr, payloads, and logs must remain internal and must not become default product content. +- **Canonical status vocabulary**: N/A for UI. +- **Visible complexity impact**: neutral; no visible surface changes. +- **Product Surface exceptions**: none. + +## Browser Verification Plan *(mandatory)* + +- **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 *(mandatory)* + +- **Required?**: no. +- **No-human-sanity rationale**: N/A - no product surface changed. +- **Reviewer questions**: N/A. +- **Planned result location**: N/A. + +## Product Surface Merge Gate Checklist *(mandatory)* + +- [x] No-legacy posture or approved exception recorded. +- [x] Product Surface Impact is completed or `N/A` is justified. +- [x] Browser proof is completed or `N/A - no rendered UI surface changed` is justified. +- [x] Human Product Sanity is completed or not applicable with rationale. +- [x] Product Surface exceptions are documented or `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 *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)* + +- **Cross-cutting feature?**: yes, backend shared evidence/operation/provider infrastructure only. +- **Interaction class(es)**: OperationRun execution truth, Coverage v2 evidence writing, provider readiness, identity evaluation, capture outcome summarization. No operator interaction family or rendered surface changes. +- **Systems touched**: Existing `GenericContentEvidenceCaptureService`, `CoverageSourceContractResolver`, `CoverageResourceUpserter`, `CoverageEvidenceWriter`, `ExchangePowerShellCommandContracts`, `ExchangePowerShellCommandRunner`, `ExchangePowerShellInvocationResult`, `ExchangePowerShellProductionRunner`, `ExchangePowerShellInvocationReadinessEvaluator`, `ProviderCapabilityEvaluator`, `OperationSummaryKeys`, `SummaryCountsNormalizer`, and `tenantpilot` Exchange PowerShell config. +- **Existing pattern(s) to extend**: Coverage v2 resource/evidence storage, `tenant_configuration.capture`, Spec 430 command contract metadata, Spec 432 runner boundary, Spec 433 combined readiness, `exchange_powershell_invoke` provider capability evaluation, `OperationSummaryKeys`, and sanitized OperationRun context conventions. +- **Shared contract / presenter / builder / renderer to reuse**: No UI presenter/renderer. Reuse existing OperationRun service/lifecycle conventions, Coverage v2 evidence models, provider capability/readiness gates, existing `CaptureOutcome` values, and generic capture redaction/normalization helpers where safe. +- **Why the existing shared path is sufficient or insufficient**: Existing Graph capture is sufficient for Graph-backed contracts and must remain unchanged. It is insufficient for Exchange PowerShell runner output because Exchange has no Graph endpoint and requires an explicit provider-owned adapter boundary. +- **Allowed deviation and why**: A provider-owned Exchange adapter and local guard classes are allowed because they protect evidence correctness and avoid Graph-shaped fake contracts. Broad provider frameworks, UI semantics, customer-output frameworks, and Exchange mini-platforms are forbidden. +- **Consistency impact**: Later target promotion must reuse this adapter/guard path instead of adding a second Exchange evidence path. +- **Review focus**: Verify no raw provider payload leaks, no over-promotion, no fake empty evidence, no new operation type, and no generic Graph capture regression. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: no rendered UX. It touches internal OperationRun type/context requirements for the capture path. +- **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 - no toast, run link, artifact link, browser event, queued DB notification, or rendered surface. +- **Local surface-owned behavior that remains**: none. +- **Queued DB-notification policy**: N/A. +- **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, or run link behavior, stop and amend/split the spec. + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: yes. +- **Boundary classification**: mixed. Exchange PowerShell command execution and response handling are provider-owned. Coverage v2 evidence storage, operation truth, identity safety, and customer-claim boundaries are platform-core. +- **Seams affected**: source-contract decision/eligibility, capture adapter routing, identity evaluation before evidence append, evidence writer maximum level, OperationRun context and summary counts, provider readiness and `exchange_powershell_invoke` capability gates. +- **Neutral platform terms preserved or introduced**: workspace, managed environment, provider connection, operation, resource type, source contract, capture outcome, evidence state, coverage level, identity state, claim state. +- **Provider-specific semantics retained and why**: Exchange command names and `exchange_online_powershell_rest` source surface remain provider-owned metadata because they are required to execute verified Exchange commands safely. +- **Why this does not deepen provider coupling accidentally**: The adapter is bounded to Exchange and must not alter Graph capture, platform-core ownership, generic evidence semantics, product UI vocabulary, or customer output. +- **Follow-up path**: document-in-feature for bounded Exchange adapter details; follow-up spec for target-specific evidence promotion and any provider-neutral collection-proof model. + +## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* + +N/A - no operator-facing surface change. + +## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change. + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change. + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change. + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +N/A - no operator-facing surface change. + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no. Execution truth remains `OperationRun`; evidence truth remains existing Coverage v2 evidence rows. +- **New persisted entity/table/artifact?**: no runtime persistence beyond existing resource/evidence rows. Spec Kit artifacts only. +- **New abstraction?**: yes, a narrow Exchange evidence capture adapter/guard boundary or repo-canonical equivalents. +- **New enum/state/reason family?**: possibly limited internal outcome/failure labels; no customer-visible status family. +- **New cross-domain UI framework/taxonomy?**: no. +- **Current operator problem**: Preventing false Exchange evidence readiness, customer claims, and unsafe evidence append before Exchange target evidence is proven. +- **Existing structure is insufficient because**: `GenericContentEvidenceCaptureService` is Graph-oriented, `CoverageResourceUpserter` records identity state without hard-stopping all append paths, and `CoverageEvidenceWriter` can promote beyond `content_backed`. +- **Narrowest correct implementation**: Add an Exchange-specific internal adapter and local guards around existing Coverage v2 paths. Do not add a generalized provider capture framework, new operation type, schema, UI, or customer-output layer. +- **Ownership cost**: Focused service/test ownership in TenantConfiguration. Future target promotion must respect the adapter contract and maintain regression tests. +- **Alternative intentionally rejected**: Reusing Graph capture with fake endpoints, manually writing evidence rows, adding durable empty-collection schema now, or modifying the writer globally without an Exchange-specific maximum-level guard. +- **Release truth**: Current-release safety prerequisite for future Exchange content-backed evidence promotion. + +### Compatibility posture + +This feature assumes a pre-production environment. Backward compatibility, legacy aliases, migration shims, historical fixtures, fallback readers, and compatibility-specific tests are out of scope. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit and Feature. Unit tests for adapter eligibility, identity hard-stop, content-only guard, empty-collection policy, and sanitized result mapping. Feature tests for database/evidence no-promotion, scope enforcement, OperationRun context, and no-product-surface guard scans. +- **Validation lane(s)**: fast-feedback/confidence focused tests; selected regression tests for Specs 415, 417, 419, 420, 426, 427, 430, 431, 432, 433, and provider capability registry. Browser lane is N/A. +- **Why this classification and these lanes are sufficient**: The change is backend service/evidence behavior. No rendered UI, browser interaction, migration, queue trigger, or customer output is introduced. +- **New or expanded test families**: focused Spec 434 tests under existing TenantConfiguration/Coverage v2 families. +- **Fixture / helper cost impact**: Exchange adapter fixtures and fake runner/evidence-writer spies should remain local and cheap. No live provider setup, no browser state, no heavy global provider/workspace defaults. +- **Heavy-family visibility / justification**: none planned. +- **Special surface test profile**: N/A - no rendered UI surface changed. +- **Standard-native relief or required special coverage**: N/A - no Filament/Livewire surface. +- **Reviewer handoff**: Verify lane fit, no browser requirement, no hidden heavy fixture defaults, no application start surface, and no raw output in run context/log assertions. +- **Budget / baseline / trend impact**: expected neutral. +- **Escalation needed**: none if scope remains adapter/guard only; reject-or-split if implementation needs UI, durable empty collection proof, new operation type, schema, customer output, or broad provider framework. +- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage: `N/A - no rendered UI surface changed`. +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec434 --compact` + - `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec433|Spec432|Spec431|Spec430|Spec415|Spec417|Spec419|Spec420|Spec426|Spec427|ProviderCapabilityRegistryTest|ProviderCapabilityEvaluationTest' --compact` + - `cd apps/platform && ./vendor/bin/pint --dirty --test` + - `git diff --check` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Safe Exchange Capture Adapter Boundary (Priority: P1) + +A platform engineer needs a trusted internal Exchange adapter that can receive verified runner output for the scoped Exchange targets without routing through Graph capture or adding a customer-facing surface. + +**Why this priority**: This is the prerequisite for any later Exchange content-backed evidence promotion. + +**Independent Test**: Unit and feature tests prove only `transportRule`, `remoteDomain`, and `inboundConnector` are accepted, `tenant_configuration.capture` is required, same-scope provider connection is required, and Graph provider gateway is not called. + +**Acceptance Scenarios**: + +1. **Given** a scoped capture run and verified Exchange command contract, **When** the adapter receives a successful structured collection, **Then** it evaluates gates before calling the Coverage v2 writer path. +2. **Given** an unsupported canonical type or wrong OperationRun type, **When** capture is attempted, **Then** no evidence row is created and a safe blocked outcome is returned. +3. **Given** generic Graph capture tests, **When** Spec 434 is implemented, **Then** the existing Graph path still captures Graph-backed contracts unchanged. + +--- + +### User Story 2 - Identity Unsafe Evidence Cannot Be Appended (Priority: P1) + +A reviewer needs proof that Exchange evidence cannot be appended when identity is unstable, conflicting, missing, unsupported, or display-name-only. + +**Why this priority**: Evidence without stable identity can create false governance proof and wrong customer claims. + +**Independent Test**: Tests spy on the evidence writer and database to prove unsafe identity blocks before append and produces no resource evidence row. + +**Acceptance Scenarios**: + +1. **Given** runner output with missing stable identity, **When** the adapter evaluates the item, **Then** the writer is not called and no evidence row exists. +2. **Given** duplicate or conflicting identity, **When** capture is attempted, **Then** the adapter returns an identity blocker and stores no payload in OperationRun context. +3. **Given** a later target-specific rule is needed for derived identity, **When** implementation cannot prove it in Spec 434, **Then** it remains blocked and is deferred to Spec 435. + +--- + +### User Story 3 - Exchange Capture Cannot Over-Promote Coverage (Priority: P1) + +A reviewer needs evidence that the Exchange path can only create `content_backed` internal evidence and cannot become comparable, renderable, certified, restore-ready, or customer-claimable in this slice. + +**Why this priority**: Current writer behavior can promote to renderable when summary builders match; this spec must prevent accidental overclaiming through the Exchange adapter path. + +**Independent Test**: Tests prove the Exchange adapter path applies a `content_backed` maximum and persisted evidence/resource state cannot exceed that maximum. + +**Acceptance Scenarios**: + +1. **Given** a payload shape that a summary builder could render, **When** it is written through the Exchange adapter path, **Then** stored coverage remains `content_backed`. +2. **Given** adapter fixture evidence, **When** claim state is evaluated, **Then** it remains internal/not customer-claimable for this slice. +3. **Given** compare/render/certification/restore/customer output code paths, **When** Spec 434 is implemented, **Then** none are added or activated. + +--- + +### User Story 4 - Empty Collections Are Honest Zero-Item Outcomes (Priority: P1) + +A platform reviewer needs empty Exchange collections to be represented as zero-item execution outcomes, not fake resource/evidence rows. + +**Why this priority**: Empty collections can be misread as durable evidence unless the product explicitly models collection-level proof, which is out of scope here. + +**Independent Test**: Empty collection tests prove safe summary counts, no resource row, no evidence row, and deferred durable collection proof. + +**Acceptance Scenarios**: + +1. **Given** a successful Exchange runner result with zero items, **When** the adapter handles it, **Then** OperationRun summary counts record zero items and no resource/evidence row is created. +2. **Given** no durable collection proof model exists, **When** a reviewer checks the implementation report, **Then** it states collection-level empty proof is deferred. + +## Functional Requirements + +- **FR-434-001**: The implementation MUST provide an internal trusted Exchange evidence capture adapter or repo-canonical equivalent. +- **FR-434-002**: The adapter MUST accept only `transportRule`, `remoteDomain`, and `inboundConnector` for this slice. +- **FR-434-003**: The adapter MUST require an existing `tenant_configuration.capture` OperationRun and MUST NOT add a new OperationRun type. +- **FR-434-004**: The adapter MUST reject mismatched workspace, managed environment, or provider connection scope before evidence append. +- **FR-434-005**: The adapter MUST require Spec 433 combined credential and permission readiness and MUST treat any `ProviderCapabilityEvaluator` result for `exchange_powershell_invoke` other than `Supported` as a sanitized blocker before capture proceeds. +- **FR-434-006**: The adapter MUST require the Spec 432 runner boundary and verified structured output before capture proceeds. Accepted runner input is limited to `ExchangePowerShellCommandRunner::run(ExchangePowerShellCommandContract, ExchangePowerShellInvocationContext): ExchangePowerShellInvocationResult` where `successful` is true, `collection` is a list, and sanitized context identifies `output_state` as `structured_collection` or `empty_collection`. +- **FR-434-007**: The adapter MUST require Spec 430 verified command contracts and MUST NOT fake Graph endpoints or captured outcomes. +- **FR-434-008**: Exchange runner output MUST NOT route through `ProviderGateway` or the generic Graph list path. +- **FR-434-009**: Identity MUST be evaluated before evidence append, and unsafe identity MUST prevent writer invocation. +- **FR-434-010**: Identity conflict, missing stable external ID, unsupported identity, duplicate identity, and display-name-only identity MUST block by default unless an existing repo-approved rule explicitly permits the case. +- **FR-434-011**: Evidence written through the Exchange adapter path MUST be capped at `content_backed`. +- **FR-434-012**: The implementation MUST prevent comparable, renderable, certified, restore-ready, and customer-claimable promotion through the Spec 434 path. +- **FR-434-013**: Empty collections MUST create no fake resource row and no fake evidence row. +- **FR-434-014**: Empty collections MAY update safe flat numeric summary counts using existing `OperationSummaryKeys`. +- **FR-434-015**: OperationRun context, messages, summary counts, logs, and notifications MUST NOT include raw payloads, raw stdout/stderr, serialized Exchange objects, credential material, tokens, or provider response bodies. +- **FR-434-016**: Raw and normalized payloads, if persisted for non-empty fixture evidence, MUST go only through existing Coverage v2 evidence storage and redaction policy. +- **FR-434-017**: Generic Graph content capture behavior MUST remain unchanged and covered by regression tests. +- **FR-434-018**: No migration, Exchange-specific evidence table, `tenant_id`, legacy shim, fallback reader, dual write, route, UI, job, schedule, listener, asset, global search behavior, report, Review Pack, PDF output, restore flow, or customer claim may be added by this spec. +- **FR-434-019**: Implementation report MUST record the OperationRun decision, adapter boundary proof, provider-capability proof, accepted runner-result proof, identity hard-stop proof, content-only guard proof, empty collection proof, generic capture regression proof, no-promotion proof, no-product-surface proof, no-tenant-id proof, tests run, and deferred work. +- **FR-434-020**: Persisted evidence capture outcome MUST reuse existing `CaptureOutcome` values only. `captured` is allowed only for accepted non-empty content-backed fixture evidence; `capture_blocked_missing_contract`, `capture_blocked_permission`, `capture_blocked_unsupported`, and `capture_failed` are the only allowed blocked/failure persisted outcomes for this path. Adding a new `CaptureOutcome` enum value requires amending this spec. +- **FR-434-021**: Adapter-local outcome/failure codes, if introduced, MUST be limited to `exchange_capture_prerequisite_blocked`, `exchange_capture_identity_blocked`, `exchange_capture_content_level_blocked`, `exchange_capture_empty_collection`, and `exchange_capture_sanitized_failure`. These codes MUST stay internal, sanitized, and behavior-backed: prerequisite blockers prevent writer invocation, identity blockers prevent writer invocation, content-level blockers prevent over-promotion, empty collection creates only safe zero-item summary counts, and sanitized failure fails closed without raw provider data. + +## Non-Functional Requirements + +- **NFR-434-001**: Adapter behavior must be deterministic and testable with fake runner output; no live Microsoft/Exchange calls are required for validation. +- **NFR-434-002**: All scope and readiness failures must fail closed with sanitized reason/failure codes. +- **NFR-434-003**: Summary counts must remain flat numeric values from the existing allowed key set. +- **NFR-434-004**: Test fixtures must stay local to Spec 434 and must not introduce heavy shared defaults or browser dependencies. +- **NFR-434-005**: The implementation must remain compatible with Laravel 12, Filament v5, Livewire v4, PHP 8.4, PostgreSQL, and Pest 4 repo conventions. + +## Out of Scope + +- Full content-backed evidence promotion for `transportRule`, `remoteDomain`, or `inboundConnector`. +- Target-specific normalization completeness and durable payload hash finalization for production evidence. +- Durable provider-neutral collection-level empty proof. +- Exchange comparable/renderable promotion. +- Exchange certification, restore readiness, customer output, Review Pack/PDF/report output, or customer-safe claims. +- Live Exchange provider calls in tests or arbitrary PowerShell execution. +- Teams, Exchange Admin API, outbound connectors, accepted domains, organization config, mailbox plans, sharing policies, Security & Compliance targets, or broader M365 coverage. +- UI, routes, navigation, Filament/Livewire components, assets, global search, jobs, schedules, listeners, or external triggers. + +## Acceptance Criteria + +- **AC-434-001**: A safe internal Exchange capture adapter boundary exists and requires `tenant_configuration.capture`. +- **AC-434-002**: Unsupported target types, wrong OperationRun type, wrong scope, missing readiness, missing runner boundary, and missing verified contract all block without evidence rows. +- **AC-434-003**: Unsafe identity prevents evidence writer invocation and creates no evidence row. +- **AC-434-004**: Evidence written through the adapter path cannot exceed `content_backed`. +- **AC-434-005**: Empty collection results create zero-item summary outcomes only, with no fake resource/evidence rows. +- **AC-434-006**: OperationRun context and logs remain sanitized. +- **AC-434-007**: Generic Graph capture regressions pass and prove the existing Graph capture path is unchanged. +- **AC-434-008**: No UI/product surface/browser path changes exist; browser proof is `N/A - no rendered UI surface changed`. +- **AC-434-009**: No migration, no Exchange-specific evidence table, no `tenant_id`, no mini-platform, no fallback reader, and no legacy shim are introduced. + +## Success Criteria + +- **SC-434-001**: Later Exchange target evidence promotion can reuse the adapter/guard path without redefining OperationRun, identity hard-stop, content-only guard, empty collection policy, or generic Graph separation. +- **SC-434-002**: Reviewers can determine from tests and implementation report that Spec 434 did not create customer-visible Exchange evidence claims. +- **SC-434-003**: Focused Spec 434 tests and selected regression tests pass without browser or heavy-governance expansion. + +## Risks + +- **R-434-001**: A too-generic adapter framework could violate proportionality and spread provider-specific semantics into platform core. Mitigation: keep adapter Exchange-owned and narrow. +- **R-434-002**: Modifying `CoverageEvidenceWriter` globally could regress Graph-backed capture. Mitigation: use a bounded max-level guard and run generic capture regressions. +- **R-434-003**: Empty collection handling could be mistaken as durable evidence. Mitigation: no resource/evidence row; implementation report documents collection-proof deferral. +- **R-434-004**: Spec 433 PASS WITH CONDITIONS could be misread as full provider readiness. Mitigation: require implementation preflight to re-check Spec 433 condition and fail closed if it affects adapter safety. + +## Assumptions + +- Spec 433 remains implemented with PASS WITH CONDITIONS and no merge-blocking readiness findings for this adapter-only slice. +- `tenant_configuration.capture` remains the canonical evidence-owning operation type. +- `transportRule`, `remoteDomain`, and `inboundConnector` command contracts remain verified/pending capture through Spec 430/431/432 repo truth. +- Existing Coverage v2 resource/evidence tables are sufficient for non-empty internal content-backed fixture evidence. +- Durable collection-level empty proof requires a separate provider-neutral design and is deferred. + +## Open Questions + +None blocking preparation. Implementation must stop and amend this spec if it needs durable empty-collection persistence, a new OperationRun type, schema changes, UI/product surface changes, live provider validation, or customer output. + +## Follow-up Spec Candidates + +- Spec 435 - Exchange Content-Backed Evidence Promotion Slice 1 for `transportRule`, `remoteDomain`, and `inboundConnector`. +- Exchange comparable/renderable promotion after content-backed evidence exists. +- Teams PowerShell adapter contract and evidence support. +- M365 customer output and claim guard after evidence and compare semantics are proven. +- Provider-neutral durable collection-proof evidence model, only if product workflows require empty collection proof. + +## Candidate Selection Gate + +PASS. The selected candidate is directly provided by the user, not covered by an existing active or completed Spec 434 package, follows implemented Specs 429-433, aligns with the Exchange/Teams Coverage v2 roadmap sequence, is narrow enough for a bounded implementation loop, and defers full target promotion plus customer/product surfaces. + +## Spec Readiness Gate + +PASS for preparation. `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md` define a bounded, testable, no-application-implementation package. Runtime implementation must re-check repo state, Spec 433 conditions, and all hard gates before changing application code. diff --git a/specs/434-exchange-evidence-capture-adapter-content-only-guard/tasks.md b/specs/434-exchange-evidence-capture-adapter-content-only-guard/tasks.md new file mode 100644 index 00000000..47786787 --- /dev/null +++ b/specs/434-exchange-evidence-capture-adapter-content-only-guard/tasks.md @@ -0,0 +1,166 @@ +# Tasks: Exchange Evidence Capture Adapter and Content-Only Guard + +**Input**: Design documents from `specs/434-exchange-evidence-capture-adapter-content-only-guard/` +**Prerequisites**: `spec.md`, `plan.md`, `checklists/requirements.md` + +**Implementation Mode**: backend adapter/guard implementation only. No UI, routes, jobs, schedules, listeners, migrations, customer output, compare/render, certification, restore, or `tenant_id` ownership truth unless the spec is amended first. + +**Implementation Note (2026-07-08)**: T007-T021 were covered by +`apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php` +rather than split into separate Unit files because the adapter boundary, unsafe-identity zero resource/evidence assertions, +OperationRun summary-count updates, same-scope checks, and evidence/resource absence are DB-backed +behavior. The file keeps fixtures local and also exercises direct identity-gate paths for existing +conflict and unsupported-identity states. No literal writer spy is used; writer non-reachability is +proven by adapter code order plus zero resource/evidence database assertions. + +## Test Governance Checklist + +- [x] Lane assignment is named as Unit/Feature fast-feedback/confidence and is the narrowest sufficient proof. +- [x] New or changed tests stay in the smallest honest family; no browser or heavy-governance family is added accidentally. +- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; Spec 434 fixtures remain local. +- [x] Planned validation commands cover adapter/guard behavior 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 `N/A - no rendered UI surface changed`. +- [x] Any migration, UI, customer-output, or durable empty-collection proof need is escalated to spec amendment or follow-up spec. + +## Phase 1: Preflight + +**Purpose**: Lock scope, repo truth, and completed-spec guardrails before runtime work. + +- [x] T001 Capture current branch, HEAD, and `git status --short` in `specs/434-exchange-evidence-capture-adapter-content-only-guard/implementation-report.md`. +- [x] T002 Confirm Specs 429-433 are completed or implementation-closed read-only context and are not edited. +- [x] T003 Confirm Spec 433 implementation report is PASS WITH CONDITIONS with no merge-blocking adapter, readiness, redaction, no-promotion, or no-UI findings. +- [x] T004 Confirm no existing `specs/434-*` package is overwritten and this package remains the only active Spec 434 target. +- [x] T005 Confirm implementation diff starts with no runtime UI, route, job, schedule, listener, migration, customer-output, report, Review Pack, PDF, restore, certification, compare/render, or `tenant_id` scope. +- [x] T006 Confirm `tenant_configuration.capture` is the only evidence-owning OperationRun type for this slice. + +## Phase 2: Tests First - Adapter and Eligibility + +**Purpose**: Define expected adapter boundaries before implementation. + +- [x] T007 [P] Add feature-backed tests in `apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php` proving the adapter accepts only `transportRule`, `remoteDomain`, and `inboundConnector`. +- [x] T008 [P] Add feature-backed tests proving wrong OperationRun type, missing OperationRun, wrong workspace, wrong managed environment, and wrong provider connection block before evidence append. +- [x] T009 [P] Add feature-backed tests proving missing Spec 433 readiness, `exchange_powershell_invoke` provider capability status other than `Supported`, missing Spec 432 runner boundary, invalid `ExchangePowerShellInvocationResult` shape, and missing Spec 430 verified command contract block before evidence append. +- [x] T010 [P] Add tests proving Exchange capture does not call `ProviderGateway` or the generic Graph list path. +- [x] T011 [P] Add tests proving no fake Graph endpoint, fake captured outcome, or `fake_empty_success` is used for Exchange eligibility, and persisted outcomes reuse existing `CaptureOutcome` values only. + +## Phase 3: Tests First - Identity and Content Guard + +**Purpose**: Prove unsafe evidence cannot be appended or over-promoted. + +- [x] T012 [P] Add feature-backed tests in `apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php` proving identity conflict blocks before resource upsert/evidence append. +- [x] T013 [P] Add feature-backed tests proving missing stable external ID, unsupported identity, duplicate identity, and display-name-only identity block by default. +- [x] T014 [P] Prove unsafe identity does not reach evidence append by adapter code order plus zero resource/evidence assertions; no literal writer spy is used. +- [x] T015 [P] Add feature-backed tests in `apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php` proving Exchange adapter writes cannot exceed `content_backed`. +- [x] T016 [P] Add tests proving comparable, renderable, certified, restore-ready, and customer-claimable promotion does not occur through the Spec 434 path. + +## Phase 4: Tests First - Empty Collection, Redaction, and No Product Surface + +**Purpose**: Prove zero-item and redaction behavior before implementation. + +- [x] T017 [P] Add feature-backed tests in `apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php` proving empty collections create no resource row and no evidence row. +- [x] T018 [P] Add tests proving empty collections produce only safe flat numeric zero-item summary counts using existing summary keys. +- [x] T019 [P] Add tests proving OperationRun context/messages/summary counts contain no raw payload, stdout, stderr, serialized Exchange object, credential material, token, or provider response body. +- [x] T020 [P] Add static guard assertions in `apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php` proving no new routes, Filament pages/resources/widgets, Livewire components, Blade views, navigation, assets, global search resources, jobs, schedules, listeners, customer-output code, or migrations are introduced. +- [x] T021 [P] Add static guard assertions proving no `tenant_id`, Exchange-specific evidence table, legacy shim, fallback reader, dual write, or Exchange mini-platform is introduced. + +## Phase 5: Adapter Boundary Implementation + +**Purpose**: Add the narrow trusted Exchange adapter. + +- [x] T022 Create `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php` or repo-canonical equivalent. +- [x] T023 Implement same-scope validation for workspace, managed environment, provider connection, and `tenant_configuration.capture` OperationRun. +- [x] T024 Implement target allowlist for `transportRule`, `remoteDomain`, and `inboundConnector`. +- [x] T025 Implement dependency on Spec 433 combined readiness, `ProviderCapabilityEvaluator` `exchange_powershell_invoke` status `Supported`, and Spec 432 accepted `ExchangePowerShellInvocationResult` output boundary. +- [x] T026 Implement dependency on verified Spec 430 command contract metadata. +- [x] T027 Ensure adapter is internal/trusted only and exposes no route, UI action, command, schedule, listener, or public trigger. + +## Phase 6: Capture Eligibility and Source Contract Semantics + +**Purpose**: Keep Exchange capture honest without pretending it is Graph capture. + +- [x] T028 Create `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCaptureEligibilityGate.php` or repo-canonical equivalent. +- [x] T029 Represent Exchange capture eligibility without fake Graph `source_endpoint` or fake `Captured` source contract outcome. +- [x] T030 Preserve `CoverageSourceContractResolver` Graph behavior and add only bounded Exchange-specific eligibility metadata where needed. +- [x] T031 Ensure `transportRule`, `remoteDomain`, and `inboundConnector` remain pending full content-backed promotion until Spec 435 or equivalent. +- [x] T032 Map safe command contract metadata, accepted runner-result metadata, response shape metadata, redaction metadata, and normalization handoff into sanitized internal context without storing raw `ExchangePowerShellInvocationResult::collection` outside the evidence path. + +## Phase 7: Identity Hard-Stop Implementation + +**Purpose**: Stop unsafe identity before any evidence append. + +- [x] T033 Create `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellIdentityEvidenceGate.php` or repo-canonical equivalent. +- [x] T034 Evaluate identity before calling `CoverageEvidenceWriter`. +- [x] T035 Block identity conflict, missing stable external ID, unsupported identity, duplicate identity, and display-name-only identity by default. +- [x] T036 Return only Spec 434 allowlisted sanitized blocker outcomes without raw payload or provider output. +- [x] T037 Ensure unsafe identity creates no `TenantConfigurationResourceEvidence` row. + +## Phase 8: Content-Only Guard Implementation + +**Purpose**: Prevent accidental promotion beyond internal content-backed evidence. + +- [x] T038 Create `apps/platform/app/Services/TenantConfiguration/ExchangePowerShellContentOnlyEvidenceGuard.php` or repo-canonical equivalent. +- [x] T039 Add a max-coverage-level guard for the Exchange adapter path so stored coverage is no higher than `content_backed`. +- [x] T040 Adjust `CoverageEvidenceWriter` or call-site options narrowly enough that existing Graph capture behavior remains unchanged. +- [x] T041 Ensure comparable, renderable, certified, restore-ready, and customer-claimable state cannot be set through the Exchange adapter path. +- [x] T042 Ensure no new customer-visible status vocabulary or Product Surface runtime framework is introduced. + +## Phase 9: Empty Collection and Redaction Implementation + +**Purpose**: Keep zero-item and sensitive-output behavior safe. + +- [x] T043 Implement empty collection handling as `exchange_capture_empty_collection` or repo-canonical equivalent and safe summary counts only. +- [x] T044 Ensure empty collections create no fake `TenantConfigurationResource` and no fake `TenantConfigurationResourceEvidence`. +- [x] T045 Ensure durable collection-level empty proof is not implemented in Spec 434. +- [x] T046 Sanitize OperationRun context and failure/outcome mapping so only Spec 434 allowlisted internal codes, existing `CaptureOutcome` values, existing ProviderReasonCodes or `ext.*` reason codes, and no raw stdout/stderr, serialized Exchange objects, credential material, tokens, or provider response bodies are present. +- [x] T047 Use existing redaction/evidence storage policy for non-empty guarded fixture evidence only. + +## Phase 10: Regression Coverage + +**Purpose**: Prove adjacent completed behavior remains intact. + +- [x] T048 Run focused Spec 434 tests: `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec434 --compact`. +- [x] T049 Run Spec 433 readiness regression: `cd apps/platform && ./vendor/bin/sail artisan test --filter=Spec433 --compact`. +- [x] T050 Run Spec 432/431/430 regression: `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec432|Spec431|Spec430' --compact`. +- [x] T051 Run Coverage v2/generic capture/identity regression: `cd apps/platform && ./vendor/bin/sail artisan test --filter='Spec415|Spec417|Spec419|Spec420|Spec426|Spec427' --compact`. +- [x] T052 Run provider capability regression: `cd apps/platform && ./vendor/bin/sail artisan test --filter='ProviderCapabilityRegistryTest|ProviderCapabilityEvaluationTest' --compact`. +- [x] T053 If Sail is unavailable, document the failure and use the repo's non-Docker fallback `cd apps/platform && php artisan test ...` for the same focused filters. + +## Phase 11: Final Validation and Close-Out + +**Purpose**: Finish the implementation report and review proof. + +- [x] T054 Run `cd apps/platform && ./vendor/bin/pint --dirty --test`. +- [x] T055 Run `git diff --check`. +- [x] T056 Run `git status --short` and confirm only active Spec 434 and intended runtime/test files changed. +- [x] T057 Create `specs/434-exchange-evidence-capture-adapter-content-only-guard/implementation-report.md`. +- [x] T058 Record branch, HEAD, dirty state before/after, files changed, and completed-spec read-only proof. +- [x] T059 Record Spec 433 prerequisite proof and any PASS WITH CONDITIONS handling. +- [x] T060 Record OperationRun decision: `tenant_configuration.capture`, no new OperationRun type, existing summary keys only. +- [x] T061 Record adapter boundary proof and generic Graph capture boundary proof. +- [x] T062 Record capture eligibility, provider-capability gate, accepted runner-result gate, outcome/failure-code allowlist, identity hard-stop, content-only writer guard, empty collection policy, and redaction proof. +- [x] T063 Record no compare/render/certification/restore/customer claim, no UI/routes/jobs/schedules/listeners, no migration, no Exchange-specific table, no `tenant_id`, no legacy shim, no fallback reader, and no mini-platform proof. +- [x] T064 Record tests run, browser `N/A - no rendered UI surface changed`, Laravel 12/PHP 8.4/PostgreSQL/Pest 4 compatibility unchanged, Livewire v4 compliance unchanged, provider registration unchanged under `apps/platform/bootstrap/providers.php`, global search unchanged, destructive/high-impact actions none, asset strategy none, deployment impact none, visible complexity neutral, and deferred work. + +## Dependencies + +- Phase 1 must complete before runtime work. +- Phases 2-4 tests should be created before implementation where practical. +- Phase 5 adapter boundary precedes Phase 6 eligibility and Phase 7 identity wiring. +- Phase 8 content-only guard must be complete before non-empty fixture evidence can be accepted. +- Phase 9 empty collection/redaction must be complete before final regression. +- Phase 11 closes only after all required validation is documented. + +## Stop Conditions + +Stop and amend or split the spec if implementation needs any of the following: + +- new OperationRun type; +- migration or durable collection-proof model; +- UI, route, navigation, Filament, Livewire, browser, asset, or global search changes; +- job, schedule, listener, console trigger, or public start surface; +- live Exchange provider calls for validation; +- compare/render/certification/restore/customer output; +- Exchange-specific evidence table; +- `tenant_id` ownership truth; +- legacy shim, fallback reader, dual write, or Coverage v1 bridge.