, * 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']; $envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult( resourceType: $canonicalType, contract: ExchangePowerShellCommandContract::fromVerifiedArray($contract, $this->commandContracts), runnerMode: $this->runnerMode($operationRun, $runnerResult), result: $runnerResult, ); $readiness = $this->normalizer->evaluate($envelope); if (! $readiness->ready) { $summaryCounts = $this->zeroSummaryCounts(); $this->recordSummaryCounts($operationRun, $summaryCounts); return $this->result( canonicalType: $canonicalType, outcome: CaptureOutcome::BlockedUnsupported, adapterOutcome: $this->normalizerBlockedAdapterOutcome($readiness), itemCount: 0, reasonCode: $readiness->blockers[0] ?? 'exchange_normalizer_blocked', sourceContractKey: $sourceDecision->contractKey, evidenceIds: [], summaryCounts: $summaryCounts, ); } if ($readiness->emptyCollection) { $summaryCounts = $this->zeroSummaryCounts(); $this->recordSummaryCounts($operationRun, $summaryCounts); 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); $evidenceItems = $this->normalizer->normalizedEvidenceItems($envelope); if ($evidenceItems === [] || count($evidenceItems) !== $readiness->itemCount) { $summaryCounts = $this->zeroSummaryCounts(); $this->recordSummaryCounts($operationRun, $summaryCounts); return $this->result( canonicalType: $canonicalType, outcome: CaptureOutcome::BlockedUnsupported, adapterOutcome: self::OUTCOME_PREREQUISITE_BLOCKED, itemCount: 0, reasonCode: 'exchange_normalizer_hash_unready', sourceContractKey: $sourceDecision->contractKey, evidenceIds: [], summaryCounts: $summaryCounts, ); } $identity = $this->identityGate->evaluateCollection( tenant: $tenant, providerConnection: $providerConnection, resourceType: $resourceType, items: array_values(array_map( static fn (array $item): array => $item['raw_payload'], $evidenceItems, )), 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 = []; $permissionContext = $this->permissionContext($providerConnection); foreach ($evidenceItems as $item) { $rawPayload = $item['raw_payload']; $normalizedPayload = $this->evidenceNormalizedPayload( normalizedPayload: $item['normalized_payload'], decision: $decision, envelope: $envelope, readiness: $readiness, ); $payloadHash = $this->evidencePayloadHash($normalizedPayload, $envelope, $readiness); $resource = $this->resourceUpserter->upsert( tenant: $tenant, providerConnection: $providerConnection, resourceType: $resourceType, payload: $rawPayload, sourceMetadata: $decision->sourceMetadata, ); $evidence = $this->evidenceWriter->append( resource: $resource, resourceType: $resourceType, providerConnection: $providerConnection, operationRun: $operationRun, decision: $decision, rawPayload: $rawPayload, 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 $normalizedPayload * @return array */ private function evidenceNormalizedPayload( array $normalizedPayload, CoverageSourceContractDecision $decision, ExchangePowerShellStructuredOutputEnvelope $envelope, ExchangePowerShellNormalizerReadiness $readiness, ): array { $source = array_filter([ 'source_contract_key' => $decision->contractKey, 'source_endpoint' => $decision->sourceEndpoint, 'source_version' => $decision->sourceVersion, 'source_schema_hash' => $decision->sourceSchemaHash, 'source_surface' => $envelope->sourceSurface, 'command_contract_name' => $envelope->commandContractName, 'command_contract_version' => $envelope->commandContractVersion, 'payload_shape_version' => $readiness->definition['payload_shape_version'] ?? null, 'normalizer_version' => $readiness->definition['normalizer_version'] ?? null, ], static fn (mixed $value): bool => $value !== null && $value !== ''); return [ ...$normalizedPayload, 'source' => $source, ]; } /** * @param array $normalizedPayload */ private function evidencePayloadHash( array $normalizedPayload, ExchangePowerShellStructuredOutputEnvelope $envelope, ExchangePowerShellNormalizerReadiness $readiness, ): string { $hashInput = $this->hashes->build( resourceType: $envelope->resourceType, sourceSurface: $envelope->sourceSurface, commandContractName: $envelope->commandContractName, commandContractVersion: $envelope->commandContractVersion, payloadShapeVersion: (string) ($readiness->definition['payload_shape_version'] ?? ExchangePowerShellTargetEvidenceNormalizer::PAYLOAD_SHAPE_VERSION), normalizerVersion: (string) ($readiness->definition['normalizer_version'] ?? 'unknown'), normalizedPayload: $normalizedPayload, ); return $this->hashes->hash($hashInput); } /** * @param array $payload * @return array */ private function redactedPayload(array $payload): array { $redacted = $this->redactor->redact($payload); return is_array($redacted) ? $redacted : []; } private function runnerMode(OperationRun $operationRun, ExchangePowerShellInvocationResult $runnerResult): string { $runnerMode = $runnerResult->context['runner_mode'] ?? data_get($operationRun->context, 'runner_mode'); return is_string($runnerMode) && trim($runnerMode) !== '' ? trim($runnerMode) : 'unknown'; } private function normalizerBlockedAdapterOutcome(ExchangePowerShellNormalizerReadiness $readiness): string { foreach ($readiness->blockers as $blocker) { if (str_contains($blocker, 'identity') || str_contains($blocker, 'display_name') || str_contains($blocker, 'duplicate') || str_contains($blocker, 'missing_stable') || str_contains($blocker, 'derived') ) { return self::OUTCOME_IDENTITY_BLOCKED; } } return self::OUTCOME_PREREQUISITE_BLOCKED; } /** * @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, ]; } }