feat: activate Exchange coverage evidence capture (Spec 454) #520

68 changed files with 14379 additions and 287 deletions

View File

@ -13,8 +13,12 @@
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
use App\Services\TenantConfiguration\CoverageTypeAuthority;
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
use App\Services\TenantConfiguration\ExchangeCoverageCaptureCohortPolicy;
use App\Services\TenantConfiguration\StartTenantConfigurationCapture;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\BadgeSpec;
use App\Support\ManagedEnvironmentLinks;
use App\Support\Navigation\NavigationScope;
use App\Support\OperationRunType;
@ -26,11 +30,15 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\TenantConfiguration\Workload;
use BackedEnum;
use Carbon\CarbonImmutable;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model;
use Livewire\Attributes\Locked;
@ -65,10 +73,16 @@ class CoverageV2Readiness extends Page
#[Url(as: 'provider_connection_id', keep: true)]
public ?string $providerConnectionId = null;
#[Url(as: 'workload', keep: true)]
public string $requestedWorkload = Workload::Intune->value;
#[Locked]
public string $activeWorkload = Workload::Intune->value;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Provider remediation or a needed confirmed capture is dominant; when proof is current, capture remains an optional neutral refresh.')
->satisfy(ActionSurfaceSlot::ListHeader, 'The native Decision Page owns one active provider/workload question and one confirmed capture or remediation action; embedded tables remain read-only registry/report surfaces.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::PrimaryLinkColumn->value)
->withPrimaryLinkColumnReason('Only the primary name/resource column opens the read-only inspect slide-over; full-row click would conflict with dense registry comparison columns.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The surface uses primary link columns for read-only inspect details and no secondary row menu.')
@ -128,6 +142,7 @@ public function mount(Workspace|int|string|null $workspace = null, ManagedEnviro
}
$this->environmentId = (int) $resolvedEnvironment->getKey();
$this->activateRequestedWorkload();
$context = $this->providerContext();
@ -141,9 +156,22 @@ public function updatedProviderConnectionId(): void
$this->providerContext();
}
public function updatedRequestedWorkload(): void
{
$this->activateRequestedWorkload();
}
public function selectWorkload(string $workload): void
{
$this->requestedWorkload = $workload;
$this->activateRequestedWorkload();
}
public function getTitle(): string|Htmlable
{
return __('localization.coverage_v2.title');
return $this->activeWorkloadEnum() === Workload::Exchange
? __('localization.coverage_v2.exchange.title')
: __('localization.coverage_v2.title');
}
public static function getNavigationLabel(): string
@ -171,7 +199,27 @@ public function environment(): ManagedEnvironment
public function readinessSummary(): array
{
return app(CoverageV2ReadinessReadModel::class)
->summary($this->environment(), $this->providerConnection());
->summary($this->environment(), $this->providerConnection(), $this->activeWorkloadEnum());
}
public function readinessBadge(): BadgeSpec
{
return match ($this->readinessSummary()['readiness_state'] ?? 'unknown') {
'expired' => new BadgeSpec(
__('localization.coverage_v2.states.expired'),
'warning',
'heroicon-m-clock',
),
'not_configured' => new BadgeSpec(
__('localization.coverage_v2.states.not_configured'),
'gray',
'heroicon-m-question-mark-circle',
),
default => BadgeRenderer::spec(
BadgeDomain::CoverageV2Readiness,
$this->readinessSummary()['readiness_state'] ?? 'unknown',
),
};
}
public function formatSummaryTimestamp(mixed $value): ?string
@ -215,7 +263,9 @@ protected function getHeaderActions(): array
$capture = Action::make('captureConfiguration')
->label(fn (): string => $this->readinessIsReady()
? __('localization.coverage_v2.refresh_proof')
: __('localization.coverage_v2.capture_configuration'))
: ($this->activeWorkloadEnum() === Workload::Exchange
? __('localization.coverage_v2.exchange.capture')
: __('localization.coverage_v2.capture_configuration')))
->icon('heroicon-o-arrow-path')
->color(fn (): string => $this->captureActionAvailable() && ! $this->readinessIsReady() ? 'primary' : 'gray')
->extraAttributes(fn (): array => [
@ -225,12 +275,43 @@ protected function getHeaderActions(): array
: 'secondary',
])
->requiresConfirmation()
->modalHeading(__('localization.coverage_v2.capture_configuration'))
->modalDescription(fn (): string => __('localization.coverage_v2.capture_description', [
'provider' => $this->providerConnection()?->display_name ?? __('localization.coverage_v2.selected_microsoft_environment'),
]))
->modalHeading(fn (): string => $this->activeWorkloadEnum() === Workload::Exchange
? __('localization.coverage_v2.exchange.capture')
: __('localization.coverage_v2.capture_configuration'))
->modalDescription(fn (): string => $this->activeWorkloadEnum() === Workload::Exchange
? __('localization.coverage_v2.exchange.capture_description')
: __('localization.coverage_v2.capture_description', [
'provider' => $this->providerConnection()?->display_name ?? __('localization.coverage_v2.selected_microsoft_environment'),
]))
->modalSubmitActionLabel(fn (): string => $this->activeWorkloadEnum() === Workload::Exchange
? __('localization.coverage_v2.exchange.capture')
: __('localization.coverage_v2.capture_configuration'))
->form(fn (): array => $this->activeWorkloadEnum() === Workload::Exchange
? [
Select::make('provider_connection_id')
->label(__('localization.coverage_v2.provider_connection'))
->options(fn (): array => $this->providerContext()['options'])
->getOptionLabelUsing(function (mixed $value): ?string {
if (! is_numeric($value) || (int) $value < 1) {
return null;
}
$options = $this->providerContext()['options'];
return $options[(int) $value]
?? __('localization.coverage_v2.provider_connection');
})
->default(fn (): ?int => is_numeric($this->providerConnectionId)
? (int) $this->providerConnectionId
: null)
->helperText(__('localization.coverage_v2.exchange.provider_draft_help'))
->required()
->native(false)
->searchable(),
]
: [])
->disabled(fn (): bool => ! $this->captureActionAvailable())
->action(fn (): OperationRun => $this->captureConfiguration());
->action(fn (array $data): ?OperationRun => $this->captureConfiguration($data));
$capture = UiEnforcement::forAction($capture, fn (): ManagedEnvironment => $this->environment())
->requireCapability(Capabilities::EVIDENCE_MANAGE)
@ -248,6 +329,7 @@ protected function getHeaderActions(): array
Action::make('resolveProviderBlocker')
->label(is_string($remediationLabel) && $remediationLabel !== '' ? $remediationLabel : __('localization.coverage_v2.resolve_provider_prerequisites'))
->icon('heroicon-o-wrench-screwdriver')
->extraAttributes(['data-testid' => 'coverage-v2-secondary-action'])
->visible(! $this->captureEligible() && is_string($remediationUrl) && $remediationUrl !== '')
->url(is_string($remediationUrl) ? $remediationUrl : null),
];
@ -272,7 +354,7 @@ private function captureEligibility(): array
}
return app(CoverageV2ReadinessReadModel::class)
->captureEligibility($this->environment(), $connection);
->captureEligibility($this->environment(), $connection, $this->activeWorkloadEnum());
}
private function captureEligible(): bool
@ -282,7 +364,9 @@ private function captureEligible(): bool
private function captureActionAvailable(): bool
{
return $this->captureEligible() && ! $this->captureInProgress();
return $this->actorHasCaptureCapabilities()
&& $this->captureEligible()
&& ! $this->captureInProgress();
}
private function captureInProgress(): bool
@ -320,6 +404,15 @@ private function captureDisabledReason(): ?string
if (! $access->allowed()) {
return __('localization.coverage_v2.evidence_manage_required');
}
if ($this->activeWorkloadEnum() === Workload::Exchange) {
$providerAccess = app(ManagedEnvironmentAccessScopeResolver::class)
->decision($user, $environment, Capabilities::PROVIDER_RUN);
if (! $providerAccess->allowed()) {
return __('localization.coverage_v2.exchange.provider_run_required');
}
}
}
if ($this->captureInProgress()) {
@ -331,10 +424,16 @@ private function captureDisabledReason(): ?string
: (string) ($this->captureEligibility()['reason'] ?? __('localization.coverage_v2.capture_blocked'));
}
private function captureConfiguration(): OperationRun
/**
* @param array<string, mixed> $data
*/
private function captureConfiguration(array $data = []): ?OperationRun
{
$user = auth()->user();
$providerConnection = $this->providerConnection();
$workload = $this->activeWorkloadEnum();
$providerConnection = $workload === Workload::Exchange
? $this->resolveDraftProviderConnection($data['provider_connection_id'] ?? null)
: $this->providerConnection();
if (! $user instanceof User) {
abort(403);
@ -344,14 +443,41 @@ private function captureConfiguration(): OperationRun
abort(404);
}
$run = app(StartTenantConfigurationCapture::class)->start(
tenant: $this->environment(),
providerConnection: $providerConnection,
actor: $user,
canonicalTypes: collect(app(CoverageTypeAuthority::class)->productCaptureEligibleDefinitions())
->pluck('canonicalKey')
->all(),
);
try {
$run = app(StartTenantConfigurationCapture::class)->start(
tenant: $this->environment(),
providerConnection: $providerConnection,
actor: $user,
canonicalTypes: $workload === Workload::Intune
? collect(app(CoverageTypeAuthority::class)->productCaptureEligibleDefinitions())
->pluck('canonicalKey')
->all()
: null,
workload: $workload,
cohortIdentifier: $workload === Workload::Exchange
? ExchangeCoverageCaptureCohortPolicy::IDENTIFIER
: null,
);
} catch (AuthorizationException $exception) {
if ($workload !== Workload::Exchange) {
throw $exception;
}
$reasonCode = in_array($exception->getMessage(), [
'provider_binding_unsupported',
'provider_connection_inactive',
], true)
? $exception->getMessage()
: 'exchange_capture_prerequisite_blocked';
Notification::make()
->title(__('localization.coverage_v2.exchange.capture_blocked'))
->body(__('localization.coverage_v2.exchange.blocked_reason', ['reason' => $reasonCode]))
->danger()
->send();
return null;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this, $this->environmentId);
@ -363,6 +489,71 @@ private function captureConfiguration(): OperationRun
return $run;
}
public function canViewTechnicalAnnex(): bool
{
$user = auth()->user();
return $user instanceof User
&& app(ManagedEnvironmentAccessScopeResolver::class)
->decision($user, $this->environment(), Capabilities::EVIDENCE_MANAGE)
->allowed();
}
private function actorHasCaptureCapabilities(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$resolver = app(ManagedEnvironmentAccessScopeResolver::class);
$environment = $this->environment();
if (! $resolver->decision($user, $environment, Capabilities::EVIDENCE_MANAGE)->allowed()) {
return false;
}
return $this->activeWorkloadEnum() !== Workload::Exchange
|| $resolver->decision($user, $environment, Capabilities::PROVIDER_RUN)->allowed();
}
private function resolveDraftProviderConnection(mixed $providerConnectionId): ProviderConnection
{
if (! is_numeric($providerConnectionId) || (int) $providerConnectionId < 1) {
abort(404);
}
$connection = ProviderConnection::query()->find((int) $providerConnectionId);
$environment = $this->environment();
if (! $connection instanceof ProviderConnection
|| (int) $connection->workspace_id !== (int) $environment->workspace_id
|| (int) $connection->managed_environment_id !== (int) $environment->getKey()
) {
abort(404);
}
return $connection;
}
private function activateRequestedWorkload(): void
{
if (! in_array($this->requestedWorkload, [
Workload::Intune->value,
Workload::Exchange->value,
], true)) {
$this->requestedWorkload = Workload::Intune->value;
}
$this->activeWorkload = $this->requestedWorkload;
}
private function activeWorkloadEnum(): Workload
{
return Workload::from($this->activeWorkload);
}
/**
* @param array<mixed> $parameters
*/

View File

@ -14,6 +14,7 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\TenantConfiguration\Workload;
use Carbon\CarbonImmutable;
use Filament\Actions\Action;
use Filament\Support\Enums\FontFamily;
@ -37,10 +38,20 @@ class CoverageV2ResourceInstancesTable extends TableWidget
#[Locked]
public ?int $providerConnectionId = null;
public function mount(?int $environmentId = null, ?int $providerConnectionId = null): void
#[Locked]
public string $workload = Workload::Intune->value;
public function mount(
?int $environmentId = null,
?int $providerConnectionId = null,
string $workload = Workload::Intune->value,
): void
{
$this->environmentId = $environmentId;
$this->providerConnectionId = $providerConnectionId;
$this->workload = in_array($workload, [Workload::Intune->value, Workload::Exchange->value], true)
? $workload
: Workload::Intune->value;
$this->authorizeEnvironment();
}
@ -52,6 +63,7 @@ public function table(Table $table): Table
->query(fn (): Builder => app(CoverageV2ReadinessReadModel::class)->resourceInstanceQuery(
$this->environment(),
$this->providerConnection(),
Workload::from($this->workload),
))
->searchable()
->searchPlaceholder(__('localization.coverage_v2.instances.search_placeholder'))
@ -138,6 +150,14 @@ public function table(Table $table): Table
->label(__('localization.coverage_v2.instances.resource_type'))
->options(fn (): array => \App\Models\TenantConfigurationResourceType::query()
->active()
->where('workload', $this->workload)
->whereIn(
'canonical_type',
app(CoverageV2ReadinessReadModel::class)
->resourceTypeQuery(Workload::from($this->workload))
->pluck('canonical_type')
->all(),
)
->orderBy('display_name')
->pluck('display_name', 'id')
->mapWithKeys(fn (string $label, int|string $id): array => [(string) $id => $label])
@ -229,9 +249,20 @@ private function inspectAction(): Action
->modalHeading(fn (TenantConfigurationResource $record): string => (string) ($record->source_display_name ?: $record->canonical_resource_key))
->modalContent(fn (TenantConfigurationResource $record): View => view('filament.modals.tenant-configuration.coverage-v2-resource-inspect', [
'details' => app(CoverageV2ReadinessReadModel::class)->inspectDetails($record, $this->environment(), auth()->user()),
'canViewTechnicalAnnex' => $this->canViewTechnicalAnnex(),
]));
}
private function canViewTechnicalAnnex(): bool
{
$user = auth()->user();
return $user instanceof User
&& app(ManagedEnvironmentAccessScopeResolver::class)
->decision($user, $this->environment(), Capabilities::EVIDENCE_MANAGE)
->allowed();
}
private function formatTimestamp(mixed $value): ?string
{
if (! $value instanceof \DateTimeInterface && ! is_string($value)) {

View File

@ -13,6 +13,7 @@
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
use App\Support\Auth\Capabilities;
use App\Support\TenantConfiguration\CoverageOperatorVisibility;
use App\Support\TenantConfiguration\Workload;
use Carbon\CarbonImmutable;
use Filament\Actions\Action;
use Filament\Support\Enums\TextSize;
@ -34,18 +35,28 @@ class CoverageV2ResourceTypesTable extends TableWidget
#[Locked]
public ?int $providerConnectionId = null;
#[Locked]
public string $workload = Workload::Intune->value;
/** @var array<string, array<string, mixed>> */
#[Locked]
public array $outcomes = [];
public function mount(?int $environmentId = null, ?int $providerConnectionId = null): void
public function mount(
?int $environmentId = null,
?int $providerConnectionId = null,
string $workload = Workload::Intune->value,
): void
{
$this->environmentId = $environmentId;
$this->providerConnectionId = $providerConnectionId;
$this->workload = in_array($workload, [Workload::Intune->value, Workload::Exchange->value], true)
? $workload
: Workload::Intune->value;
$this->authorizeEnvironment();
$summary = app(CoverageV2ReadinessReadModel::class)
->summary($this->environment(), $this->providerConnection());
->summary($this->environment(), $this->providerConnection(), Workload::from($this->workload));
$this->outcomes = collect($summary['type_outcomes'] ?? [])
->mapWithKeys(static function (array $row): array {
@ -79,6 +90,7 @@ public function table(Table $table): Table
->label(__('localization.coverage_v2.types_table.resource_type'))
->width('40%')
->wrap()
->extraCellAttributes(['data-testid' => 'coverage-v2-visible-data-row'])
->disabledClick(fn (array $record): bool => ($record['inspectable'] ?? false) !== true)
->action($this->inspectAction()),
TextColumn::make('mobile_status')
@ -268,7 +280,7 @@ private function inspectAction(): Action
->modalContent(fn (array $record): View => view('filament.modals.tenant-configuration.coverage-v2-resource-type-inspect', [
'details' => app(CoverageV2ReadinessReadModel::class)->resourceTypeInspectDetails(
$this->resourceTypeForInspect($record),
'intune_tcm_core',
$this->workload === Workload::Intune->value ? 'intune_tcm_core' : null,
),
]));
}
@ -284,13 +296,18 @@ private function resourceTypeForInspect(array $record): TenantConfigurationResou
: null;
if (! is_string($canonicalType)
|| $definition?->operatorVisibility() !== CoverageOperatorVisibility::ProductVisible
|| $definition?->operatorVisibility() !== (
$this->workload === Workload::Exchange->value
? CoverageOperatorVisibility::InternalOperatorVisible
: CoverageOperatorVisibility::ProductVisible
)
) {
abort(404);
}
$resourceType = TenantConfigurationResourceType::query()
->where('canonical_type', $canonicalType)
->where('workload', $this->workload)
->first();
if (! $resourceType instanceof TenantConfigurationResourceType) {

View File

@ -14,16 +14,21 @@
use App\Services\OperationRunService;
use App\Services\TenantConfiguration\CaptureTypeResultWriter;
use App\Services\TenantConfiguration\CoverageCaptureOutcomeSummarizer;
use App\Services\TenantConfiguration\ExchangeCoverageCaptureConsumer;
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
use App\Services\TenantConfiguration\GenericContentEvidenceCaptureService;
use App\Support\Audit\AuditActorSnapshot;
use App\Support\Audit\AuditOutcome;
use App\Support\Audit\AuditTargetSnapshot;
use App\Support\OperationRunStatus;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\TenantConfiguration\Workload;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
use Throwable;
@ -56,6 +61,12 @@ public function __construct(
public function middleware(): array
{
return [
(new WithoutOverlapping('tenant-configuration-capture:'.(int) $this->run->getKey()))
->dontRelease()
->expireAfter(
$this->timeout
+ app(OperationLifecyclePolicy::class)->retryAfterSafetyMarginSeconds(),
),
new EnsureQueuedExecutionLegitimate,
new TrackOperationRun,
];
@ -75,6 +86,8 @@ public function handle(
GenericContentEvidenceCaptureService $captureService,
OperationRunService $operationRuns,
AuditRecorder $auditRecorder,
?ExchangePowerShellInvocationGate $exchangeGate = null,
?ExchangeCoverageCaptureConsumer $exchangeConsumer = null,
): void {
$run = $this->run->fresh(['tenant.workspace', 'user']);
@ -102,6 +115,53 @@ public function handle(
throw new RuntimeException('Tenant configuration capture run is missing its managed environment or same-scope provider connection.');
}
$workloadValue = data_get($run->context, 'workload');
$workload = is_string($workloadValue)
? Workload::tryFrom($workloadValue)
: null;
if (is_string($workloadValue) && ! $workload instanceof Workload) {
throw new RuntimeException('Tenant configuration Capture workload is invalid.');
}
if ($workload === Workload::Exchange) {
$exchangeGate ??= app(ExchangePowerShellInvocationGate::class);
$exchangeConsumer ??= app(ExchangeCoverageCaptureConsumer::class);
$actor = $run->user;
if (! $actor) {
throw new RuntimeException('Exchange Capture requires its actor-bound initiator.');
}
$exchangeGate->consumeCaptureBatch(
$run,
$actor,
$providerConnection,
$exchangeConsumer,
);
$run = $run->fresh(['tenant.workspace', 'user']) ?? $run;
$results = app(CaptureTypeResultWriter::class)
->finalizeUnfinishedForRun($run);
$result = app(CoverageCaptureOutcomeSummarizer::class)
->summarize($results);
$completed = $operationRuns->updateRun(
run: $run,
status: OperationRunStatus::Completed->value,
outcome: $result['run_outcome'],
summaryCounts: $result['summary_counts'],
failures: $result['failures'],
);
$this->recordTerminalAudit(
$auditRecorder,
$completed,
$providerConnection,
$result,
$operationRuns->auditActorSnapshot($completed),
);
return;
}
$result = $captureService->capture(
tenant: $tenant,
providerConnection: $providerConnection,

View File

@ -26,6 +26,7 @@ public function __construct(
private readonly EntraRenderableSummaryBuilder $entraSummaryBuilder,
private readonly ExchangeTeamsRenderableSummaryBuilder $exchangeTeamsSummaryBuilder,
private readonly SecurityComplianceRenderableSummaryBuilder $securityComplianceSummaryBuilder,
private readonly ExchangePowerShellCommandContracts $exchangePowerShellContracts,
) {}
/**
@ -67,12 +68,14 @@ public function append(
): TenantConfigurationResourceEvidence {
$capturedAt = now();
$coverageLevel = $this->cappedCoverageLevel(
$this->coverageLevelFor($resourceType, $normalizedPayload),
$maximumCoverageLevel,
);
$evidence = TenantConfigurationResourceEvidence::query()->create([
$coverageLevel = $maximumCoverageLevel === CoverageLevel::ContentBacked
? CoverageLevel::ContentBacked
: $this->cappedCoverageLevel(
$this->coverageLevelFor($resourceType, $normalizedPayload),
$maximumCoverageLevel,
);
[$sourceEndpoint, $sourceMetadata] = $this->sourceProvenance($decision);
$candidate = [
'resource_id' => (int) $resource->getKey(),
'workspace_id' => (int) $resource->workspace_id,
'managed_environment_id' => (int) $resource->managed_environment_id,
@ -80,28 +83,40 @@ public function append(
'resource_type_id' => (int) $resourceType->getKey(),
'operation_run_id' => (int) $operationRun->getKey(),
'source_contract_key' => (string) $decision->contractKey,
'source_endpoint' => (string) $decision->sourceEndpoint,
'source_endpoint' => $sourceEndpoint,
'source_version' => $decision->sourceVersion,
'source_schema_hash' => $decision->sourceSchemaHash,
'source_metadata' => $decision->sourceMetadata,
'source_metadata' => $sourceMetadata,
'raw_payload' => $rawPayload,
'normalized_payload' => $normalizedPayload,
'payload_hash' => $payloadHash,
'permission_context' => $permissionContext === [] ? (object) [] : $permissionContext,
'permission_context' => $permissionContext,
'evidence_state' => EvidenceState::ContentBacked->value,
'coverage_level' => $coverageLevel->value,
'capture_outcome' => CaptureOutcome::Captured->value,
'captured_at' => $capturedAt,
]);
];
$inserted = $this->insertOrIgnoreExactConflict(
$candidate,
$capturedAt,
);
$evidence = TenantConfigurationResourceEvidence::query()
->where('operation_run_id', (int) $operationRun->getKey())
->where('resource_id', (int) $resource->getKey())
->lockForUpdate()
->firstOrFail();
$resource->forceFill([
'latest_evidence_id' => (int) $evidence->getKey(),
'latest_evidence_state' => EvidenceState::ContentBacked->value,
'latest_identity_state' => $this->stringValue($resource->latest_identity_state),
'latest_claim_state' => $this->stringValue($resource->latest_claim_state),
'latest_payload_hash' => $payloadHash,
'latest_captured_at' => $capturedAt,
])->save();
$this->assertExactImmutableReuse($evidence, $candidate);
if ($inserted) {
$resource->forceFill([
'latest_evidence_id' => (int) $evidence->getKey(),
'latest_evidence_state' => EvidenceState::ContentBacked->value,
'latest_identity_state' => $this->stringValue($resource->latest_identity_state),
'latest_claim_state' => $this->stringValue($resource->latest_claim_state),
'latest_payload_hash' => $payloadHash,
'latest_captured_at' => $capturedAt,
])->save();
}
return $evidence;
});
@ -154,6 +169,153 @@ private function coverageLevelFor(TenantConfigurationResourceType $resourceType,
return CoverageLevel::ContentBacked;
}
/**
* @return array{0: string, 1: array<string, mixed>}
*/
private function sourceProvenance(CoverageSourceContractDecision $decision): array
{
$sourceEndpoint = is_string($decision->sourceEndpoint)
? trim($decision->sourceEndpoint)
: '';
$sourceMetadata = $decision->sourceMetadata;
if ($sourceEndpoint !== '') {
return [$sourceEndpoint, $sourceMetadata];
}
$commandContractKey = is_string($decision->commandContractKey)
? trim($decision->commandContractKey)
: '';
$contract = $this->exchangePowerShellContracts->contractForCanonicalType(
$decision->canonicalType,
);
if ($commandContractKey === ''
|| $commandContractKey !== 'exchange_powershell.'.$decision->canonicalType
|| ! is_array($contract)
|| ($contract['contract_key'] ?? null) !== $commandContractKey
|| ! is_string($contract['command_name'] ?? null)
|| ! $this->exchangePowerShellContracts->validateCommandName(
$contract['command_name'],
)['accepted']
) {
throw new InvalidArgumentException('Captured Evidence executable descriptor is invalid.');
}
$sourceMetadata = [
...$sourceMetadata,
'executable_descriptor_kind' => 'command',
'command_contract_key' => $commandContractKey,
];
return [
ExchangePowerShellCommandContracts::SOURCE_SURFACE.':'.$contract['command_name'],
$sourceMetadata,
];
}
/**
* @param array<string, mixed> $candidate
*/
private function insertOrIgnoreExactConflict(array $candidate, mixed $capturedAt): bool
{
$timestamp = now();
$columns = [
...array_keys($candidate),
'captured_at',
'created_at',
'updated_at',
];
$values = [
...array_values($candidate),
$capturedAt,
$timestamp,
$timestamp,
];
$jsonColumns = [
'source_metadata',
'raw_payload',
'normalized_payload',
'permission_context',
];
foreach ($jsonColumns as $column) {
$index = array_search($column, $columns, true);
if (is_int($index)) {
$values[$index] = json_encode(
$values[$index] === [] ? (object) [] : $values[$index],
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
);
}
}
$quotedColumns = implode(', ', $columns);
$placeholders = implode(', ', array_fill(0, count($columns), '?'));
$sql = sprintf(
'INSERT INTO tenant_configuration_resource_evidence (%s) VALUES (%s) '
.'ON CONFLICT (operation_run_id, resource_id) DO NOTHING',
$quotedColumns,
$placeholders,
);
return DB::affectingStatement($sql, $values) === 1;
}
/**
* @param array<string, mixed> $candidate
*/
private function assertExactImmutableReuse(
TenantConfigurationResourceEvidence $evidence,
array $candidate,
): void {
$persisted = [];
foreach (array_keys($candidate) as $field) {
$persisted[$field] = $evidence->getAttribute($field);
}
foreach (array_keys($candidate) as $field) {
if ($this->canonicalize($persisted[$field])
!== $this->canonicalize($candidate[$field])
) {
throw new LogicException(
'Conflicting Evidence already exists for this Capture Run and Resource field: '
.$field.'.',
);
}
}
}
private function canonicalize(mixed $value): mixed
{
if ($value instanceof BackedEnum) {
return $value->value;
}
if (is_object($value)) {
$value = (array) $value;
}
if (! is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map(fn (mixed $item): mixed => $this->canonicalize($item), $value);
}
$normalized = [];
foreach ($value as $key => $nested) {
$normalized[(string) $key] = $this->canonicalize($nested);
}
ksort($normalized);
return $normalized;
}
private function assertScoped(
TenantConfigurationResource $resource,
TenantConfigurationResourceType $resourceType,

View File

@ -9,8 +9,11 @@
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceType;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageProductClassification;
use App\Support\TenantConfiguration\EvidenceState;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\SourceClass;
use App\Support\TenantConfiguration\Workload;
use InvalidArgumentException;
final class CoverageResourceUpserter
@ -19,6 +22,7 @@ public function __construct(
private readonly CanonicalIdentityResolver $identityResolver,
private readonly CoverageResourceIdentityEvaluator $identityEvaluator,
private readonly ClaimGuard $claimGuard,
private readonly CoverageTypeAuthority $coverageTypes,
) {}
/**
@ -59,7 +63,7 @@ public function upsert(
'canonical_type' => $canonicalType,
'canonical_key_kind' => $identity->keyKind->value,
'source_resource_id' => $identity->sourceResourceId,
'source_display_name' => $this->extractDisplayName($payload),
'source_display_name' => $this->sourceDisplayName($resourceType, $payload),
'source_metadata' => $this->jsonObject($sourceMetadata),
'identity_strategy' => $identity->strategyIdentifier,
'source_identity' => $identity->sourceIdentity,
@ -85,6 +89,28 @@ private function claimStateFor(
TenantConfigurationResourceType $resourceType,
CanonicalIdentityResult $identity,
): ClaimState {
$workload = $resourceType->workload instanceof Workload
? $resourceType->workload
: Workload::tryFrom((string) $resourceType->workload);
if ($workload === Workload::Exchange) {
if (in_array($identity->identityState, [
IdentityState::IdentityConflict,
IdentityState::MissingExternalId,
IdentityState::UnsupportedIdentity,
], true)) {
return ClaimState::ClaimBlocked;
}
$definition = $this->coverageTypes->find((string) $resourceType->canonical_type);
return $definition?->workload === Workload::Exchange
&& $definition->productClassification === CoverageProductClassification::InternalOnly
&& $definition->isCaptureEligible()
? ClaimState::InternalOnly
: ClaimState::ClaimBlocked;
}
$guarded = $this->claimGuard->evaluate(
scopeKey: 'intune_tcm_core',
requestedLevel: $resourceType->default_coverage_level,
@ -152,4 +178,25 @@ private function extractDisplayName(array $payload): ?string
return $displayName !== '' ? $displayName : null;
}
/**
* @param array<string, mixed> $payload
*/
private function sourceDisplayName(TenantConfigurationResourceType $resourceType, array $payload): ?string
{
$workload = $resourceType->workload instanceof Workload
? $resourceType->workload
: Workload::tryFrom((string) $resourceType->workload);
if ($workload !== Workload::Exchange) {
return $this->extractDisplayName($payload);
}
return match ((string) $resourceType->canonical_type) {
'transportRule' => __('localization.coverage_v2.exchange.protected_transport_rule'),
'remoteDomain' => __('localization.coverage_v2.exchange.protected_remote_domain'),
'inboundConnector' => __('localization.coverage_v2.exchange.protected_inbound_connector'),
default => __('localization.coverage_v2.exchange.protected_configuration'),
};
}
}

View File

@ -10,6 +10,8 @@
{
public const CONTRACT_VERIFIED_PENDING_CAPTURE = 'contract_verified_pending_capture';
public const CONTRACT_VERIFIED_CAPTURE_ENABLED = 'contract_verified_capture_enabled';
public const CONTRACT_BLOCKED_MISSING_SOURCE = 'contract_blocked_missing_source';
public const CONTRACT_BLOCKED_PERMISSION_UNCLEAR = 'contract_blocked_permission_unclear';
@ -35,6 +37,7 @@ public function __construct(
public CaptureOutcome $outcome,
public ?string $contractKey = null,
public ?string $sourceEndpoint = null,
public ?string $commandContractKey = null,
public string $sourceVersion = 'v1.0',
public ?string $sourceSchemaHash = null,
public ?string $reasonCode = null,
@ -52,6 +55,7 @@ public static function sourceContractStates(): array
{
return [
self::CONTRACT_VERIFIED_PENDING_CAPTURE,
self::CONTRACT_VERIFIED_CAPTURE_ENABLED,
self::CONTRACT_BLOCKED_MISSING_SOURCE,
self::CONTRACT_BLOCKED_PERMISSION_UNCLEAR,
self::CONTRACT_BLOCKED_BETA_ONLY,
@ -64,10 +68,12 @@ public static function sourceContractStates(): array
public function capturable(): bool
{
$hasSourceEndpoint = is_string($this->sourceEndpoint) && trim($this->sourceEndpoint) !== '';
$hasCommandContractKey = is_string($this->commandContractKey) && trim($this->commandContractKey) !== '';
return $this->outcome === CaptureOutcome::Captured
&& is_string($this->contractKey)
&& $this->contractKey !== ''
&& is_string($this->sourceEndpoint)
&& $this->sourceEndpoint !== '';
&& trim($this->contractKey) !== ''
&& ($hasSourceEndpoint xor $hasCommandContractKey);
}
}

View File

@ -301,14 +301,14 @@ private function verifiedExchangePowerShellAdapterContract(
return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping');
}
$state = CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE;
$state = CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED;
$contractKey = (string) $contract['contract_key'];
$metadata = [
'source_contract_key' => $contractKey,
'source_contract_name' => $contractKey,
'source_contract_state' => $state,
'contract_blocker_reason' => null,
'capture_eligibility_state' => 'pending_capture',
'capture_eligibility_state' => 'capture_enabled',
'source_class' => $sourceClass?->value,
'registry_source_class' => $sourceClass?->value,
'support_state' => $supportState?->value,
@ -320,7 +320,7 @@ private function verifiedExchangePowerShellAdapterContract(
'source_schema_hash' => $this->sourceSchemaHash($contract),
'source_schema_hash_available' => true,
'provider_adapter_state' => 'adapter_contract_available',
'provider_adapter_proof' => 'Spec 430 verifies a structured Exchange PowerShell command contract only; live execution is deferred.',
'provider_adapter_proof' => 'The structured Exchange PowerShell contract is Capture-enabled; live execution remains runtime-gated.',
'provider_calls_allowed' => false,
'execution_enabled' => false,
'fake_runner_testable' => true,
@ -349,11 +349,12 @@ private function verifiedExchangePowerShellAdapterContract(
return new CoverageSourceContractDecision(
canonicalType: $canonicalType,
outcome: CaptureOutcome::BlockedMissingContract,
outcome: CaptureOutcome::Captured,
contractKey: $contractKey,
commandContractKey: $contractKey,
sourceVersion: ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION,
sourceSchemaHash: $this->sourceSchemaHash($contract),
reasonCode: $state,
reasonCode: null,
sourceContractState: $state,
contract: $contract,
sourceMetadata: array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== ''),

View File

@ -172,7 +172,7 @@ final class CoverageTypeAuthority
['entraRoleDefinitions', 'entra', 'Role inventory', 'NON_COVERAGE_INVENTORY', 'NOT_APPLICABLE', 'NOT_REQUIRED', 'Entra inventory', [], [], 'Provider inventory metadata.'],
['externalAccessPolicy', 'teams', 'External access', 'FUTURE_PRODUCT_CANDIDATE', 'UNIMPLEMENTED', 'EXPLICITLY_BLOCKED', 'Teams family', [], [], 'Provider command explicitly excludes this type.'],
['groupPolicyConfiguration', 'intune', 'Group Policy inventory', 'NON_COVERAGE_INVENTORY', 'NOT_APPLICABLE', 'NOT_REQUIRED', 'Inventory owner', [], [], 'Reclassified as Inventory.'],
['inboundConnector', 'exchange', 'Connectors', 'FUTURE_PRODUCT_CANDIDATE', 'FOUNDATION_ONLY', 'EXPLICITLY_BLOCKED', 'Exchange family', ['inboundConnectors'], [], 'Provider metadata foundation.'],
['inboundConnector', 'exchange', 'Connectors', 'INTERNAL_ONLY', 'OPERATOR_PRODUCTIZED', 'INTERNAL_ONLY', 'Exchange family', ['inboundConnectors'], [], 'Internal Exchange Capture/Evidence type.'],
['intuneRoleAssignment', 'intune', 'RBAC inventory', 'NON_COVERAGE_INVENTORY', 'NOT_APPLICABLE', 'NOT_REQUIRED', 'Inventory owner', [], [], 'Reclassified as Inventory.'],
['intuneRoleDefinition', 'intune', 'RBAC inventory', 'NON_COVERAGE_INVENTORY', 'NOT_APPLICABLE', 'NOT_REQUIRED', 'Inventory owner', [], [], 'Reclassified as Inventory.'],
['labelPolicy', 'purview', 'Labels', 'FUTURE_PRODUCT_CANDIDATE', 'FOUNDATION_ONLY', 'EXPLICITLY_BLOCKED', 'Purview family', ['sensitivityLabelPolicy'], [], 'Provider metadata foundation.'],
@ -188,7 +188,7 @@ final class CoverageTypeAuthority
['outboundConnector', 'exchange', 'Connectors', 'FUTURE_PRODUCT_CANDIDATE', 'UNIMPLEMENTED', 'EXPLICITLY_BLOCKED', 'Exchange family', [], [], 'Provider command explicitly excludes this type.'],
['protectionAlert', 'purview', 'Alerts', 'FUTURE_PRODUCT_CANDIDATE', 'FOUNDATION_ONLY', 'EXPLICITLY_BLOCKED', 'Purview family', ['alertPolicy'], [], 'Provider metadata foundation.'],
['rbacRoleAssignment', 'intune', 'RBAC discovery', 'NON_COVERAGE_INVENTORY', 'NOT_APPLICABLE', 'NOT_REQUIRED', 'Inventory/Graph owner', [], [], 'Graph metadata only.'],
['remoteDomain', 'exchange', 'Domains', 'FUTURE_PRODUCT_CANDIDATE', 'FOUNDATION_ONLY', 'EXPLICITLY_BLOCKED', 'Exchange family', ['remoteDomains'], [], 'Provider metadata foundation.'],
['remoteDomain', 'exchange', 'Domains', 'INTERNAL_ONLY', 'OPERATOR_PRODUCTIZED', 'INTERNAL_ONLY', 'Exchange family', ['remoteDomains'], [], 'Internal Exchange Capture/Evidence type.'],
['retentionCompliancePolicy', 'purview', 'Retention', 'FUTURE_PRODUCT_CANDIDATE', 'FOUNDATION_ONLY', 'EXPLICITLY_BLOCKED', 'Purview family', ['retentionPolicy'], [], 'Provider metadata foundation.'],
['roleDefinition', 'entra', 'Directory governance', 'FUTURE_PRODUCT_CANDIDATE', 'FOUNDATION_ONLY', 'EXPLICITLY_BLOCKED', 'Entra family', ['directoryRoleDefinition'], [], 'Provider metadata foundation.'],
['roleScopeTag', 'intune', 'RBAC foundation', 'FUTURE_PRODUCT_CANDIDATE', 'FOUNDATION_ONLY', 'EXPLICITLY_BLOCKED', 'Intune future', [], [], 'Experimental foundation only.'],
@ -203,7 +203,7 @@ final class CoverageTypeAuthority
['teamsChannelsPolicy', 'teams', 'Channels', 'FUTURE_PRODUCT_CANDIDATE', 'UNIMPLEMENTED', 'EXPLICITLY_BLOCKED', 'Teams family', [], [], 'Provider command explicitly excludes this type.'],
['teamsUpdateManagementPolicy', 'teams', 'Update management', 'FUTURE_PRODUCT_CANDIDATE', 'FOUNDATION_ONLY', 'EXPLICITLY_BLOCKED', 'Teams family', ['updateManagementPolicy'], [], 'Provider metadata foundation.'],
['termsAndConditions', 'intune', 'Terms inventory', 'NON_COVERAGE_INVENTORY', 'NOT_APPLICABLE', 'NOT_REQUIRED', 'Inventory owner', [], [], 'Reclassified as Inventory.'],
['transportRule', 'exchange', 'Mail flow', 'FUTURE_PRODUCT_CANDIDATE', 'FOUNDATION_ONLY', 'EXPLICITLY_BLOCKED', 'Exchange family', ['mailFlowRule'], [], 'Provider metadata foundation.'],
['transportRule', 'exchange', 'Mail flow', 'INTERNAL_ONLY', 'OPERATOR_PRODUCTIZED', 'INTERNAL_ONLY', 'Exchange family', ['mailFlowRule'], [], 'Internal Exchange Capture/Evidence type.'],
['voiceRoute', 'teams', 'Voice', 'FUTURE_PRODUCT_CANDIDATE', 'FOUNDATION_ONLY', 'EXPLICITLY_BLOCKED', 'Teams family', ['onlineVoiceRoute'], [], 'Provider metadata foundation.'],
['windowsAutopilotDeploymentProfile', 'intune', 'Autopilot inventory', 'NON_COVERAGE_INVENTORY', 'NOT_APPLICABLE', 'NOT_REQUIRED', 'Inventory owner', [], [], 'Reclassified as Inventory.'],
['windowsDriverUpdateProfile', 'intune', 'Update inventory', 'NON_COVERAGE_INVENTORY', 'NOT_APPLICABLE', 'NOT_REQUIRED', 'Inventory owner', [], [], 'Reclassified as Inventory.'],
@ -546,6 +546,10 @@ private function buildNormativeDefinitions(): array
displayName: self::NORMATIVE_DISPLAY_NAME_OVERRIDES[$row[0]]
?? Str::headline($row[0]),
classificationReason: $row[9],
internalCompareEligible: in_array($row[0], [
'conditionalAccessPolicy',
'securityDefaults',
], true),
),
self::DEFINITION_ROWS,
);
@ -630,6 +634,7 @@ private function comparableDefinition(CoverageTypeDefinition $definition): array
'source_metadata' => $definition->sourceMetadata,
'display_name' => $definition->displayName,
'classification_reason' => $definition->classificationReason,
'internal_compare_eligible' => $definition->internalCompareEligible,
];
}

View File

@ -18,6 +18,7 @@
use App\Support\OperationRunType;
use App\Support\Providers\Capabilities\ProviderCapabilityEvaluator;
use App\Support\Providers\Capabilities\ProviderCapabilityResult;
use App\Support\Providers\Capabilities\ProviderCapabilityStatus;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\CaptureTypeOutcome;
use App\Support\TenantConfiguration\ClaimState;
@ -46,16 +47,18 @@ public function __construct(
private readonly ProviderCapabilityEvaluator $providerCapabilities,
private readonly SupportedScopeResolver $supportedScopes,
private readonly CoverageTypeAuthority $coverageTypes,
private readonly ExchangeCoverageCaptureCohortPolicy $exchangeCaptureCohort,
private readonly ExchangePowerShellCommandContracts $exchangePowerShellContracts,
) {}
/**
* @return Builder<TenantConfigurationResourceType>
*/
public function resourceTypeQuery(): Builder
public function resourceTypeQuery(Workload|string $workload = Workload::Intune): Builder
{
return TenantConfigurationResourceType::query()
->active()
->whereIn('canonical_type', $this->productVisibleCanonicalTypes())
->whereIn('canonical_type', $this->canonicalTypesForWorkload($this->supportedWorkload($workload)))
->orderBy('workload')
->orderBy('source_class')
->orderBy('canonical_type');
@ -64,7 +67,11 @@ public function resourceTypeQuery(): Builder
/**
* @return Builder<TenantConfigurationResource>
*/
public function resourceInstanceQuery(ManagedEnvironment $environment, ?ProviderConnection $providerConnection = null): Builder
public function resourceInstanceQuery(
ManagedEnvironment $environment,
?ProviderConnection $providerConnection = null,
Workload|string|null $workload = null,
): Builder
{
$query = TenantConfigurationResource::query()
->where('workspace_id', (int) $environment->workspace_id)
@ -82,31 +89,51 @@ public function resourceInstanceQuery(ManagedEnvironment $environment, ?Provider
$query->where('provider_connection_id', (int) $providerConnection->getKey());
}
if ($workload !== null) {
$query->whereIn(
'canonical_type',
$this->canonicalTypesForWorkload($this->supportedWorkload($workload)),
);
}
return $query;
}
/**
* @return array<string, mixed>
*/
public function summary(ManagedEnvironment $environment, ?ProviderConnection $providerConnection = null): array
public function summary(
ManagedEnvironment $environment,
?ProviderConnection $providerConnection = null,
Workload|string $workload = Workload::Intune,
): array
{
$workload = $this->supportedWorkload($workload);
if (! $providerConnection instanceof ProviderConnection) {
return [
'readiness_state' => 'unknown',
'readiness_reason' => __('localization.coverage_v2.select_provider_to_evaluate'),
'readiness_next_step' => __('localization.coverage_v2.select_provider'),
'readiness_state' => $workload === Workload::Exchange ? 'not_configured' : 'unknown',
'readiness_reason' => $workload === Workload::Exchange
? __('localization.coverage_v2.exchange.not_configured')
: __('localization.coverage_v2.select_provider_to_evaluate'),
'readiness_next_step' => $workload === Workload::Exchange
? __('localization.coverage_v2.exchange.configure_provider')
: __('localization.coverage_v2.select_provider'),
'provider_connection' => null,
'metrics' => [],
'type_outcomes' => [],
'resources_total' => 0,
'capture_in_progress' => false,
'fresh_until' => null,
'workload' => $workload->value,
];
}
$this->assertProviderConnectionScope($environment, $providerConnection);
return $this->captureTruthSummary($environment, $providerConnection);
return $workload === Workload::Exchange
? $this->exchangeCaptureTruthSummary($environment, $providerConnection)
: $this->captureTruthSummary($environment, $providerConnection);
}
/**
@ -156,9 +183,52 @@ public function providerContext(ManagedEnvironment $environment, int|string|null
/**
* @return array{eligible: bool, reason: string, next_action_label: ?string, next_action_url: ?string, result: ?ProviderCapabilityResult}
*/
public function captureEligibility(ManagedEnvironment $environment, ProviderConnection $providerConnection): array
public function captureEligibility(
ManagedEnvironment $environment,
ProviderConnection $providerConnection,
Workload|string $workload = Workload::Intune,
): array
{
$this->assertProviderConnectionScope($environment, $providerConnection);
$workload = $this->supportedWorkload($workload);
if ($workload === Workload::Exchange) {
try {
$this->exchangeCaptureCohort->resolvePersisted();
} catch (UnexpectedValueException) {
return [
'eligible' => false,
'reason' => __('localization.coverage_v2.exchange.cohort_unavailable'),
'next_action_label' => null,
'next_action_url' => null,
'result' => null,
];
}
$result = $this->providerCapabilities->evaluate(
$environment,
$providerConnection,
'exchange_powershell_invoke',
);
if ($result->status !== ProviderCapabilityStatus::Supported) {
return [
'eligible' => false,
'reason' => __('localization.coverage_v2.exchange.prerequisites_blocked'),
'next_action_label' => $result->nextStepLabel,
'next_action_url' => $result->nextStepUrl,
'result' => $result,
];
}
return [
'eligible' => true,
'reason' => __('localization.coverage_v2.selected_provider_ready'),
'next_action_label' => __('localization.coverage_v2.capture_configuration'),
'next_action_url' => null,
'result' => $result,
];
}
try {
$this->supportedScopes->resolveIntuneCoreCaptureCohort();
@ -432,6 +502,315 @@ private function captureTruthSummary(
];
}
/**
* @return array<string, mixed>
*/
private function exchangeCaptureTruthSummary(
ManagedEnvironment $environment,
ProviderConnection $providerConnection,
): array {
$canonicalTypes = $this->canonicalTypesForWorkload(Workload::Exchange);
try {
$resourceTypes = $this->exchangeCaptureCohort
->resolvePersisted()
->keyBy('canonical_type');
$registryResourceTypes = $resourceTypes;
$cohortReady = true;
} catch (UnexpectedValueException) {
$resourceTypes = collect();
$registryResourceTypes = TenantConfigurationResourceType::query()
->where('workload', Workload::Exchange->value)
->whereIn('canonical_type', $canonicalTypes)
->get()
->keyBy('canonical_type');
$cohortReady = false;
}
$resourceTypeIds = $registryResourceTypes
->pluck('id')
->map(static fn (mixed $id): int => (int) $id)
->all();
$captureTypeResults = TenantConfigurationCaptureTypeResult::query()
->where('workspace_id', (int) $environment->workspace_id)
->where('managed_environment_id', (int) $environment->getKey())
->where('provider_connection_id', (int) $providerConnection->getKey())
->whereIn('resource_type_id', $resourceTypeIds)
->with([
'operationRun:id,workspace_id,managed_environment_id,type,status,outcome,created_at,started_at,completed_at',
])
->latest('created_at')
->latest('id')
->get([
'id',
'workspace_id',
'managed_environment_id',
'provider_connection_id',
'operation_run_id',
'resource_type_id',
'source_contract_key',
'source_version',
'source_schema_hash',
'outcome',
'item_count',
'evidence_count',
'source_page_count',
'reason_code',
'started_at',
'completed_at',
'created_at',
]);
$resultsByTypeId = $captureTypeResults->groupBy('resource_type_id');
$resourceCountsByTypeId = TenantConfigurationResource::query()
->where('workspace_id', (int) $environment->workspace_id)
->where('managed_environment_id', (int) $environment->getKey())
->where('provider_connection_id', (int) $providerConnection->getKey())
->whereIn('resource_type_id', $resourceTypeIds)
->select('resource_type_id')
->selectRaw('COUNT(*) AS resource_count')
->groupBy('resource_type_id')
->get()
->mapWithKeys(static fn (TenantConfigurationResource $resource): array => [
(int) $resource->resource_type_id => (int) $resource->getAttribute('resource_count'),
]);
$evidenceCountsByResult = TenantConfigurationResourceEvidence::query()
->where('workspace_id', (int) $environment->workspace_id)
->where('managed_environment_id', (int) $environment->getKey())
->where('provider_connection_id', (int) $providerConnection->getKey())
->where('capture_outcome', CaptureOutcome::Captured->value)
->whereIn('resource_type_id', $resourceTypeIds)
->select(['resource_type_id', 'operation_run_id'])
->selectRaw('COUNT(DISTINCT resource_id) AS resource_count')
->selectRaw('COUNT(*) AS evidence_count')
->groupBy('resource_type_id', 'operation_run_id')
->get()
->mapWithKeys(static fn (TenantConfigurationResourceEvidence $evidence): array => [
self::resultEvidenceCountKey(
(int) $evidence->resource_type_id,
(int) $evidence->operation_run_id,
) => [
'resource_count' => (int) $evidence->getAttribute('resource_count'),
'evidence_count' => (int) $evidence->getAttribute('evidence_count'),
],
]);
$currentnessHours = $this->exchangeCurrentnessHours();
$evaluatedAt = now();
$cutoff = $evaluatedAt->copy()->subHours($currentnessHours);
$rows = [];
foreach ($canonicalTypes as $canonicalType) {
/** @var TenantConfigurationResourceType|null $resourceType */
$resourceType = $resourceTypes->get($canonicalType);
/** @var TenantConfigurationResourceType|null $registryResourceType */
$registryResourceType = $registryResourceTypes->get($canonicalType);
$typeId = $resourceType instanceof TenantConfigurationResourceType
? (int) $resourceType->getKey()
: null;
$typeResults = $typeId !== null
? $resultsByTypeId->get($typeId, collect())
: collect();
$activeAttempt = $typeResults
->filter(fn (TenantConfigurationCaptureTypeResult $result): bool => $result->outcome === null
&& $result->operationRun instanceof OperationRun
&& $this->runIsActive($result->operationRun)
)
->sortByDesc(fn (TenantConfigurationCaptureTypeResult $result): string => $this->resultOrderKey($result, 'created_at'))
->first();
$latestTerminalAttempt = $typeResults
->filter(fn (TenantConfigurationCaptureTypeResult $result): bool => $result->outcome instanceof CaptureTypeOutcome
&& $result->completed_at !== null
)
->sortByDesc(fn (TenantConfigurationCaptureTypeResult $result): string => $this->resultOrderKey($result, 'completed_at'))
->first();
$latestAttempt = $activeAttempt instanceof TenantConfigurationCaptureTypeResult
? $activeAttempt
: $latestTerminalAttempt;
$latestSuccess = $typeResults
->filter(fn (TenantConfigurationCaptureTypeResult $result): bool => $result->outcome instanceof CaptureTypeOutcome
&& $result->outcome->isSuccessfulComplete()
&& $result->completed_at !== null
&& $this->exchangeSuccessHasConsistentEvidence($result, $evidenceCountsByResult)
)
->sortByDesc(fn (TenantConfigurationCaptureTypeResult $result): string => $this->resultOrderKey($result, 'completed_at'))
->first();
$lastSuccessAt = $latestSuccess?->completed_at;
$successCounts = $latestSuccess instanceof TenantConfigurationCaptureTypeResult
? $evidenceCountsByResult->get(
self::resultEvidenceCountKey(
(int) $latestSuccess->resource_type_id,
(int) $latestSuccess->operation_run_id,
),
['resource_count' => 0, 'evidence_count' => 0],
)
: ['resource_count' => 0, 'evidence_count' => 0];
$resultKind = match ($latestSuccess?->outcome) {
CaptureTypeOutcome::SuccessWithItems => 'resources',
CaptureTypeOutcome::SuccessEmpty => 'empty',
default => 'none',
};
$currentness = $lastSuccessAt === null
? 'absent'
: ($lastSuccessAt->gte($cutoff) ? 'current' : 'stale');
$attemptReasonCode = $latestAttempt instanceof TenantConfigurationCaptureTypeResult
&& is_string($latestAttempt->reason_code)
? $latestAttempt->reason_code
: null;
$needsAttention = $latestAttempt instanceof TenantConfigurationCaptureTypeResult
&& in_array($latestAttempt->outcome, [
CaptureTypeOutcome::Blocked,
CaptureTypeOutcome::Failed,
CaptureTypeOutcome::Partial,
CaptureTypeOutcome::NotAttempted,
], true);
$rows[] = [
'canonical_type' => $canonicalType,
'inspectable' => $registryResourceType instanceof TenantConfigurationResourceType,
'display_name' => $registryResourceType?->display_name ?? str($canonicalType)->headline()->toString(),
'attempt_state' => $this->attemptState($latestAttempt),
'attempt_at' => $latestAttempt?->completed_at ?? $latestAttempt?->started_at ?? $latestAttempt?->created_at,
'attempt_reason_code' => $attemptReasonCode,
'attempt_reason' => $this->captureAttemptReason($attemptReasonCode),
'result_state' => $resultKind,
'currentness' => $currentness,
'last_success_at' => $lastSuccessAt,
'current' => $currentness === 'current',
'needs_attention' => $needsAttention,
'resource_count' => (int) ($successCounts['resource_count'] ?? 0),
'evidence_count' => (int) ($successCounts['evidence_count'] ?? 0),
];
}
$rowsCollection = collect($rows);
$currentResults = $rowsCollection->where('current', true)->count();
$stale = $rowsCollection->where('currentness', 'stale')->count();
$noSuccessfulResult = $rowsCollection->where('currentness', 'absent')->count();
$needsAttention = $rowsCollection->where('needs_attention', true)->count();
$failedAttempts = $rowsCollection->where('attempt_state', 'failed')->count();
$oldestCurrentSuccess = $currentResults === count($canonicalTypes)
? $rowsCollection->pluck('last_success_at')->filter()->sortBy(
fn (mixed $value): int => $value->getTimestamp(),
)->first()
: null;
$freshUntil = $oldestCurrentSuccess instanceof \DateTimeInterface
? CarbonImmutable::instance($oldestCurrentSuccess)->addHours($currentnessHours)
: null;
$hasActiveRun = $captureTypeResults->contains(
fn (TenantConfigurationCaptureTypeResult $result): bool => $result->outcome === null
&& $result->operationRun instanceof OperationRun
&& $this->runIsActive($result->operationRun),
);
$eligibility = $cohortReady
? $this->captureEligibility($environment, $providerConnection, Workload::Exchange)
: [
'eligible' => false,
'reason' => __('localization.coverage_v2.exchange.cohort_unavailable'),
'next_action_label' => null,
'next_action_url' => null,
'result' => null,
];
$readinessState = match (true) {
$hasActiveRun => 'running',
! $eligibility['eligible'] => 'blocked',
$currentResults === count($canonicalTypes) && $needsAttention === 0 => 'ready',
$stale === count($canonicalTypes) && $needsAttention === 0 => 'expired',
$failedAttempts > 0 && $currentResults === 0 => 'failed',
default => 'needs_attention',
};
$reason = match ($readinessState) {
'running' => __('localization.coverage_v2.capture_running'),
'blocked' => (string) $eligibility['reason'],
'ready' => __('localization.coverage_v2.exchange.all_current'),
'expired' => __('localization.coverage_v2.exchange.expired'),
'failed' => __('localization.coverage_v2.attempt_needs_attention'),
default => __('localization.coverage_v2.exchange.results_need_attention'),
};
return [
'readiness_state' => $readinessState,
'readiness_reason' => $reason,
'readiness_next_step' => $readinessState === 'blocked'
? ($eligibility['next_action_label'] ?? __('localization.coverage_v2.resolve_provider_prerequisites'))
: ($readinessState === 'running'
? __('localization.coverage_v2.wait_for_capture')
: ($readinessState === 'ready'
? __('localization.coverage_v2.no_action_required')
: __('localization.coverage_v2.capture_configuration'))),
'provider_connection' => [
'id' => (int) $providerConnection->getKey(),
'label' => (string) $providerConnection->display_name,
],
'workload' => Workload::Exchange->value,
'capture_cohort' => ExchangeCoverageCaptureCohortPolicy::IDENTIFIER,
'capture_eligibility' => $eligibility,
'capture_in_progress' => $hasActiveRun,
'fresh_until' => $freshUntil,
'cohort_ready' => $cohortReady,
'resource_types_total' => count($canonicalTypes),
'resources_total' => $resourceCountsByTypeId->sum(),
'evidence_total' => $rowsCollection->sum('evidence_count'),
'metrics' => [
__('localization.coverage_v2.metrics.current_results') => sprintf('%d/%d', $currentResults, count($canonicalTypes)),
__('localization.coverage_v2.metrics.stale') => $stale,
__('localization.coverage_v2.metrics.no_successful_result') => $noSuccessfulResult,
__('localization.coverage_v2.metrics.latest_attempt_needs_attention') => $needsAttention,
],
'current_results_count' => $currentResults,
'stale_count' => $stale,
'no_success_count' => $noSuccessfulResult,
'latest_attempt_needs_attention_count' => $needsAttention,
'type_outcomes' => $rows,
];
}
/**
* @param Collection<string, array{resource_count: int, evidence_count: int}> $evidenceCountsByResult
*/
private function exchangeSuccessHasConsistentEvidence(
TenantConfigurationCaptureTypeResult $result,
Collection $evidenceCountsByResult,
): bool {
$counts = $evidenceCountsByResult->get(
self::resultEvidenceCountKey(
(int) $result->resource_type_id,
(int) $result->operation_run_id,
),
['resource_count' => 0, 'evidence_count' => 0],
);
$evidenceCount = (int) ($counts['evidence_count'] ?? 0);
return match ($result->outcome) {
CaptureTypeOutcome::SuccessWithItems => $result->item_count > 0
&& $result->evidence_count > 0
&& $evidenceCount === (int) $result->evidence_count,
CaptureTypeOutcome::SuccessEmpty => $result->item_count === 0
&& $result->evidence_count === 0
&& $evidenceCount === 0,
default => false,
};
}
private static function resultEvidenceCountKey(int $resourceTypeId, int $operationRunId): string
{
return $resourceTypeId.':'.$operationRunId;
}
private function exchangeCurrentnessHours(): int
{
$configured = config('tenantpilot.coverage_v2.exchange_powershell_currentness_hours');
if (! is_int($configured) || $configured < 1) {
throw new UnexpectedValueException('Exchange PowerShell currentness configuration is invalid.');
}
return $configured;
}
private function runIsActive(OperationRun $run): bool
{
return in_array((string) $run->status, [
@ -688,11 +1067,13 @@ private function typedRenderSummary(TenantConfigurationResource $resource): ?arr
{
$evidence = $resource->latestEvidence;
$canonicalType = (string) $resource->canonical_type;
$definition = $this->coverageTypes->find($canonicalType);
if (
! $evidence instanceof TenantConfigurationResourceEvidence
|| ! $this->isRenderableEvidenceForResource($resource, $evidence)
|| ! is_array($evidence->normalized_payload)
|| ($definition?->workload === Workload::Exchange && ! $definition->internalCompareEligible)
) {
return null;
}
@ -914,15 +1295,19 @@ private static function compareToken(mixed $value, string $fallback): string
*/
public function resourceTypeInspectDetails(TenantConfigurationResourceType $resourceType, ?string $scopeKey = null): array
{
$scope = $this->readinessVisibleScope($scopeKey);
$scopeKey = $scope instanceof TenantConfigurationSupportedScope
? (string) $scope->scope_key
: null;
$workload = self::safeStateValue($resourceType->workload);
$isExchange = $workload === Workload::Exchange->value;
$scope = $isExchange ? null : $this->readinessVisibleScope($scopeKey);
$scopeKey = $isExchange
? ExchangeCoverageCaptureCohortPolicy::IDENTIFIER
: ($scope instanceof TenantConfigurationSupportedScope
? (string) $scope->scope_key
: null);
return [
'name' => (string) $resourceType->display_name,
'canonical_type' => (string) $resourceType->canonical_type,
'workload' => self::humanize(self::safeStateValue($resourceType->workload)),
'workload' => self::humanize($workload),
'resource_class' => self::humanize(self::safeStateValue($resourceType->resource_class)),
'source_class' => self::safeStateValue($resourceType->source_class),
'support_state' => self::safeStateValue($resourceType->support_state),
@ -931,10 +1316,14 @@ public function resourceTypeInspectDetails(TenantConfigurationResourceType $reso
'default_identity_state' => self::safeStateValue($resourceType->default_identity_state),
'default_claim_state' => self::safeStateValue($resourceType->default_claim_state),
'restore_tier' => self::humanize(self::safeStateValue($resourceType->restore_tier)),
'supported_scope' => $scope instanceof TenantConfigurationSupportedScope
? $this->scopeInclusionLabel($resourceType, $scopeKey)
: 'No active scope',
'scope' => $scope instanceof TenantConfigurationSupportedScope ? (string) $scope->display_name : null,
'supported_scope' => $isExchange
? __('localization.coverage_v2.exchange.type_scope_included')
: ($scope instanceof TenantConfigurationSupportedScope
? $this->scopeInclusionLabel($resourceType, $scopeKey)
: 'No active scope'),
'scope' => $isExchange
? __('localization.coverage_v2.exchange.type_scope')
: ($scope instanceof TenantConfigurationSupportedScope ? (string) $scope->display_name : null),
'scope_key' => $scopeKey,
'allows_beta_claims' => (bool) $resourceType->allows_beta_claims,
'allows_graph_fallback_claims' => (bool) $resourceType->allows_graph_fallback_claims,
@ -1194,4 +1583,27 @@ private function productVisibleCanonicalTypes(): array
$this->coverageTypes->productVisibleDefinitions(),
);
}
private function supportedWorkload(Workload|string $workload): Workload
{
$workload = $workload instanceof Workload
? $workload
: Workload::tryFrom(trim($workload));
if (! in_array($workload, [Workload::Intune, Workload::Exchange], true)) {
throw new UnexpectedValueException('Coverage v2 readiness workload is unsupported.');
}
return $workload;
}
/**
* @return list<string>
*/
private function canonicalTypesForWorkload(Workload $workload): array
{
return $workload === Workload::Exchange
? $this->exchangePowerShellContracts->includedCanonicalTypes()
: $this->productVisibleCanonicalTypes();
}
}

View File

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
use App\Models\TenantConfigurationResourceType;
use App\Support\TenantConfiguration\Workload;
use Illuminate\Database\Eloquent\Collection;
use UnexpectedValueException;
final class ExchangeCoverageCaptureCohortPolicy
{
public const string IDENTIFIER = 'exchange_powershell_capture.v1';
public function __construct(
private readonly CoverageTypeAuthority $authority,
private readonly ResourceTypeRegistry $resourceTypes,
private readonly CoverageSourceContractResolver $sourceContracts,
private readonly ExchangePowerShellCommandContracts $commandContracts,
) {}
public function identifier(): string
{
return self::IDENTIFIER;
}
/**
* @return Collection<int, TenantConfigurationResourceType>
*/
public function resolve(): Collection
{
$orderedContractTypes = $this->commandContracts->includedCanonicalTypes();
$authorityTypes = collect($this->authority->internalCaptureEligibleDefinitions())
->filter(static fn ($definition): bool => $definition->workload === Workload::Exchange)
->pluck('canonicalKey')
->sort()
->values()
->all();
$expectedAuthorityTypes = collect($orderedContractTypes)->sort()->values()->all();
if ($authorityTypes !== $expectedAuthorityTypes) {
throw new UnexpectedValueException('The Exchange Capture authority does not match the command-contract cohort.');
}
$registryDefinitions = collect(ResourceTypeRegistry::defaultDefinitions())
->keyBy('canonical_type');
$resolved = new Collection;
foreach ($orderedContractTypes as $canonicalType) {
$definition = $registryDefinitions->get($canonicalType);
$resourceType = is_array($definition)
? new TenantConfigurationResourceType($definition)
: null;
if (! $resourceType instanceof TenantConfigurationResourceType
|| ! (bool) $resourceType->is_active
|| $resourceType->workload !== Workload::Exchange
) {
throw new UnexpectedValueException("The active Exchange Capture type {$canonicalType} is unavailable.");
}
$decision = $this->sourceContracts->resolve($resourceType);
if (! $decision->capturable()
|| $decision->sourceContractState !== CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED
|| $decision->commandContractKey !== 'exchange_powershell.'.$canonicalType
) {
throw new UnexpectedValueException("The Exchange Capture Source contract {$canonicalType} is unavailable.");
}
$resolved->push($resourceType);
}
return $resolved;
}
/**
* @return Collection<int, TenantConfigurationResourceType>
*/
public function resolvePersisted(): Collection
{
return $this->resolve()
->map(function (TenantConfigurationResourceType $planned): TenantConfigurationResourceType {
$persisted = $this->resourceTypes->findActive((string) $planned->canonical_type);
if (! $persisted instanceof TenantConfigurationResourceType
|| $persisted->workload !== Workload::Exchange
) {
throw new UnexpectedValueException(
"The persisted Exchange Capture type {$planned->canonical_type} is unavailable.",
);
}
$decision = $this->sourceContracts->resolve($persisted);
if (! $decision->capturable()
|| $decision->commandContractKey !== 'exchange_powershell.'.$planned->canonical_type
) {
throw new UnexpectedValueException(
"The persisted Exchange Capture contract {$planned->canonical_type} is unavailable.",
);
}
return $persisted;
})
->values();
}
}

View File

@ -0,0 +1,339 @@
<?php
declare(strict_types=1);
namespace App\Services\TenantConfiguration;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationCaptureTypeResult;
use App\Models\TenantConfigurationResourceType;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\TenantConfiguration\CaptureTypeOutcome;
use App\Support\TenantConfiguration\ExchangePowerShellBatchContinuation;
use LogicException;
use RuntimeException;
use Throwable;
class ExchangeCoverageCaptureConsumer
{
public function __construct(
private readonly CaptureTypeResultWriter $typeResults,
private readonly CoverageSourceContractResolver $sourceContracts,
private readonly ExchangePowerShellCommandContracts $commandContracts,
private readonly ExchangePowerShellEvidenceNormalizer $normalizer,
private readonly ExchangePowerShellHashInputBuilder $hashes,
private readonly ExchangePowerShellIdentityEvidenceGate $identityGate,
private readonly CoverageResourceUpserter $resourceUpserter,
private readonly CoverageEvidenceWriter $evidenceWriter,
private readonly ExchangePowerShellContentOnlyEvidenceGuard $contentOnlyGuard,
) {}
public function markStarted(OperationRun $parentCaptureRun, string $canonicalType): void
{
$this->typeResults->markStarted(
$parentCaptureRun,
$this->resourceType($parentCaptureRun, $canonicalType),
);
}
public function consumeReceipt(
OperationRun $parentCaptureRun,
string $canonicalType,
ExchangePowerShellInvocationReceipt $receipt,
): ExchangePowerShellBatchContinuation {
$resourceType = $this->resourceType($parentCaptureRun, $canonicalType);
$result = $receipt->invocationResult();
$this->assertReceiptContract(
$parentCaptureRun,
$canonicalType,
$receipt,
);
if ($result->blocked) {
$this->typeResults->finalize(
run: $parentCaptureRun,
resourceType: $resourceType,
outcome: CaptureTypeOutcome::Blocked,
itemCount: 0,
evidenceCount: 0,
reasonCode: $result->reasonCode ?? ProviderReasonCodes::ProviderBindingUnsupported,
);
return ExchangePowerShellBatchContinuation::Stop;
}
if (! $result->successful) {
$this->typeResults->finalize(
run: $parentCaptureRun,
resourceType: $resourceType,
outcome: CaptureTypeOutcome::Failed,
itemCount: 0,
evidenceCount: 0,
reasonCode: $result->reasonCode ?? ProviderReasonCodes::UnknownError,
);
return $this->isFatalInvocationFailure($result->failureCode)
? ExchangePowerShellBatchContinuation::Stop
: ExchangePowerShellBatchContinuation::Continue;
}
$contract = $this->commandContracts->contractForCanonicalType($canonicalType);
if (! is_array($contract)) {
throw new LogicException('Exchange command contract is unavailable.');
}
$decision = $this->sourceContracts->resolve($resourceType);
$envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: $canonicalType,
contract: ExchangePowerShellCommandContract::fromVerifiedArray(
$contract,
$this->commandContracts,
[],
),
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
result: $result,
);
$readiness = $this->normalizer->evaluate($envelope);
if (! $readiness->ready) {
$this->typeResults->finalize(
run: $parentCaptureRun,
resourceType: $resourceType,
outcome: CaptureTypeOutcome::Failed,
itemCount: 0,
evidenceCount: 0,
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
);
return ExchangePowerShellBatchContinuation::Continue;
}
if ($readiness->emptyCollection) {
$this->typeResults->finalize(
run: $parentCaptureRun,
resourceType: $resourceType,
outcome: CaptureTypeOutcome::SuccessEmpty,
itemCount: 0,
evidenceCount: 0,
sourcePageCount: 1,
decision: $decision,
);
return ExchangePowerShellBatchContinuation::Continue;
}
$items = $this->normalizer->normalizedEvidenceItems($envelope);
$tenant = $parentCaptureRun->tenant()->first();
$connection = $this->providerConnection($parentCaptureRun);
if (! $tenant || count($items) !== $readiness->itemCount) {
throw new RuntimeException('Exchange Capture persistence scope is unavailable.');
}
$identity = $this->identityGate->evaluateCollection(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
items: array_map(
static fn (array $item): array => $item['raw_payload'],
$items,
),
sourceMetadata: $decision->sourceMetadata,
);
if (($identity['allowed'] ?? false) !== true) {
$this->typeResults->finalize(
run: $parentCaptureRun,
resourceType: $resourceType,
outcome: CaptureTypeOutcome::Failed,
itemCount: 0,
evidenceCount: 0,
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
);
return ExchangePowerShellBatchContinuation::Continue;
}
try {
foreach ($items as $item) {
$rawPayload = $item['raw_payload'];
$normalizedPayload = [
...$item['normalized_payload'],
'source' => array_filter([
'source_contract_key' => $decision->contractKey,
'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 !== ''),
];
$payloadHash = $this->hashes->hash($this->hashes->build(
resourceType: $canonicalType,
sourceSurface: $envelope->sourceSurface,
commandContractName: $envelope->commandContractName,
commandContractVersion: $envelope->commandContractVersion,
payloadShapeVersion: (string) $readiness->definition['payload_shape_version'],
normalizerVersion: (string) $readiness->definition['normalizer_version'],
normalizedPayload: $normalizedPayload,
));
$resource = $this->resourceUpserter->upsert(
tenant: $tenant,
providerConnection: $connection,
resourceType: $resourceType,
payload: $rawPayload,
sourceMetadata: $decision->sourceMetadata,
);
$evidence = $this->evidenceWriter->append(
resource: $resource,
resourceType: $resourceType,
providerConnection: $connection,
operationRun: $parentCaptureRun,
decision: $decision,
rawPayload: $rawPayload,
normalizedPayload: $normalizedPayload,
payloadHash: $payloadHash,
permissionContext: [
'permission_evidence_state' => 'verified',
'provider_connection_id' => (int) $connection->getKey(),
'technical_operation_run_id' => (int) $receipt->technicalRun()->getKey(),
],
maximumCoverageLevel: $this->contentOnlyGuard->maximumCoverageLevel(),
);
$this->contentOnlyGuard->assertContentOnly(
$evidence,
$resource->refresh(),
);
}
} catch (Throwable) {
$evidenceCount = $this->typeResults->committedEvidenceCount(
$parentCaptureRun,
$resourceType,
);
$this->typeResults->finalize(
run: $parentCaptureRun,
resourceType: $resourceType,
outcome: $evidenceCount > 0
? CaptureTypeOutcome::Partial
: CaptureTypeOutcome::Failed,
itemCount: $evidenceCount,
evidenceCount: $evidenceCount,
reasonCode: ProviderReasonCodes::UnknownError,
decision: $evidenceCount > 0 ? $decision : null,
);
return ExchangePowerShellBatchContinuation::Continue;
} finally {
unset($items);
}
$evidenceCount = $this->typeResults->committedEvidenceCount(
$parentCaptureRun,
$resourceType,
);
$this->typeResults->finalize(
run: $parentCaptureRun,
resourceType: $resourceType,
outcome: CaptureTypeOutcome::SuccessWithItems,
itemCount: $readiness->itemCount,
evidenceCount: $evidenceCount,
sourcePageCount: 1,
decision: $decision,
);
return ExchangePowerShellBatchContinuation::Continue;
}
public function blockAll(OperationRun $parentCaptureRun, string $reasonCode): void
{
foreach ($this->commandContracts->includedCanonicalTypes() as $canonicalType) {
$resourceType = $this->resourceType($parentCaptureRun, $canonicalType);
$this->typeResults->markStarted($parentCaptureRun, $resourceType);
$this->typeResults->finalize(
run: $parentCaptureRun,
resourceType: $resourceType,
outcome: CaptureTypeOutcome::Blocked,
itemCount: 0,
evidenceCount: 0,
reasonCode: $reasonCode,
);
}
}
private function resourceType(
OperationRun $parentCaptureRun,
string $canonicalType,
): TenantConfigurationResourceType {
$result = TenantConfigurationCaptureTypeResult::query()
->where('operation_run_id', (int) $parentCaptureRun->getKey())
->whereHas(
'resourceType',
static fn ($query) => $query->where('canonical_type', $canonicalType),
)
->with('resourceType')
->first();
$resourceType = $result?->resourceType;
if (! $resourceType instanceof TenantConfigurationResourceType) {
throw new RuntimeException('Exchange Capture resource type is outside the trusted parent Run plan.');
}
return $resourceType;
}
private function providerConnection(OperationRun $parentCaptureRun): ProviderConnection
{
$connection = ProviderConnection::query()
->whereKey((int) data_get(
$parentCaptureRun->context,
'target_scope.provider_connection_id',
))
->where('workspace_id', (int) $parentCaptureRun->workspace_id)
->where('managed_environment_id', (int) $parentCaptureRun->managed_environment_id)
->where('provider', 'microsoft')
->where('is_enabled', true)
->first();
if (! $connection instanceof ProviderConnection) {
throw new RuntimeException('Exchange Capture provider connection is unavailable.');
}
return $connection;
}
private function assertReceiptContract(
OperationRun $parentCaptureRun,
string $canonicalType,
ExchangePowerShellInvocationReceipt $receipt,
): void {
$technicalRun = $receipt->technicalRun();
$metadata = $receipt->safeMetadata();
if ($technicalRun->status !== OperationRunStatus::Completed->value
|| (int) data_get($technicalRun->context, 'parent_capture_operation_run_id')
!== (int) $parentCaptureRun->getKey()
|| data_get($technicalRun->context, 'workload') !== 'exchange'
|| data_get($technicalRun->context, 'capture_cohort')
!== ExchangeCoverageCaptureCohortPolicy::IDENTIFIER
|| ($metadata['command_key'] ?? null) !== $canonicalType
) {
throw new RuntimeException('Exchange invocation receipt correlation is invalid.');
}
}
private function isFatalInvocationFailure(?string $failureCode): bool
{
$failureCode = strtolower(trim((string) $failureCode));
return str_contains($failureCode, 'authentication')
|| str_contains($failureCode, 'authorization')
|| str_contains($failureCode, 'credential')
|| str_contains($failureCode, 'private_key')
|| str_contains($failureCode, 'runtime_blocked');
}
}

View File

@ -74,7 +74,7 @@ public function evaluate(
$decision = $this->contractResolver->resolve($resourceType);
$contract = $this->commandContracts->contractForCanonicalType($canonicalType);
if (! $this->verifiedPendingCaptureContract($decision, $contract)) {
if (! $this->verifiedCaptureContract($decision, $contract)) {
return $this->blocked('source_contract_not_verified', CaptureOutcome::BlockedMissingContract);
}
@ -132,14 +132,15 @@ private function sameScope(
/**
* @param array<string, mixed>|null $contract
*/
private function verifiedPendingCaptureContract(?CoverageSourceContractDecision $decision, ?array $contract): bool
private function verifiedCaptureContract(?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
&& $decision->capturable()
&& ($decision->sourceContractState ?? data_get($decision->sourceMetadata, 'source_contract_state')) === CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED
&& 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, 'source_contract_state') === CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED
&& data_get($contract, 'read_only') === true;
}

View File

@ -341,9 +341,9 @@ private function contract(
'fake_runner_testable' => true,
'provider_calls_allowed' => false,
'execution_enabled' => false,
'capture_eligibility_state' => 'pending_capture',
'capture_eligibility_state' => 'capture_enabled',
'provider_adapter_state' => 'adapter_contract_available',
'source_contract_state' => CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE,
'source_contract_state' => CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED,
'permission_model' => $this->permissionModel($canonicalType, $permissionNotes),
'response_shape' => $this->responseShape(
identityFields: $identityFields,

View File

@ -8,7 +8,6 @@
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResourceType;
use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\TenantConfiguration\CaptureOutcome;
final class ExchangePowerShellEvidenceCaptureAdapter
@ -59,8 +58,6 @@ public function capture(
if (($eligibility['allowed'] ?? false) !== true) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: $eligibility['outcome'] ?? CaptureOutcome::BlockedUnsupported,
@ -89,8 +86,6 @@ public function capture(
if (! $readiness->ready) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::BlockedUnsupported,
@ -105,8 +100,6 @@ public function capture(
if ($readiness->emptyCollection) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::Captured,
@ -124,8 +117,6 @@ public function capture(
if ($evidenceItems === [] || count($evidenceItems) !== $readiness->itemCount) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::BlockedUnsupported,
@ -151,8 +142,6 @@ public function capture(
if (($identity['allowed'] ?? false) !== true) {
$summaryCounts = $this->zeroSummaryCounts();
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::BlockedUnsupported,
@ -203,8 +192,6 @@ public function capture(
}
$summaryCounts = $this->successSummaryCounts(count($evidenceIds));
$this->recordSummaryCounts($operationRun, $summaryCounts);
return $this->result(
canonicalType: $canonicalType,
outcome: CaptureOutcome::Captured,
@ -222,12 +209,11 @@ public function capture(
*/
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,
'source_contract_state' => CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED,
'executable_descriptor_kind' => 'command',
'command_contract_key' => $sourceDecision->commandContractKey,
'capture_adapter' => 'exchange_powershell_evidence_capture_adapter',
'capture_eligibility_state' => 'content_backed_only',
'content_level_maximum' => 'content_backed',
@ -241,11 +227,11 @@ private function capturableDecision(CoverageSourceContractDecision $sourceDecisi
canonicalType: $sourceDecision->canonicalType,
outcome: CaptureOutcome::Captured,
contractKey: $sourceDecision->contractKey,
sourceEndpoint: $sourceEndpoint,
commandContractKey: $sourceDecision->commandContractKey,
sourceVersion: $sourceDecision->sourceVersion,
sourceSchemaHash: $sourceDecision->sourceSchemaHash,
reasonCode: null,
sourceContractState: CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE,
sourceContractState: CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED,
contract: $contract,
sourceMetadata: $sourceMetadata,
);
@ -380,16 +366,6 @@ private function successSummaryCounts(int $count): array
];
}
/**
* @param array<string, int> $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) {

View File

@ -6,6 +6,12 @@
final class ExchangePowerShellHashInputBuilder
{
public const string HASH_CONTRACT = 'exchange-redacted-material-v1';
public function __construct(
private readonly ExchangePowerShellCommandContracts $contracts,
) {}
/**
* @param array<string, mixed> $normalizedPayload
* @return array<string, mixed>
@ -19,13 +25,17 @@ public function build(
string $normalizerVersion,
array $normalizedPayload,
): array {
$sourceSchemaHash = $this->sourceSchemaHash($resourceType);
return $this->canonicalize([
'hash_contract' => self::HASH_CONTRACT,
'resource_type' => $resourceType,
'source_surface' => $sourceSurface,
'command_contract_name' => $commandContractName,
'command_contract_version' => $commandContractVersion,
'payload_shape_version' => $payloadShapeVersion,
'normalizer_version' => $normalizerVersion,
'source_schema_hash' => $sourceSchemaHash,
'normalized_payload' => $normalizedPayload,
]);
}
@ -70,4 +80,31 @@ private function canonicalize(mixed $value): mixed
return $normalized;
}
private function sourceSchemaHash(string $resourceType): string
{
$contract = $this->contracts->contractForCanonicalType($resourceType);
if (! is_array($contract)) {
return hash('sha256', '');
}
$schema = [
'adapter_pattern' => $contract['adapter_pattern'] ?? null,
'allowed_parameters' => $contract['allowed_parameters'] ?? [],
'canonical_type' => $contract['canonical_type'] ?? null,
'command_name' => $contract['command_name'] ?? null,
'response_shape' => $contract['response_shape'] ?? [],
'source_surface' => $contract['source_surface'] ?? null,
];
ksort($schema);
return hash(
'sha256',
json_encode(
$schema,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
),
);
}
}

View File

@ -8,6 +8,7 @@
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
use App\Services\OperationRunService;
@ -19,8 +20,12 @@
use App\Support\OperationRunType;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\TenantConfiguration\ExchangePowerShellBatchContinuation;
use App\Support\TenantConfiguration\Workload;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use RuntimeException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
@ -36,13 +41,16 @@ final class ExchangePowerShellInvocationGate
public const string REDACTION_POLICY = 'strict_no_raw_output';
public const string SOURCE_CONTRACT_STATE = 'contract_verified_pending_capture';
public const string SOURCE_CONTRACT_STATE = CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED;
private const string BATCH_CONTRACT = ExchangeCoverageCaptureCohortPolicy::IDENTIFIER;
public function __construct(
private readonly ManagedEnvironmentAccessScopeResolver $accessScopeResolver,
private readonly ProviderOperationTrustedStarter $providerOperationStarter,
private readonly OperationRunService $operationRuns,
private readonly ExchangePowerShellCommandContracts $commandContracts,
private readonly ExchangePowerShellCredentialReferenceResolver $credentialReferences,
private readonly ExchangePowerShellCertificateMaterialResolver $materials,
private readonly ExchangePowerShellPermissionEvidenceEvaluator $permissions,
private readonly ExchangePowerShellRuntimeReadinessChecker $runtimeReadiness,
@ -55,17 +63,7 @@ public function invoke(ExchangePowerShellInvocationContext $context): ExchangePo
$this->authorize($context);
$this->assertContextContract($context);
$startedRun = null;
$start = $this->providerOperationStarter->start(
tenant: $context->managedEnvironment,
connection: $context->providerConnection,
operationType: self::OPERATION_TYPE,
dispatcher: function (OperationRun $run) use (&$startedRun): void {
$startedRun = $run;
},
initiator: $context->actor,
extraContext: $this->initialRunContext($context),
);
[$start, $startedRun] = $this->startInvocationRun($context);
if (! $startedRun instanceof OperationRun
|| $start->status !== 'started'
@ -77,6 +75,324 @@ public function invoke(ExchangePowerShellInvocationContext $context): ExchangePo
return $this->executeStartedRun($startedRun, $context);
}
public function consumeCaptureBatch(
OperationRun $parentCaptureRun,
User $actor,
ProviderConnection $connection,
ExchangeCoverageCaptureConsumer $consumer,
): void {
[$parentCaptureRun, $workspace, $environment, $connection] = $this->resolveCanonicalBatchScope(
$parentCaptureRun,
$connection,
);
$this->authorizeBatch($actor, $environment);
if (! $this->claimBatchPreflightAttempt($parentCaptureRun)) {
return;
}
$material = null;
$permission = null;
$runtime = null;
$receipt = null;
try {
if (! (bool) config(self::FEATURE_CONFIG_PATH, false)
|| ! (bool) config(self::PRODUCTION_RUNNER_CONFIG_PATH, false)
) {
$consumer->blockAll(
$parentCaptureRun,
ProviderReasonCodes::ProviderBindingUnsupported,
);
return;
}
$credentialReferenceId = $this->resolveBatchCredentialReference(
$parentCaptureRun,
$connection,
$consumer,
);
if ($credentialReferenceId === null) {
return;
}
try {
$material = $this->materials->resolve(
$environment,
$connection,
$credentialReferenceId,
);
} catch (ExchangePowerShellCertificateMaterialResolutionException $exception) {
$consumer->blockAll($parentCaptureRun, $exception->reasonCode);
return;
}
$permission = $this->permissions->evaluate($environment, $connection);
if (! $permission->allowed) {
$consumer->blockAll(
$parentCaptureRun,
$permission->reasonCode ?? ProviderReasonCodes::ProviderPermissionMissing,
);
return;
}
$runtime = $this->runtimeReadiness->check();
if (! $runtime->allowed) {
$consumer->blockAll(
$parentCaptureRun,
$runtime->reasonCode ?? ProviderReasonCodes::ProviderBindingUnsupported,
);
return;
}
foreach ($this->commandContracts->includedCanonicalTypes() as $canonicalType) {
$consumer->markStarted($parentCaptureRun, $canonicalType);
$context = new ExchangePowerShellInvocationContext(
actor: $actor,
workspace: $workspace,
managedEnvironment: $environment,
providerConnection: $connection,
credentialReferenceId: $credentialReferenceId,
commandKey: $canonicalType,
runnerMode: self::RUNNER_MODE_PRODUCTION,
redactionPolicy: self::REDACTION_POLICY,
sourceContractState: self::SOURCE_CONTRACT_STATE,
correlationId: sprintf(
'capture-%d-%s',
(int) $parentCaptureRun->getKey(),
$canonicalType,
),
);
$receipt = $this->invokeBatchCommand($context, $material, $parentCaptureRun);
try {
$continuation = $consumer->consumeReceipt(
$parentCaptureRun,
$canonicalType,
$receipt,
);
} finally {
unset($receipt);
$receipt = null;
}
if ($continuation === ExchangePowerShellBatchContinuation::Stop) {
return;
}
}
} finally {
unset($receipt, $runtime, $permission, $material);
}
}
/**
* @return array{0: OperationRun, 1: Workspace, 2: ManagedEnvironment, 3: ProviderConnection}
*/
private function resolveCanonicalBatchScope(
OperationRun $parentCaptureRun,
ProviderConnection $connection,
): array {
$run = OperationRun::query()
->whereKey((int) $parentCaptureRun->getKey())
->where('type', OperationRunType::TenantConfigurationCapture->value)
->first();
if (! $run instanceof OperationRun) {
throw new NotFoundHttpException('Tenant configuration Capture Run not found.');
}
$workspace = Workspace::query()->whereKey((int) $run->workspace_id)->first();
$environment = ManagedEnvironment::query()
->whereKey((int) $run->managed_environment_id)
->where('workspace_id', (int) $run->workspace_id)
->first();
$canonicalConnection = ProviderConnection::query()
->whereKey((int) $connection->getKey())
->where('workspace_id', (int) $run->workspace_id)
->where('managed_environment_id', (int) $run->managed_environment_id)
->where('provider', 'microsoft')
->where('is_enabled', true)
->first();
if (! $workspace instanceof Workspace
|| ! $environment instanceof ManagedEnvironment
|| ! $canonicalConnection instanceof ProviderConnection
) {
throw new NotFoundHttpException('Exchange Capture provider scope not found.');
}
$resourceTypes = data_get($run->context, 'resource_types');
if (data_get($run->context, 'workload') !== Workload::Exchange->value
|| data_get($run->context, 'capture_cohort') !== self::BATCH_CONTRACT
|| $resourceTypes !== $this->commandContracts->includedCanonicalTypes()
|| (int) data_get($run->context, 'target_scope.workspace_id') !== (int) $workspace->getKey()
|| (int) data_get($run->context, 'target_scope.managed_environment_id') !== (int) $environment->getKey()
|| (int) data_get($run->context, 'target_scope.provider_connection_id') !== (int) $canonicalConnection->getKey()
) {
throw new InvalidArgumentException('Exchange Capture parent Run contract is invalid.');
}
return [$run, $workspace, $environment, $canonicalConnection];
}
private function authorizeBatch(User $actor, ManagedEnvironment $environment): void
{
$decision = $this->accessScopeResolver->decision(
$actor,
$environment,
Capabilities::PROVIDER_RUN,
);
if ($decision->allowed()) {
return;
}
if ($decision->shouldDenyAsNotFound()) {
throw new NotFoundHttpException('Managed environment not found.');
}
throw (new AuthorizationException('This action is unauthorized.'))->withStatus(403);
}
private function claimBatchPreflightAttempt(OperationRun $parentCaptureRun): bool
{
return DB::transaction(function () use ($parentCaptureRun): bool {
$run = OperationRun::query()
->whereKey((int) $parentCaptureRun->getKey())
->lockForUpdate()
->first();
if (! $run instanceof OperationRun) {
throw new NotFoundHttpException('Tenant configuration Capture Run not found.');
}
$batch = data_get($run->context, 'exchange_powershell_batch');
if (is_array($batch) && array_key_exists('preflight_attempt_claimed_at', $batch)) {
if (($batch['contract'] ?? null) !== self::BATCH_CONTRACT
|| ! is_string($batch['preflight_attempt_claimed_at'])
|| trim($batch['preflight_attempt_claimed_at']) === ''
) {
throw new RuntimeException('Exchange Capture preflight claim is invalid.');
}
return false;
}
if ($batch !== null) {
throw new RuntimeException('Exchange Capture preflight claim is invalid.');
}
$context = is_array($run->context) ? $run->context : [];
$context['exchange_powershell_batch'] = [
'contract' => self::BATCH_CONTRACT,
'preflight_attempt_claimed_at' => now()->toJSON(),
];
$run->forceFill(['context' => $context])->save();
return true;
});
}
private function resolveBatchCredentialReference(
OperationRun $parentCaptureRun,
ProviderConnection $connection,
ExchangeCoverageCaptureConsumer $consumer,
): ?int {
$credentials = ProviderCredential::query()
->where('provider_connection_id', (int) $connection->getKey())
->get(['id']);
if ($credentials->count() !== 1) {
$consumer->blockAll(
$parentCaptureRun,
$credentials->isEmpty()
? ProviderReasonCodes::ProviderCredentialMissing
: ProviderReasonCodes::ProviderCredentialInvalid,
);
return null;
}
$credential = $this->credentialReferences->resolve($connection);
if (! $credential->allowed) {
$consumer->blockAll(
$parentCaptureRun,
$credential->reasonCode ?? ProviderReasonCodes::ProviderCredentialInvalid,
);
return null;
}
$credentialReferenceId = $credential->context['credential_reference_id'] ?? null;
if (! is_int($credentialReferenceId)
|| $credentialReferenceId !== (int) $credentials->first()?->getKey()
) {
$consumer->blockAll(
$parentCaptureRun,
ProviderReasonCodes::ProviderCredentialInvalid,
);
return null;
}
return $credentialReferenceId;
}
private function invokeBatchCommand(
ExchangePowerShellInvocationContext $context,
ExchangePowerShellCertificateMaterial $material,
OperationRun $parentCaptureRun,
): ExchangePowerShellInvocationReceipt {
[$start, $startedRun] = $this->startInvocationRun($context, $parentCaptureRun);
if (! $startedRun instanceof OperationRun
|| $start->status !== 'started'
|| ! $start->dispatched
) {
return $this->receiptFromNonDispatch($start, $context);
}
return $this->executeStartedRun(
$startedRun,
$context,
$material,
sharedPreflightComplete: true,
);
}
/**
* @return array{0: ProviderOperationStartResult, 1: ?OperationRun}
*/
private function startInvocationRun(
ExchangePowerShellInvocationContext $context,
?OperationRun $parentCaptureRun = null,
): array {
$startedRun = null;
$start = $this->providerOperationStarter->start(
tenant: $context->managedEnvironment,
connection: $context->providerConnection,
operationType: self::OPERATION_TYPE,
dispatcher: function (OperationRun $run) use (&$startedRun): void {
$startedRun = $run;
},
initiator: $context->actor,
extraContext: $this->initialRunContext($context, $parentCaptureRun),
);
return [$start, $startedRun];
}
private function resolveCanonicalContext(
ExchangePowerShellInvocationContext $context,
): ExchangePowerShellInvocationContext {
@ -173,9 +489,12 @@ private function assertContextContract(ExchangePowerShellInvocationContext $cont
/**
* @return array<string, mixed>
*/
private function initialRunContext(ExchangePowerShellInvocationContext $context): array
private function initialRunContext(
ExchangePowerShellInvocationContext $context,
?OperationRun $parentCaptureRun = null,
): array
{
return [
$runContext = [
'operation' => ['type' => self::OPERATION_TYPE],
'source' => 'spec453_exchange_powershell_invocation_gate',
'target_resource_type' => $context->commandKey,
@ -201,6 +520,14 @@ private function initialRunContext(ExchangePowerShellInvocationContext $context)
'provider_connection_id' => (int) $context->providerConnection->getKey(),
],
];
if ($parentCaptureRun instanceof OperationRun) {
$runContext['parent_capture_operation_run_id'] = (int) $parentCaptureRun->getKey();
$runContext['workload'] = Workload::Exchange->value;
$runContext['capture_cohort'] = self::BATCH_CONTRACT;
}
return $runContext;
}
private function receiptFromNonDispatch(
@ -234,6 +561,8 @@ private function receiptFromNonDispatch(
private function executeStartedRun(
OperationRun $run,
ExchangePowerShellInvocationContext $context,
?ExchangePowerShellCertificateMaterial $sharedMaterial = null,
bool $sharedPreflightComplete = false,
): ExchangePowerShellInvocationReceipt {
$run = $this->operationRuns->updateRun(
run: $run,
@ -270,8 +599,9 @@ private function executeStartedRun(
);
}
if (! (bool) config(self::FEATURE_CONFIG_PATH, false)
|| ! (bool) config(self::PRODUCTION_RUNNER_CONFIG_PATH, false)
if (! $sharedPreflightComplete
&& (! (bool) config(self::FEATURE_CONFIG_PATH, false)
|| ! (bool) config(self::PRODUCTION_RUNNER_CONFIG_PATH, false))
) {
return $this->terminalBlocked(
$run,
@ -305,18 +635,68 @@ private function executeStartedRun(
[],
);
try {
$material = $this->materials->resolve(
$material = $sharedMaterial;
if (! $sharedPreflightComplete) {
try {
$material = $this->materials->resolve(
$context->managedEnvironment,
$context->providerConnection,
$context->credentialReferenceId,
);
} catch (ExchangePowerShellCertificateMaterialResolutionException $exception) {
return $this->terminalBlocked(
$run,
ExchangePowerShellInvocationResult::blocked(
reasonCode: $exception->reasonCode,
failureCode: $exception->failureCode,
message: 'Exchange PowerShell certificate material is unavailable.',
context: ['credential_state' => 'blocked'],
),
$context,
);
}
$permission = $this->permissions->evaluate(
$context->managedEnvironment,
$context->providerConnection,
$context->credentialReferenceId,
);
} catch (ExchangePowerShellCertificateMaterialResolutionException $exception) {
if (! $permission->allowed) {
return $this->terminalBlocked(
$run,
ExchangePowerShellInvocationResult::blocked(
reasonCode: $permission->reasonCode ?? ProviderReasonCodes::ProviderPermissionMissing,
failureCode: $permission->failureCode ?? 'permission_evidence_blocked_unvalidated',
message: $permission->message,
context: $this->safeScalarContext($permission->context),
),
$context,
);
}
$runtime = $this->runtimeReadiness->check();
if (! $runtime->allowed) {
return $this->terminalBlocked(
$run,
ExchangePowerShellInvocationResult::blocked(
reasonCode: $runtime->reasonCode ?? ProviderReasonCodes::ProviderBindingUnsupported,
failureCode: $runtime->failureCode ?? 'exchange_runtime_blocked_disabled',
message: $runtime->message,
context: $this->safeScalarContext($runtime->context),
),
$context,
);
}
}
if (! $material instanceof ExchangePowerShellCertificateMaterial) {
return $this->terminalBlocked(
$run,
ExchangePowerShellInvocationResult::blocked(
reasonCode: $exception->reasonCode,
failureCode: $exception->failureCode,
reasonCode: ProviderReasonCodes::ProviderCredentialMissing,
failureCode: 'credential_blocked_certificate_inaccessible',
message: 'Exchange PowerShell certificate material is unavailable.',
context: ['credential_state' => 'blocked'],
),
@ -324,39 +704,6 @@ private function executeStartedRun(
);
}
$permission = $this->permissions->evaluate(
$context->managedEnvironment,
$context->providerConnection,
);
if (! $permission->allowed) {
return $this->terminalBlocked(
$run,
ExchangePowerShellInvocationResult::blocked(
reasonCode: $permission->reasonCode ?? ProviderReasonCodes::ProviderPermissionMissing,
failureCode: $permission->failureCode ?? 'permission_evidence_blocked_unvalidated',
message: $permission->message,
context: $this->safeScalarContext($permission->context),
),
$context,
);
}
$runtime = $this->runtimeReadiness->check();
if (! $runtime->allowed) {
return $this->terminalBlocked(
$run,
ExchangePowerShellInvocationResult::blocked(
reasonCode: $runtime->reasonCode ?? ProviderReasonCodes::ProviderBindingUnsupported,
failureCode: $runtime->failureCode ?? 'exchange_runtime_blocked_disabled',
message: $runtime->message,
context: $this->safeScalarContext($runtime->context),
),
$context,
);
}
$result = $this->withReadinessContext(
$this->runner->run($contract, $context, $material),
);

View File

@ -62,8 +62,21 @@ public function capture(
);
foreach ($this->plannedResourceTypes($operationRun, $selectedCanonicalTypes) as $resourceType) {
try {
$decision = $this->contractResolver->resolve($resourceType, allowBetaCapture: $allowBetaCapture);
} catch (Throwable $exception) {
$this->captureTypeResults->markStarted($operationRun, $resourceType);
throw $exception;
}
if ($decision->commandContractKey !== null) {
throw new UnexpectedValueException(
"Coverage type {$resourceType->canonical_type} requires its dedicated command-backed Evidence capture consumer.",
);
}
$this->captureTypeResults->markStarted($operationRun, $resourceType);
$decision = $this->contractResolver->resolve($resourceType, allowBetaCapture: $allowBetaCapture);
if (! $decision->capturable()) {
$this->captureTypeResults->finalize(

View File

@ -222,7 +222,7 @@ private static function m365RepresentativeDefinitions(): array
['transportRule', 'Transport rule', RestoreTier::NotRestorable, 'high', ['mailFlowRule']],
['acceptedDomain', 'Accepted domain', RestoreTier::PreviewOnly, 'medium', ['acceptedDomains']],
['sharedMailbox', 'Shared mailbox', RestoreTier::PreviewOnly, 'medium', ['sharedMailboxes']],
['remoteDomain', 'Remote domain', RestoreTier::PreviewOnly, 'medium', ['remoteDomains']],
['remoteDomain', 'Remote domain', RestoreTier::NotRestorable, 'medium', ['remoteDomains']],
['mailboxPlan', 'Mailbox plan', RestoreTier::PreviewOnly, 'medium', ['mailboxPlans']],
['inboundConnector', 'Inbound connector', RestoreTier::NotRestorable, 'high', ['inboundConnectors']],
['organizationConfig', 'Organization configuration', RestoreTier::NotRestorable, 'high', ['organizationConfiguration']],
@ -336,6 +336,7 @@ private static function m365ResourceDefinition(Workload $workload, array $entry)
'is_full_catalog' => false,
'catalog_import_batch' => 'spec_419_seeded_representative_manifest',
'customer_claims_allowed' => false,
'certification_allowed' => false,
],
];
}

View File

@ -21,6 +21,7 @@
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\Capabilities\ProviderCapabilityEvaluator;
use App\Support\Providers\Capabilities\ProviderCapabilityResult;
use App\Support\TenantConfiguration\Workload;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Bus;
@ -41,6 +42,7 @@ public function __construct(
private readonly CoverageTypeAuthority $coverageTypes,
private readonly CaptureTypeResultWriter $captureTypeResults,
private readonly CoverageCaptureOutcomeSummarizer $captureOutcomeSummarizer,
private readonly ExchangeCoverageCaptureCohortPolicy $exchangeCaptureCohort,
) {}
/**
@ -51,23 +53,63 @@ public function start(
ProviderConnection $providerConnection,
User $actor,
?array $canonicalTypes = null,
Workload $workload = Workload::Intune,
?string $cohortIdentifier = null,
): OperationRun {
$this->authorize($tenant, $actor);
if ($workload === Workload::Exchange) {
$this->authorize($tenant, $actor, Capabilities::PROVIDER_RUN);
}
$this->assertProviderConnectionInScope($tenant, $providerConnection);
$requestedResourceTypes = $this->normalizeResourceTypes($canonicalTypes);
$plannedResourceTypes = $this->trustedResourceTypes($requestedResourceTypes);
if ($workload === Workload::Exchange) {
$this->assertExchangeProviderConnection($providerConnection);
$plannedResourceTypes = $this->exchangeCaptureCohort->resolvePersisted();
$requestedResourceTypes = collect($plannedResourceTypes)
->pluck('canonical_type')
->map(static fn (mixed $type): string => (string) $type)
->values()
->all();
if ($cohortIdentifier !== $this->exchangeCaptureCohort->identifier()) {
throw new UnexpectedValueException('The Exchange Capture cohort contract is invalid.');
}
if ($canonicalTypes !== null
&& $this->normalizeResourceTypes($canonicalTypes) !== collect($requestedResourceTypes)->sort()->values()->all()
) {
throw new UnexpectedValueException('The Exchange Capture request does not match the exact cohort.');
}
} else {
$requestedResourceTypes = $this->normalizeResourceTypes($canonicalTypes);
$plannedResourceTypes = $this->trustedResourceTypes($requestedResourceTypes);
}
$resourceTypes = $plannedResourceTypes
->map(static fn ($resourceType): string => (string) $resourceType->canonical_type)
->all();
$this->assertIntuneCoreProviderEligibility($tenant, $providerConnection, $resourceTypes);
$context = $this->runContext($tenant, $providerConnection, $resourceTypes);
if ($workload === Workload::Intune) {
$this->assertIntuneCoreProviderEligibility($tenant, $providerConnection, $resourceTypes);
}
$context = $this->runContext(
$tenant,
$providerConnection,
$resourceTypes,
$workload,
$cohortIdentifier,
);
$run = $this->operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::TenantConfigurationCapture->value,
identityInputs: [
'provider_connection_id' => (int) $providerConnection->getKey(),
'workload' => $workload->value,
'capture_cohort' => $cohortIdentifier,
'resource_types' => $resourceTypes,
],
context: $context,
@ -199,9 +241,13 @@ private function assertIntuneCoreProviderEligibility(
}
}
private function authorize(ManagedEnvironment $tenant, User $actor): void
private function authorize(
ManagedEnvironment $tenant,
User $actor,
string $capability = Capabilities::EVIDENCE_MANAGE,
): void
{
$decision = $this->accessScopeResolver->decision($actor, $tenant, Capabilities::EVIDENCE_MANAGE);
$decision = $this->accessScopeResolver->decision($actor, $tenant, $capability);
if ($decision->allowed()) {
return;
@ -223,6 +269,17 @@ private function assertProviderConnectionInScope(ManagedEnvironment $tenant, Pro
}
}
private function assertExchangeProviderConnection(ProviderConnection $providerConnection): void
{
if (trim((string) $providerConnection->provider) !== 'microsoft') {
throw (new AuthorizationException('provider_binding_unsupported'))->withStatus(403);
}
if (! (bool) $providerConnection->is_enabled) {
throw (new AuthorizationException('provider_connection_inactive'))->withStatus(403);
}
}
/**
* @param list<string>|null $canonicalTypes
* @return list<string>
@ -318,7 +375,13 @@ private function trustedResourceTypes(array $canonicalTypes): Collection
* @param list<string> $resourceTypes
* @return array<string, mixed>
*/
private function runContext(ManagedEnvironment $tenant, ProviderConnection $providerConnection, array $resourceTypes): array
private function runContext(
ManagedEnvironment $tenant,
ProviderConnection $providerConnection,
array $resourceTypes,
Workload $workload,
?string $cohortIdentifier,
): array
{
$context = [
'operation' => [
@ -330,11 +393,17 @@ private function runContext(ManagedEnvironment $tenant, ProviderConnection $prov
'provider_connection_id' => (int) $providerConnection->getKey(),
],
'resource_types' => $resourceTypes,
'workload' => $workload->value,
'required_capability' => Capabilities::EVIDENCE_MANAGE,
'required_capabilities' => $workload === Workload::Exchange
? [Capabilities::EVIDENCE_MANAGE, Capabilities::PROVIDER_RUN]
: [Capabilities::EVIDENCE_MANAGE],
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
];
if ($resourceTypes === collect($this->productCaptureEligibleCanonicalTypes())->sort()->values()->all()) {
if ($workload === Workload::Exchange) {
$context['capture_cohort'] = $cohortIdentifier;
} elseif ($resourceTypes === collect($this->productCaptureEligibleCanonicalTypes())->sort()->values()->all()) {
$context['supported_scope_key'] = 'intune_tcm_core';
}

View File

@ -26,6 +26,7 @@ public function __construct(
public array $sourceMetadata = [],
public string $displayName = '',
public string $classificationReason = '',
public bool $internalCompareEligible = false,
) {
$this->validateRequiredText();
$this->validateStringList($this->aliases, 'alias');
@ -92,15 +93,19 @@ public function isBaselineEligible(): bool
public function isCompareEligible(): bool
{
return in_array($this->productClassification, [
CoverageProductClassification::ProductCommitted,
CoverageProductClassification::InternalOnly,
], true)
&& in_array($this->runtimeState, [
$runtimeEligible = in_array($this->runtimeState, [
CoverageRuntimeState::EvidenceReady,
CoverageRuntimeState::OperatorProductized,
CoverageRuntimeState::FullyProductized,
], true);
if ($this->productClassification === CoverageProductClassification::ProductCommitted) {
return $runtimeEligible;
}
return $this->productClassification === CoverageProductClassification::InternalOnly
&& $this->internalCompareEligible
&& $runtimeEligible;
}
public function isFindingBaseEligible(): bool
@ -200,6 +205,12 @@ public function stateInvariantViolations(): array
$violations[] = 'Fully productized committed Coverage types require a resolved publication classification.';
}
if ($this->internalCompareEligible
&& $this->productClassification !== CoverageProductClassification::InternalOnly
) {
$violations[] = 'Internal Compare eligibility is valid only for internal-only Coverage types.';
}
return $violations;
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Support\TenantConfiguration;
enum ExchangePowerShellBatchContinuation
{
case Continue;
case Stop;
}

View File

@ -932,6 +932,8 @@
'coverage_v2' => [
'intune_core_currentness_hours' => 24,
// Independent first-stage freshness target for high-impact internal Exchange configuration.
'exchange_powershell_currentness_hours' => 24,
],
'hardening' => [

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public $withinTransaction = false;
private const string INDEX = 'tenant_config_evidence_run_resource_unique';
public function up(): void
{
$this->assertNoDuplicates();
match (DB::getDriverName()) {
'pgsql' => DB::statement(
'CREATE UNIQUE INDEX CONCURRENTLY tenant_config_evidence_run_resource_unique '
.'ON tenant_configuration_resource_evidence (operation_run_id, resource_id)',
),
'sqlite' => DB::statement(
'CREATE UNIQUE INDEX '.self::INDEX
.' ON tenant_configuration_resource_evidence (operation_run_id, resource_id)',
),
default => throw new \RuntimeException(
'Tenant configuration Evidence idempotency migration requires PostgreSQL or SQLite.',
),
};
}
public function down(): void
{
match (DB::getDriverName()) {
'pgsql' => DB::statement(
'DROP INDEX CONCURRENTLY IF EXISTS tenant_config_evidence_run_resource_unique',
),
'sqlite' => DB::statement(
'DROP INDEX IF EXISTS '.self::INDEX,
),
default => throw new \RuntimeException(
'Tenant configuration Evidence idempotency rollback requires PostgreSQL or SQLite.',
),
};
}
private function assertNoDuplicates(): void
{
$duplicate = DB::table('tenant_configuration_resource_evidence')
->select(['operation_run_id', 'resource_id'])
->selectRaw('COUNT(*) AS duplicate_count')
->groupBy(['operation_run_id', 'resource_id'])
->havingRaw('COUNT(*) > 1')
->limit(1)
->first();
if ($duplicate !== null) {
throw new \RuntimeException(
'Duplicate tenant configuration Evidence rows block the idempotency migration.',
);
}
}
};

View File

@ -1372,6 +1372,38 @@
'attempt_needs_attention' => 'Der letzte Erfassungsversuch erfordert Aufmerksamkeit und es ist kein aktuelles Ergebnis verfügbar.',
'results_need_attention' => 'Mindestens ein Intune-Core-Ergebnis ist veraltet, fehlt oder erfordert Aufmerksamkeit.',
'wait_for_capture' => 'Auf Abschluss der Erfassung warten',
'workload_tabs_label' => 'Coverage-Workload',
'workloads' => [
'intune' => 'Intune',
'exchange' => 'Exchange Online',
],
'states' => [
'expired' => 'Abgelaufen',
'not_configured' => 'Nicht konfiguriert',
],
'exchange' => [
'title' => 'Exchange-Online-Abdeckung',
'scope_label' => 'Unterstützter Bereich: 3 Exchange-Online-Typen',
'cohort_unavailable' => 'Die exakte Exchange-Online-Kohorte aus drei Typen ist nicht verfügbar.',
'all_current' => 'Alle drei Exchange-Online-Typen haben aktuelle erfolgreiche Ergebnisse.',
'results_need_attention' => 'Mindestens ein Exchange-Online-Ergebnis ist veraltet, fehlt oder erfordert Aufmerksamkeit.',
'expired' => 'Die letzte vollständige Exchange-Online-Erfassung ist abgelaufen.',
'not_configured' => 'Exchange-Online-Coverage ist für diese Umgebung nicht konfiguriert.',
'configure_provider' => 'Konfigurieren oder wählen Sie eine geeignete Microsoft-Provider-Verbindung.',
'capture' => 'Exchange-Coverage erfassen',
'capture_description' => 'TenantPilot liest die exakte Exchange-Online-Kohorte aus drei Typen und speichert interne Evidence. Die Microsoft-Konfiguration wird nicht verändert.',
'provider_draft_help' => 'Diese Provider-Auswahl gilt nur für die bestätigte Erfassung und wird vor dem Start erneut geprüft.',
'provider_run_required' => 'Sie benötigen Provider-Ausführungszugriff, um Exchange-Online-Konfiguration zu erfassen.',
'prerequisites_blocked' => 'Die Exchange-Erfassung erfordert aktuelle Provider-Anmeldedaten, eine Berechtigungsprüfung und eine einsatzbereite lokale Laufzeitumgebung.',
'capture_blocked' => 'Exchange-Erfassung blockiert',
'blocked_reason' => 'Grund: :reason',
'protected_transport_rule' => 'Geschützte Transportregel',
'protected_remote_domain' => 'Geschützte Remotedomäne',
'protected_inbound_connector' => 'Geschützter eingehender Connector',
'protected_configuration' => 'Geschützte Exchange-Konfiguration',
'type_scope' => 'Exchange-Online-Erfassung',
'type_scope_included' => 'In der Exchange-Erfassungskohorte enthalten',
],
'metrics' => [
'current_results' => 'Aktuelle Ergebnisse',
'stale' => 'Veraltet',

View File

@ -1372,6 +1372,38 @@
'attempt_needs_attention' => 'The latest capture attempt needs attention and no current result is available.',
'results_need_attention' => 'One or more Intune core results are stale, absent, or need attention.',
'wait_for_capture' => 'Wait for capture to complete',
'workload_tabs_label' => 'Coverage workload',
'workloads' => [
'intune' => 'Intune',
'exchange' => 'Exchange Online',
],
'states' => [
'expired' => 'Expired',
'not_configured' => 'Not configured',
],
'exchange' => [
'title' => 'Exchange Online coverage',
'scope_label' => 'Supported scope: 3 Exchange Online types',
'cohort_unavailable' => 'The exact three-type Exchange Online cohort is not available.',
'all_current' => 'All three Exchange Online types have current successful results.',
'results_need_attention' => 'One or more Exchange Online results are stale, absent, or need attention.',
'expired' => 'The last complete Exchange Online capture has expired.',
'not_configured' => 'Exchange Online coverage is not configured for this environment.',
'configure_provider' => 'Configure or select an eligible Microsoft provider connection.',
'capture' => 'Capture Exchange coverage',
'capture_description' => 'TenantPilot will read the exact three-type Exchange Online cohort and store internal evidence. Microsoft configuration will not be changed.',
'provider_draft_help' => 'This provider selection applies only to the confirmed capture and is revalidated before the operation starts.',
'provider_run_required' => 'You need provider execution access to capture Exchange Online configuration.',
'prerequisites_blocked' => 'Exchange capture requires a current provider credential, permission verification, and local runtime readiness.',
'capture_blocked' => 'Exchange capture blocked',
'blocked_reason' => 'Reason: :reason',
'protected_transport_rule' => 'Protected transport rule',
'protected_remote_domain' => 'Protected remote domain',
'protected_inbound_connector' => 'Protected inbound connector',
'protected_configuration' => 'Protected Exchange configuration',
'type_scope' => 'Exchange Online capture',
'type_scope_included' => 'Included in the Exchange capture cohort',
],
'metrics' => [
'current_results' => 'Current results',
'stale' => 'Stale',

View File

@ -30,6 +30,7 @@
$hasTechnicalDetails = collect($technicalFields)->contains(fn ($value): bool => filled($value))
|| $sourceBadge !== null
|| filled($details['operation_run_url'] ?? null);
$canViewTechnicalAnnex = ($canViewTechnicalAnnex ?? false) === true;
$typedSummary = $details['typed_render_summary'] ?? null;
$compareSummary = is_array($typedSummary) && is_array($typedSummary['compare_summary'] ?? null)
@ -211,8 +212,11 @@
@endforeach
</dl>
@if ($hasTechnicalDetails)
<details class="rounded-lg border border-gray-200 bg-gray-50/50 p-3 dark:border-white/10 dark:bg-white/5">
@if ($hasTechnicalDetails && $canViewTechnicalAnnex)
<details
class="rounded-lg border border-gray-200 bg-gray-50/50 p-3 dark:border-white/10 dark:bg-white/5"
data-testid="coverage-v2-resource-technical-annex"
>
<summary class="cursor-pointer list-none rounded-md px-2 py-1.5 text-sm font-medium text-gray-700 transition hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:text-gray-200 dark:hover:bg-white/10">
{{ __('localization.coverage_v2.resource_inspect.view_technical_details') }}
</summary>
@ -229,7 +233,7 @@
<dl class="grid gap-3 sm:grid-cols-2">
@foreach ($technicalFields as $label => $value)
@if (filled($value))
<div class="min-w-0 border-l border-gray-200 pl-3 dark:border-white/10">
<div class="min-w-0 border-l border-gray-200 pl-3 dark:border-white/10" data-testid="coverage-v2-technical-id">
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ $label }}
</dt>
@ -242,7 +246,11 @@
</dl>
@if (filled($details['operation_run_url'] ?? null))
<x-filament::link :href="$details['operation_run_url']" icon="heroicon-o-arrow-top-right-on-square">
<x-filament::link
:href="$details['operation_run_url']"
icon="heroicon-o-arrow-top-right-on-square"
data-testid="coverage-v2-technical-link"
>
{{ $details['operation_run_label'] ?? __('localization.coverage_v2.resource_inspect.open_operation') }}
</x-filament::link>
@endif

View File

@ -47,7 +47,7 @@
<dl class="grid gap-3 sm:grid-cols-2">
@foreach ($safeFields as $label => $value)
@if (filled($value))
<div class="min-w-0 border-l border-gray-200 pl-3 dark:border-white/10">
<div class="min-w-0 border-l border-gray-200 pl-3 dark:border-white/10" data-testid="coverage-v2-technical-id">
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ $label }}
</dt>

View File

@ -4,15 +4,36 @@
$providerContext = $this->providerContext();
$providerConnection = $providerContext['connection'];
$summary = $this->readinessSummary();
$readiness = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::CoverageV2Readiness,
$summary['readiness_state'] ?? 'unknown',
);
$readiness = $this->readinessBadge();
@endphp
<div data-testid="coverage-v2-workload-tabs">
<x-filament::tabs :label="__('localization.coverage_v2.workload_tabs_label')">
<x-filament::tabs.item
:active="$this->activeWorkload === \App\Support\TenantConfiguration\Workload::Intune->value"
wire:click="selectWorkload('intune')"
data-testid="coverage-v2-workload-tab"
data-workload="intune"
>
{{ __('localization.coverage_v2.workloads.intune') }}
</x-filament::tabs.item>
<x-filament::tabs.item
:active="$this->activeWorkload === \App\Support\TenantConfiguration\Workload::Exchange->value"
wire:click="selectWorkload('exchange')"
data-testid="coverage-v2-workload-tab"
data-workload="exchange"
>
{{ __('localization.coverage_v2.workloads.exchange') }}
</x-filament::tabs.item>
</x-filament::tabs>
</div>
<div data-testid="coverage-v2-active-workload" data-active-workload="{{ $this->activeWorkload }}">
<x-filament::section icon="heroicon-o-shield-check">
<x-slot name="heading">
{{ __('localization.coverage_v2.scope_label') }}
{{ $this->activeWorkload === \App\Support\TenantConfiguration\Workload::Exchange->value
? __('localization.coverage_v2.exchange.scope_label')
: __('localization.coverage_v2.scope_label') }}
</x-slot>
<x-slot name="description">
@ -80,21 +101,30 @@
</x-filament::section>
@if ($providerConnection && ($summary['metrics'] ?? []) !== [])
<div data-testid="coverage-v2-metrics">
<div data-testid="coverage-v2-primary-metrics">
@livewire(\App\Filament\Widgets\TenantConfiguration\CoverageV2MetricsOverview::class, [
'metrics' => $summary['metrics'],
], key('coverage-v2-metrics-' . $this->environmentId . '-' . $providerConnection->getKey()))
], key('coverage-v2-metrics-' . $this->activeWorkload . '-' . $this->environmentId . '-' . $providerConnection->getKey()))
</div>
@endif
@if ($providerConnection)
<div class="min-w-0" data-testid="coverage-v2-type-outcomes">
@livewire(\App\Filament\Widgets\TenantConfiguration\CoverageV2ResourceTypesTable::class, [
'environmentId' => $this->environmentId,
'providerConnectionId' => (int) $providerConnection->getKey(),
], key('coverage-v2-resource-types-' . $this->environmentId . '-' . $providerConnection->getKey()))
<div data-testid="coverage-v2-type-outcomes">
<div
class="min-w-0"
data-testid="coverage-v2-visible-data-table"
data-visible-row-count="{{ count($summary['type_outcomes'] ?? []) }}"
data-workload="{{ $this->activeWorkload }}"
>
@livewire(\App\Filament\Widgets\TenantConfiguration\CoverageV2ResourceTypesTable::class, [
'environmentId' => $this->environmentId,
'providerConnectionId' => (int) $providerConnection->getKey(),
'workload' => $this->activeWorkload,
], key('coverage-v2-resource-types-' . $this->activeWorkload . '-' . $this->environmentId . '-' . $providerConnection->getKey()))
</div>
</div>
@if ($this->canViewTechnicalAnnex())
<x-filament::section compact secondary>
<details
class="group"
@ -124,9 +154,12 @@ class="-m-2 flex cursor-pointer list-none items-start justify-between gap-4 roun
@livewire(\App\Filament\Widgets\TenantConfiguration\CoverageV2ResourceInstancesTable::class, [
'environmentId' => $this->environmentId,
'providerConnectionId' => (int) $providerConnection->getKey(),
], key('coverage-v2-resource-instances-' . $this->environmentId . '-' . $providerConnection->getKey()))
'workload' => $this->activeWorkload,
], key('coverage-v2-resource-instances-' . $this->activeWorkload . '-' . $this->environmentId . '-' . $providerConnection->getKey()))
</div>
</details>
</x-filament::section>
@endif
@endif
</div>
</x-filament-panels::page>

View File

@ -9,6 +9,7 @@
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Models\TenantConfigurationCaptureTypeResult;
use App\Models\TenantConfigurationSupportedScope;
use App\Models\User;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
@ -17,6 +18,7 @@
use App\Support\OperationRunType;
use App\Support\TenantConfiguration\CanonicalKeyKind;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\CaptureTypeOutcome;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\EvidenceState;
@ -35,17 +37,17 @@
$page = visit(CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin'))
->resize(1440, 1100)
->waitForText('Coverage v2 Readiness')
->waitForText('Spec420 Browser Conditional Access policy')
->assertSee('Resource type registry')
->waitForText('Intune core coverage')
->waitForText('Device and app management assignment filter')
->assertSee('Results by resource type')
->assertSee('Latest attempt')
->assertSee('Last successful result')
->assertSee('Currentness')
->assertSee('Configurations found')
->assertScript('document.querySelector(\'[data-testid="coverage-v2-technical-details"]\')?.open', false)
->click('Resource instances · technical details')
->waitForText('Spec420 Browser assignment filter')
->assertSee('Resource instances')
->assertSee('Conditional Access policy')
->assertSee('Coverage level')
->assertSee('Evidence state')
->assertSee('Identity state')
->assertSee('Claim state')
->assertSee('Content backed')
->assertSee('Internal only')
->assertDontSee('M365 covered')
->assertDontSee('certified')
->assertDontSee('restore-ready')
@ -63,9 +65,9 @@
$page->script(<<<'JS'
(() => {
const rows = Array.from(document.querySelectorAll('table tbody tr'));
const row = rows.find((candidate) => candidate.textContent.includes('Spec420 Browser Conditional Access policy'));
const row = rows.find((candidate) => candidate.textContent.includes('Spec420 Browser assignment filter'));
const inspect = Array.from(row?.querySelectorAll('button, a') ?? [])
.find((element) => element.textContent.includes('Spec420 Browser Conditional Access policy'));
.find((element) => element.textContent.includes('Spec420 Browser assignment filter'));
inspect?.click();
})()
@ -78,11 +80,11 @@
->assertSee('Claim: Internal only')
->assertSee('Spec420 Browser Microsoft provider')
->assertSee('View technical details')
->assertDontSee('conditionalAccessPolicy:graph_object_id:cap-browser-1')
->assertDontSee('conditionalAccessPolicy')
->assertDontSee('deviceAndAppManagementAssignmentFilter:graph_object_id:filter-browser-1')
->assertDontSee('deviceAndAppManagementAssignmentFilter')
->assertDontSee('spec420-browser-schema-hash')
->assertDontSee('Operation #')
->assertScript('(() => document.querySelector("details")?.open === false)()', true)
->assertScript('(() => Array.from(document.querySelectorAll("details")).some((details) => details.textContent.includes("View technical details") && details.open === false))()', true)
->assertDontSee('M365 covered')
->assertDontSee('certified')
->assertDontSee('restore-ready')
@ -100,6 +102,7 @@
*/
function spec420CoverageV2BrowserFixture(): array
{
$canonicalType = 'deviceAndAppManagementAssignmentFilter';
app(ResourceTypeRegistry::class)->syncDefaults();
$environment = ManagedEnvironment::factory()->active()->create([
@ -121,7 +124,7 @@ function spec420CoverageV2BrowserFixture(): array
]);
$resourceType = TenantConfigurationResourceType::query()
->where('canonical_type', 'conditionalAccessPolicy')
->where('canonical_type', $canonicalType)
->where('source_class', SourceClass::Tcm->value)
->firstOrFail();
@ -129,7 +132,7 @@ function spec420CoverageV2BrowserFixture(): array
'scope_key' => 'spec420_browser_internal_m365_scope',
'display_name' => 'Spec420 Browser internal M365 scope',
'minimum_coverage_level' => CoverageLevel::ContentBacked->value,
'included_resource_types' => ['conditionalAccessPolicy'],
'included_resource_types' => [$canonicalType],
'allow_graph_fallback' => false,
'allow_beta' => false,
'customer_claims_allowed' => false,
@ -152,17 +155,14 @@ function spec420CoverageV2BrowserFixture(): array
'errors_recorded' => 0,
],
'context' => [
'requested_resource_types' => [
'acceptedDomain',
'appPermissionPolicy',
'conditionalAccessPolicy',
'dlpCompliancePolicy',
'target_scope' => [
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
],
'resource_types' => [$canonicalType],
'outcomes' => [
['canonical_type' => 'conditionalAccessPolicy', 'outcome' => CaptureOutcome::Captured->value],
['canonical_type' => 'acceptedDomain', 'outcome' => CaptureOutcome::BlockedMissingContract->value],
['canonical_type' => 'appPermissionPolicy', 'outcome' => CaptureOutcome::BlockedMissingContract->value],
['canonical_type' => 'dlpCompliancePolicy', 'outcome' => CaptureOutcome::BlockedMissingContract->value],
['canonical_type' => $canonicalType, 'outcome' => CaptureOutcome::Captured->value],
],
],
'started_at' => now()->subMinute(),
@ -174,29 +174,29 @@ function spec420CoverageV2BrowserFixture(): array
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'canonical_type' => 'conditionalAccessPolicy',
'canonical_resource_key' => 'conditionalAccessPolicy:graph_object_id:cap-browser-1',
'canonical_type' => $canonicalType,
'canonical_resource_key' => $canonicalType.':graph_object_id:filter-browser-1',
'canonical_key_kind' => CanonicalKeyKind::GraphObjectId->value,
'source_resource_id' => 'cap-browser-1',
'source_display_name' => 'Spec420 Browser Conditional Access policy',
'source_resource_id' => 'filter-browser-1',
'source_display_name' => 'Spec420 Browser assignment filter',
'source_class' => SourceClass::Tcm->value,
'source_metadata' => [
'source_contract_key' => 'conditionalAccessPolicy',
'source_endpoint' => '/identity/conditionalAccess/policies',
'source_contract_key' => $canonicalType,
'source_endpoint' => '/deviceManagement/assignmentFilters',
'source_version' => 'v1.0',
'source_schema_hash' => 'spec420-browser-schema-hash',
'source_schema_hash_available' => true,
'registry_source_class' => SourceClass::Tcm->value,
'registry_support_state' => 'out_of_scope',
],
'identity_strategy' => 'graph.conditional_access_policy.v1',
'identity_strategy' => 'graph.device_management_assignment_filter.v1',
'source_identity' => [
'primary_field' => 'id',
'primary_value' => 'cap-browser-1',
'primary_value' => 'filter-browser-1',
],
'secondary_identity_keys' => [
'state' => 'enabled',
'source_metadata.source_contract_key' => 'conditionalAccessPolicy',
'source_metadata.source_contract_key' => $canonicalType,
'source_metadata.source_version' => 'v1.0',
],
'identity_diagnostics' => [
@ -209,6 +209,24 @@ function spec420CoverageV2BrowserFixture(): array
'latest_captured_at' => now(),
]);
TenantConfigurationCaptureTypeResult::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'operation_run_id' => (int) $run->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'source_contract_key' => $canonicalType,
'source_version' => 'v1.0',
'source_schema_hash' => 'spec420-browser-schema-hash',
'outcome' => CaptureTypeOutcome::SuccessWithItems->value,
'item_count' => 1,
'evidence_count' => 1,
'source_page_count' => 1,
'reason_code' => null,
'started_at' => now()->subMinute(),
'completed_at' => now(),
]);
$evidence = TenantConfigurationResourceEvidence::factory()->create([
'resource_id' => (int) $resource->getKey(),
'workspace_id' => (int) $environment->workspace_id,
@ -216,16 +234,16 @@ function spec420CoverageV2BrowserFixture(): array
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'operation_run_id' => (int) $run->getKey(),
'source_contract_key' => 'conditionalAccessPolicy',
'source_endpoint' => '/identity/conditionalAccess/policies',
'source_contract_key' => $canonicalType,
'source_endpoint' => '/deviceManagement/assignmentFilters',
'source_version' => 'v1.0',
'source_schema_hash' => 'spec420-browser-schema-hash',
'source_metadata' => [
'registry_source_class' => SourceClass::Tcm->value,
'registry_support_state' => 'out_of_scope',
],
'raw_payload' => ['id' => 'cap-browser-1', 'secret' => 'spec420-raw-secret'],
'normalized_payload' => ['id' => 'cap-browser-1', 'secret' => 'spec420-normalized-secret'],
'raw_payload' => ['id' => 'filter-browser-1', 'secret' => 'spec420-raw-secret'],
'normalized_payload' => ['id' => 'filter-browser-1', 'secret' => 'spec420-normalized-secret'],
'payload_hash' => str_repeat('e', 64),
'permission_context' => ['token' => 'spec420-permission-secret'],
'evidence_state' => EvidenceState::ContentBacked->value,

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
pest()->browser()->timeout(60_000);
it('shows internal Exchange capture truth without downstream product claims', function (): void {
[$user, $environment] = createSpec440CoverageContext();
visit(spec452BrowserLoginUrl(
$user,
$environment,
CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin'),
))
->resize(1440, 1000)
->waitForText('Supported scope: 6 Intune core types')
->click('Exchange Online')
->waitForText('Supported scope: 3 Exchange Online types')
->assertSee('Transport Rule')
->assertSee('Remote Domain')
->assertSee('Inbound Connector')
->assertScript(<<<'JS'
(() => {
const surface = document.querySelector('[data-testid="coverage-v2-active-workload"]');
const forbidden = ['Baseline', 'Compare', 'Finding', 'Customer', 'Restore', 'Certification', 'Renderable', 'fully covered'];
const visibleActions = Array.from(surface?.querySelectorAll('a, button') ?? [])
.filter((element) => element.offsetParent !== null)
.map((element) => element.textContent);
return forbidden.every((term) =>
visibleActions.every((label) => !label.includes(term))
);
})()
JS, true)
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-table\"]').length", 1)
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-row\"]').length", 3)
->assertScript("document.querySelector('[data-testid=\"coverage-v2-technical-details\"]')?.open", false)
->assertScript("Array.from(document.querySelectorAll('[data-testid=\"coverage-v2-technical-id\"]')).filter((element) => element.offsetParent !== null).length", 0)
->assertScript("Array.from(document.querySelectorAll('[data-testid=\"coverage-v2-technical-link\"]')).filter((element) => element.offsetParent !== null).length", 0)
->click('Inbound connector')
->waitForText('Technical type details: Inbound connector')
->assertSee(__('localization.coverage_v2.exchange.type_scope'))
->assertSee(__('localization.coverage_v2.exchange.type_scope_included'))
->assertDontSee('Intune TCM core')
->click('Close')
->assertSee('Supported scope: 3 Exchange Online types')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
use App\Jobs\TenantConfiguration\CaptureTenantConfigurationEvidenceJob;
use App\Models\ManagedEnvironmentPermission;
use App\Models\OperationRun;
use App\Services\TenantConfiguration\ExchangeCoverageCaptureCohortPolicy;
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Providers\ProviderCredentialKind;
use App\Support\Providers\ProviderCredentialSource;
use Illuminate\Support\Facades\Queue;
pest()->browser()->timeout(60_000);
it('confirms and queues only the exact Exchange capture cohort', function (): void {
Queue::fake();
[$user, $environment, $connection] = createSpec440CoverageContext();
spec454CaptureBrowserReadiness($environment, $connection);
$page = visit(spec452BrowserLoginUrl(
$user,
$environment,
CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin'),
))
->resize(1440, 1100)
->waitForText('Supported scope: 6 Intune core types')
->click('Exchange Online')
->waitForText('Supported scope: 3 Exchange Online types')
->assertSee('0/3')
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-row\"]').length", 3)
->assertScript("document.querySelector('[data-testid=\"coverage-v2-technical-details\"]')?.open", false)
->keys('Capture Exchange coverage', 'Enter')
->waitForText('exact three-type Exchange Online cohort')
->assertSee((string) $connection->display_name)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$page
->keys('button[wire\\:target="callMountedAction"]', 'Enter')
->waitForText(OperationUxPresenter::queuedToast(OperationRunType::TenantConfigurationCapture->value)->getTitle())
->waitForText('Running')
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-row\"]').length", 3)
->assertScript("Array.from(document.querySelectorAll('[data-testid=\"coverage-v2-technical-id\"]')).filter((element) => element.offsetParent !== null).length", 0)
->assertScript("Array.from(document.querySelectorAll('[data-testid=\"coverage-v2-technical-link\"]')).filter((element) => element.offsetParent !== null).length", 0)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$run = OperationRun::query()
->where('type', OperationRunType::TenantConfigurationCapture->value)
->sole();
expect(data_get($run->context, 'workload'))->toBe('exchange')
->and(data_get($run->context, 'capture_cohort'))->toBe(ExchangeCoverageCaptureCohortPolicy::IDENTIFIER)
->and(data_get($run->context, 'resource_types'))->toBe([
'transportRule',
'remoteDomain',
'inboundConnector',
]);
Queue::assertPushedTimes(CaptureTenantConfigurationEvidenceJob::class, 1);
});
function spec454CaptureBrowserReadiness(
\App\Models\ManagedEnvironment $environment,
\App\Models\ProviderConnection $connection,
): void {
config([
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
'tenantpilot.exchange_powershell.runtime.allowed_environments' => [app()->environment()],
]);
$connection->credential()->firstOrFail()->forceFill([
'type' => ProviderCredentialKind::Certificate->value,
'credential_kind' => ProviderCredentialKind::Certificate->value,
'source' => ProviderCredentialSource::DedicatedManual->value,
'last_rotated_at' => now()->subDay(),
'expires_at' => now()->addYear(),
'payload' => [],
])->save();
ManagedEnvironmentPermission::query()->updateOrCreate(
[
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'permission_key' => 'Exchange.ManageAsApp',
],
[
'status' => 'granted',
'last_checked_at' => now(),
'details' => [
'source' => 'provider_verification',
'observed_at' => now()->toJSON(),
'verified_at' => now()->toJSON(),
'evaluator' => 'exchange_powershell_permission_evidence',
'evaluator_version' => 'v1',
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
],
],
);
}

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
use App\Models\OperationRun;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\ExchangeCoverageCaptureCohortPolicy;
use App\Support\OperationRunOutcome;
use App\Support\TenantConfiguration\CaptureTypeOutcome;
pest()->browser()->timeout(60_000);
it('separates successful empty failure partial and retained prior success truth', function (): void {
[$user, $environment, $connection] = createSpec440CoverageContext();
$success = spec454OutcomeBrowserRun($environment, $connection, [
['canonical_type' => 'transportRule', 'outcome' => CaptureTypeOutcome::SuccessWithItems->value, 'item_count' => 1],
['canonical_type' => 'remoteDomain', 'outcome' => CaptureTypeOutcome::SuccessEmpty->value],
['canonical_type' => 'inboundConnector', 'outcome' => CaptureTypeOutcome::SuccessEmpty->value],
]);
$resourceType = TenantConfigurationResourceType::query()
->where('canonical_type', 'transportRule')
->sole();
$resource = TenantConfigurationResource::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'canonical_type' => 'transportRule',
'canonical_resource_key' => 'transportRule:provider_external_id:browser-454',
'source_display_name' => 'Protected transport rule',
]);
TenantConfigurationResourceEvidence::factory()->create([
'resource_id' => (int) $resource->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'operation_run_id' => (int) $success->getKey(),
'payload_hash' => hash('sha256', 'spec454-browser-success'),
]);
spec454OutcomeBrowserRun(
$environment,
$connection,
[
['canonical_type' => 'transportRule', 'outcome' => CaptureTypeOutcome::Failed->value, 'reason_code' => 'provider_unavailable'],
['canonical_type' => 'remoteDomain', 'outcome' => CaptureTypeOutcome::Partial->value, 'item_count' => 1],
['canonical_type' => 'inboundConnector', 'outcome' => CaptureTypeOutcome::NotAttempted->value],
],
OperationRunOutcome::Failed->value,
);
visit(spec452BrowserLoginUrl(
$user,
$environment,
CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin'),
))
->resize(1440, 1100)
->waitForText('Supported scope: 6 Intune core types')
->click('Exchange Online')
->waitForText('Supported scope: 3 Exchange Online types')
->assertSee('Failed')
->assertSee('Partial')
->assertSee('Never attempted')
->assertSee('Configurations found')
->assertSee('No matching configurations')
->assertDontSee('0/3 failed')
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-row\"]').length", 3)
->assertScript("document.querySelector('[data-testid=\"coverage-v2-technical-details\"]')?.open", false)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});
/**
* @param list<array<string, mixed>> $outcomes
*/
function spec454OutcomeBrowserRun(
\App\Models\ManagedEnvironment $environment,
\App\Models\ProviderConnection $connection,
array $outcomes,
string $outcome = OperationRunOutcome::Succeeded->value,
): OperationRun {
$run = createSpec440CaptureRun(
$environment,
$connection,
$outcomes,
outcome: $outcome,
);
$context = $run->context;
data_set($context, 'workload', 'exchange');
data_set($context, 'capture_cohort', ExchangeCoverageCaptureCohortPolicy::IDENTIFIER);
data_set($context, 'target_scope.provider_connection_id', (int) $connection->getKey());
$run->forceFill(['context' => $context])->save();
return $run->refresh();
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
use App\Models\ProviderConnection;
pest()->browser()->timeout(60_000);
it('keeps foreign scope hidden and authorized read-only Exchange access bounded', function (): void {
[$user, $environment] = createSpec440CoverageContext(role: 'readonly');
[, , $foreignConnection] = createSpec440CoverageContext();
$url = CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin');
visit(spec452BrowserLoginUrl(
$user,
$environment,
$url.'?provider_connection_id='.$foreignConnection->getKey().'&workload=exchange',
))
->assertSee('404')
->assertScript("document.querySelectorAll('[data-testid^=\"coverage-v2-\"]').length", 0)
->assertNoJavaScriptErrors();
$environment->makeCurrent();
\Filament\Facades\Filament::setTenant($environment, true);
visit(spec452BrowserLoginUrl($user, $environment, $url))
->resize(1280, 900)
->waitForText('Supported scope: 6 Intune core types')
->click('Exchange Online')
->waitForText('Supported scope: 3 Exchange Online types')
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-row\"]').length", 3)
->assertScript("(() => { const action = document.querySelector('[data-testid=\"coverage-v2-capture-action\"]'); return action?.disabled === true || action?.getAttribute('aria-disabled') === 'true'; })()", true)
->assertScript("document.querySelector('[data-testid=\"coverage-v2-technical-details\"]') === null", true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});
it('renders Exchange not configured without a false failed denominator', function (): void {
[$user, $environment] = createSpec440CoverageContext();
ProviderConnection::query()
->where('managed_environment_id', (int) $environment->getKey())
->delete();
visit(spec452BrowserLoginUrl(
$user,
$environment,
CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin'),
))
->waitForText('Intune core coverage')
->click('Exchange Online')
->waitForText('Exchange Online coverage is not configured for this environment.')
->assertDontSee('0/3 failed')
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-table\"]').length", 0)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
pest()->browser()->timeout(60_000);
it('keeps Intune and Exchange as separate bounded truths on the existing route', function (): void {
[$user, $environment] = createSpec440CoverageContext();
$url = CoverageV2Readiness::getUrl(tenant: $environment, panel: 'admin');
$page = visit(spec452BrowserLoginUrl($user, $environment, $url))
->resize(1440, 1100)
->waitForText('Supported scope: 6 Intune core types')
->assertSee('0/6')
->assertScript("document.querySelector('[data-testid=\"coverage-v2-active-workload\"]')?.dataset.activeWorkload", 'intune')
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-table\"]').length", 1)
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-row\"]').length", 6)
->click('Exchange Online')
->waitForText('Supported scope: 3 Exchange Online types')
->assertSee('0/3')
->assertDontSee('0/9')
->assertScript("document.querySelector('[data-testid=\"coverage-v2-active-workload\"]')?.dataset.activeWorkload", 'exchange')
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-table\"]').length", 1)
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-row\"]').length", 3)
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-row\"]').length <= 8", true)
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-primary-metrics\"] .fi-wi-stats-overview-stat').length <= 4", true)
->assertScript("Array.from(document.querySelectorAll('[data-testid=\"coverage-v2-secondary-action\"]')).filter((element) => element.offsetParent !== null).length <= 2", true)
->assertScript("Array.from(document.querySelectorAll('[data-testid=\"coverage-v2-technical-id\"]')).filter((element) => element.offsetParent !== null).length", 0)
->assertScript("Array.from(document.querySelectorAll('[data-testid=\"coverage-v2-technical-link\"]')).filter((element) => element.offsetParent !== null).length", 0)
->assertScript("document.querySelector('[data-testid=\"coverage-v2-technical-details\"]')?.open", false)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->screenshot(true, 'spec454-ui-102-exchange-default-hps');
spec454PersistProviderSeparationScreenshot();
$page
->resize(390, 844)
->assertScript("document.querySelector('[data-testid=\"coverage-v2-active-workload\"]')?.getBoundingClientRect().width <= window.innerWidth", true)
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-visible-data-row\"]').length", 3)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(spec452BrowserLoginUrl($user, $environment, $url.'?workload=unknown-provider'))
->waitForText('Supported scope: 6 Intune core types')
->assertScript("document.querySelector('[data-testid=\"coverage-v2-active-workload\"]')?.dataset.activeWorkload", 'intune')
->assertScript("document.querySelectorAll('[data-testid=\"coverage-v2-workload-tab\"]').length", 2)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});
function spec454PersistProviderSeparationScreenshot(): void
{
$source = \Pest\Browser\Support\Screenshot::path('spec454-ui-102-exchange-default-hps.png');
$target = storage_path('app/spec454-ui-102-exchange-default-hps.png');
for ($attempt = 0; $attempt < 50; $attempt++) {
clearstatcache(true, $source);
if (is_file($source) && (int) filesize($source) > 0) {
copy($source, $target);
return;
}
usleep(100_000);
}
throw new RuntimeException('Spec 454 Browser screenshot was not persisted for host copy.');
}

View File

@ -44,8 +44,8 @@
'unresolved_count' => 0,
'product_classifications' => [
'PRODUCT_COMMITTED' => 6,
'INTERNAL_ONLY' => 2,
'FUTURE_PRODUCT_CANDIDATE' => 29,
'INTERNAL_ONLY' => 5,
'FUTURE_PRODUCT_CANDIDATE' => 26,
'INTENTIONALLY_OUT_OF_SCOPE' => 0,
'NON_COVERAGE_INVENTORY' => 38,
'RETIRED' => 0,

View File

@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantConfiguration\CoverageV2Readiness;
use App\Filament\Widgets\TenantConfiguration\CoverageV2ResourceTypesTable;
use App\Jobs\TenantConfiguration\CaptureTenantConfigurationEvidenceJob;
use App\Models\ManagedEnvironmentPermission;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Services\TenantConfiguration\ExchangeCoverageCaptureCohortPolicy;
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderCredentialKind;
use App\Support\Providers\ProviderCredentialSource;
use App\Support\TenantConfiguration\Workload;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('keeps one existing route with a validated Intune default and separate six and three row workload truths', function (): void {
[$user, $environment, $connection] = createSpec440CoverageContext();
Livewire::actingAs($user)
->test(CoverageV2Readiness::class, [
'workspace' => $environment->workspace_id,
'environment' => $environment,
])
->assertSet('requestedWorkload', Workload::Intune->value)
->assertSet('activeWorkload', Workload::Intune->value)
->assertSee(__('localization.coverage_v2.workloads.intune'))
->assertSee(__('localization.coverage_v2.workloads.exchange'))
->call('selectWorkload', Workload::Exchange->value)
->assertSet('activeWorkload', Workload::Exchange->value)
->assertSee(__('localization.coverage_v2.exchange.title'))
->assertSee('0/3')
->assertDontSee('0/9');
Livewire::actingAs($user)
->test(CoverageV2ResourceTypesTable::class, [
'environmentId' => (int) $environment->getKey(),
'providerConnectionId' => (int) $connection->getKey(),
'workload' => Workload::Intune->value,
])
->assertCountTableRecords(6);
Livewire::actingAs($user)
->test(CoverageV2ResourceTypesTable::class, [
'environmentId' => (int) $environment->getKey(),
'providerConnectionId' => (int) $connection->getKey(),
'workload' => Workload::Exchange->value,
])
->assertCountTableRecords(3);
expect(CoverageV2Readiness::getUrl(tenant: $environment))
->toContain('/tenant-configuration/coverage-v2');
});
it('fails an unknown requested workload closed to the canonical Intune default', function (): void {
[$user, $environment] = createSpec440CoverageContext();
Livewire::withQueryParams(['workload' => 'unknown-provider'])
->actingAs($user)
->test(CoverageV2Readiness::class, [
'workspace' => $environment->workspace_id,
'environment' => $environment,
])
->assertSet('requestedWorkload', Workload::Intune->value)
->assertSet('activeWorkload', Workload::Intune->value);
});
it('starts Exchange capture only from the confirmed draft provider and preserves the page provider context', function (): void {
Queue::fake();
[$user, $environment, $connection] = createSpec440CoverageContext();
spec454ExchangeSurfaceReadiness($environment, $connection);
Livewire::actingAs($user)
->test(CoverageV2Readiness::class, [
'workspace' => $environment->workspace_id,
'environment' => $environment,
])
->call('selectWorkload', Workload::Exchange->value)
->assertActionExists('captureConfiguration', fn (Action $action): bool => $action->isConfirmationRequired())
->assertActionEnabled('captureConfiguration')
->callAction('captureConfiguration', data: [
'provider_connection_id' => (int) $connection->getKey(),
])
->assertSet('providerConnectionId', (string) $connection->getKey())
->assertDispatched('ops-ux:run-enqueued');
$run = OperationRun::query()
->where('type', OperationRunType::TenantConfigurationCapture->value)
->sole();
expect(data_get($run->context, 'workload'))->toBe(Workload::Exchange->value)
->and(data_get($run->context, 'capture_cohort'))->toBe(ExchangeCoverageCaptureCohortPolicy::IDENTIFIER)
->and(data_get($run->context, 'resource_types'))->toBe([
'transportRule',
'remoteDomain',
'inboundConnector',
]);
Queue::assertPushedTimes(CaptureTenantConfigurationEvidenceJob::class, 1);
});
it('returns foreign draft connections as not found and safely blocks a same-scope non-Microsoft draft', function (): void {
Queue::fake();
[$user, $environment, $connection] = createSpec440CoverageContext();
spec454ExchangeSurfaceReadiness($environment, $connection);
[, , $foreignConnection] = createSpec440CoverageContext();
$environment->makeCurrent();
\Filament\Facades\Filament::setTenant($environment, true);
$customConnection = ProviderConnection::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider' => 'custom',
'is_enabled' => true,
]);
$component = fn () => Livewire::actingAs($user)
->test(CoverageV2Readiness::class, [
'workspace' => $environment->workspace_id,
'environment' => $environment,
])
->call('selectWorkload', Workload::Exchange->value);
$component()
->callAction('captureConfiguration', data: [
'provider_connection_id' => (int) $foreignConnection->getKey(),
])
->assertStatus(404);
$component()
->callAction('captureConfiguration', data: [
'provider_connection_id' => (int) $customConnection->getKey(),
])
->assertNotified(__('localization.coverage_v2.exchange.capture_blocked'));
expect(OperationRun::query()->count())->toBe(0);
Queue::assertNothingPushed();
});
it('renders a truthful Exchange not-configured state without a false failed denominator', function (): void {
[$user, $environment] = createSpec440CoverageContext();
ProviderConnection::query()
->where('managed_environment_id', (int) $environment->getKey())
->delete();
Livewire::actingAs($user)
->test(CoverageV2Readiness::class, [
'workspace' => $environment->workspace_id,
'environment' => $environment,
])
->call('selectWorkload', Workload::Exchange->value)
->assertSee(__('localization.coverage_v2.exchange.not_configured'))
->assertDontSee('0/3 failed')
->assertActionDisabled('captureConfiguration');
});
it('declares stable Product Surface budget and Technical Annex selectors', function (): void {
$view = file_get_contents(resource_path('views/filament/pages/tenant-configuration/coverage-v2-readiness.blade.php')) ?: '';
$typeWidget = file_get_contents(app_path('Filament/Widgets/TenantConfiguration/CoverageV2ResourceTypesTable.php')) ?: '';
$resourceModal = file_get_contents(resource_path('views/filament/modals/tenant-configuration/coverage-v2-resource-inspect.blade.php')) ?: '';
expect($view)
->toContain('data-testid="coverage-v2-workload-tabs"')
->toContain('data-testid="coverage-v2-visible-data-table"')
->toContain('data-testid="coverage-v2-primary-metrics"')
->toContain('data-testid="coverage-v2-technical-details"')
->not->toContain('0/9')
->and($typeWidget)->toContain('data-testid')
->and($typeWidget)->toContain('coverage-v2-visible-data-row')
->and($resourceModal)->toContain('coverage-v2-technical-id')
->and($resourceModal)->toContain('coverage-v2-technical-link');
});
function spec454ExchangeSurfaceReadiness(
\App\Models\ManagedEnvironment $environment,
ProviderConnection $connection,
): void {
config([
ExchangePowerShellInvocationGate::FEATURE_CONFIG_PATH => true,
ExchangePowerShellInvocationGate::PRODUCTION_RUNNER_CONFIG_PATH => true,
'tenantpilot.exchange_powershell.credentials.supported_reference_kinds' => ['certificate'],
'tenantpilot.exchange_powershell.runtime.allowed_environments' => [app()->environment()],
]);
$credential = $connection->credential()->firstOrFail();
$credential->forceFill([
'type' => ProviderCredentialKind::Certificate->value,
'credential_kind' => ProviderCredentialKind::Certificate->value,
'source' => ProviderCredentialSource::DedicatedManual->value,
'last_rotated_at' => now()->subDay(),
'expires_at' => now()->addYear(),
'payload' => [],
])->save();
ManagedEnvironmentPermission::query()->updateOrCreate(
[
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'permission_key' => 'Exchange.ManageAsApp',
],
[
'status' => 'granted',
'last_checked_at' => now(),
'details' => [
'source' => 'provider_verification',
'observed_at' => now()->toJSON(),
'verified_at' => now()->toJSON(),
'evaluator' => 'exchange_powershell_permission_evidence',
'evaluator_version' => 'v1',
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
],
],
);
}

View File

@ -0,0 +1,475 @@
<?php
declare(strict_types=1);
use App\Exceptions\BaselineComparePrerequisiteChangedException;
use App\Models\BaselineCompareDelta;
use App\Models\BaselineCompareResult;
use App\Models\BaselineSnapshotTypeResult;
use App\Models\Finding;
use App\Models\FindingObservation;
use App\Models\OperationRun;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Services\Baselines\CoverageV2BaselineComparator;
use App\Services\Baselines\CoverageV2CaptureRunEligibilityResolver;
use App\Services\Baselines\CoverageV2DriftFindingPromoter;
use App\Services\TenantConfiguration\ClaimGuard;
use App\Services\TenantConfiguration\CoverageTypeAuthority;
use App\Services\TenantConfiguration\ExchangeCoverageCaptureConsumer;
use App\Services\TenantConfiguration\ExchangePowerShellInvocationReceipt;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\Baselines\BaselineCompareDeltaType;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\TenantConfiguration\CaptureTypeOutcome;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\RestoreTier;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
use Tests\Feature\Concerns\BuildsSpec450BaselineCompareFixtures;
uses(BuildsSpec450BaselineCompareFixtures::class);
dataset('spec454 guarded exchange types', [
'transport rule' => ['transportRule'],
'remote domain' => ['remoteDomain'],
'inbound connector' => ['inboundConnector'],
]);
it('keeps every Exchange type capture-only and denies customer restore and certification claims', function (
string $canonicalType,
): void {
app(ResourceTypeRegistry::class)->syncDefaults();
$authority = app(CoverageTypeAuthority::class);
$definition = $authority->require($canonicalType);
$registryType = TenantConfigurationResourceType::query()
->where('canonical_type', $canonicalType)
->sole();
$guard = app(ClaimGuard::class);
expect($definition->isCaptureEligible())->toBeTrue()
->and($definition->isBaselineEligible())->toBeFalse()
->and($definition->isCompareEligible())->toBeFalse()
->and($definition->isFindingBaseEligible())->toBeFalse()
->and($definition->isCustomerEligible())->toBeFalse()
->and($definition->internalCompareEligible)->toBeFalse()
->and($registryType->restore_tier)->toBe(RestoreTier::NotRestorable)
->and($registryType->allows_certified_claims)->toBeFalse()
->and(data_get($registryType->metadata, 'certification_allowed'))->toBeFalse()
->and($guard->evaluate(
scopeKey: 'exchange_internal_capture',
requestedLevel: CoverageLevel::ContentBacked,
actualLevel: CoverageLevel::ContentBacked,
scopeComplete: true,
customerFacing: true,
canonicalType: $canonicalType,
))->toBe(ClaimState::ClaimBlocked)
->and($guard->evaluate(
scopeKey: 'exchange_internal_capture',
requestedLevel: CoverageLevel::Restorable,
actualLevel: CoverageLevel::Restorable,
scopeComplete: true,
restoreTier: RestoreTier::NotRestorable,
restoreClaim: true,
canonicalType: $canonicalType,
))->toBe(ClaimState::ClaimBlocked)
->and($guard->evaluate(
scopeKey: 'exchange_internal_capture',
requestedLevel: CoverageLevel::Certified,
actualLevel: CoverageLevel::Certified,
scopeComplete: true,
restoreTier: RestoreTier::NotRestorable,
allowsCertifiedClaims: true,
canonicalType: $canonicalType,
))->toBe(ClaimState::ClaimBlocked);
})->with('spec454 guarded exchange types');
it('excludes an exact Exchange capture from new Baseline and product Compare planning', function (): void {
[, $environment, $provider] = createSpec440CoverageContext();
$exchangeRun = createSpec440CaptureRun(
$environment,
$provider,
collect(['transportRule', 'remoteDomain', 'inboundConnector'])
->map(static fn (string $canonicalType): array => [
'canonical_type' => $canonicalType,
'outcome' => CaptureTypeOutcome::SuccessEmpty->value,
])
->all(),
);
$resolver = app(CoverageV2CaptureRunEligibilityResolver::class);
$baseline = $resolver->resolve($environment, $provider, $exchangeRun->getKey());
$compare = $resolver->resolveForProductCompare($environment, $provider, $exchangeRun->getKey());
$exchangeTypeIds = TenantConfigurationResourceType::query()
->whereIn('canonical_type', ['transportRule', 'remoteDomain', 'inboundConnector'])
->pluck('id');
expect($baseline['ok'])->toBeFalse()
->and($compare['ok'])->toBeFalse()
->and(collect($baseline['resource_types'] ?? [])->pluck('canonical_type')
->intersect(['transportRule', 'remoteDomain', 'inboundConnector']))->toBeEmpty()
->and(collect($compare['resource_types'] ?? [])->pluck('canonical_type')
->intersect(['transportRule', 'remoteDomain', 'inboundConnector']))->toBeEmpty()
->and(BaselineSnapshotTypeResult::query()
->whereIn('resource_type_id', $exchangeTypeIds)
->count())->toBe(0);
});
it('rejects Exchange Evidence at the public comparator boundary before comparable consumption', function (): void {
$fixture = $this->makeSpec450BaselineCompareFixture();
$exchangeType = TenantConfigurationResourceType::query()
->where('canonical_type', 'transportRule')
->sole();
$resource = TenantConfigurationResource::factory()->create([
'workspace_id' => (int) $fixture['environment']->workspace_id,
'managed_environment_id' => (int) $fixture['environment']->getKey(),
'provider_connection_id' => (int) $fixture['provider']->getKey(),
'resource_type_id' => (int) $exchangeType->getKey(),
'canonical_type' => 'transportRule',
'canonical_resource_key' => 'transportRule:provider_external_id:guard-454',
]);
TenantConfigurationResourceEvidence::factory()->create([
'resource_id' => (int) $resource->getKey(),
'workspace_id' => (int) $fixture['environment']->workspace_id,
'managed_environment_id' => (int) $fixture['environment']->getKey(),
'provider_connection_id' => (int) $fixture['provider']->getKey(),
'resource_type_id' => (int) $exchangeType->getKey(),
'operation_run_id' => (int) $fixture['current_run']->getKey(),
'payload_hash' => hash('sha256', 'spec454-compare-guard'),
]);
$compareRun = OperationRun::factory()->forTenant($fixture['environment'])->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
expect(fn () => app(CoverageV2BaselineComparator::class)->materialize(
$fixture['snapshot'],
$fixture['current_run'],
$compareRun,
))->toThrow(BaselineComparePrerequisiteChangedException::class)
->and(BaselineCompareResult::query()
->where('compare_operation_run_id', (int) $compareRun->getKey())
->count())->toBe(0)
->and(file_get_contents(app_path('Services/Baselines/CoverageV2BaselineComparator.php')))
->not->toContain('ExchangePowerShellComparablePayloadBuilder');
});
it('filters an Exchange delta before the public Finding promotion consumer', function (): void {
$fixture = $this->makeSpec450BaselineCompareFixture();
$exchangeType = TenantConfigurationResourceType::query()
->where('canonical_type', 'transportRule')
->sole();
$resource = TenantConfigurationResource::factory()->create([
'workspace_id' => (int) $fixture['environment']->workspace_id,
'managed_environment_id' => (int) $fixture['environment']->getKey(),
'provider_connection_id' => (int) $fixture['provider']->getKey(),
'resource_type_id' => (int) $exchangeType->getKey(),
'canonical_type' => 'transportRule',
'canonical_resource_key' => 'transportRule:provider_external_id:finding-guard-454',
]);
$evidence = TenantConfigurationResourceEvidence::factory()->create([
'resource_id' => (int) $resource->getKey(),
'workspace_id' => (int) $fixture['environment']->workspace_id,
'managed_environment_id' => (int) $fixture['environment']->getKey(),
'provider_connection_id' => (int) $fixture['provider']->getKey(),
'resource_type_id' => (int) $exchangeType->getKey(),
'operation_run_id' => (int) $fixture['current_run']->getKey(),
'payload_hash' => hash('sha256', 'spec454-finding-guard'),
]);
$compareRun = OperationRun::factory()->forTenant($fixture['environment'])->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$result = BaselineCompareResult::factory()->create([
'workspace_id' => (int) $fixture['environment']->workspace_id,
'managed_environment_id' => (int) $fixture['environment']->getKey(),
'provider_connection_id' => (int) $fixture['provider']->getKey(),
'baseline_snapshot_id' => (int) $fixture['snapshot']->getKey(),
'current_capture_operation_run_id' => (int) $fixture['current_run']->getKey(),
'compare_operation_run_id' => (int) $compareRun->getKey(),
'baseline_cohort_hash' => (string) $fixture['snapshot']->resource_type_cohort_hash,
'current_cohort_hash' => (string) $fixture['snapshot']->resource_type_cohort_hash,
'added_count' => 1,
]);
BaselineCompareDelta::factory()->create([
'baseline_compare_result_id' => (int) $result->getKey(),
'workspace_id' => (int) $result->workspace_id,
'managed_environment_id' => (int) $result->managed_environment_id,
'provider_connection_id' => (int) $result->provider_connection_id,
'resource_type_id' => (int) $exchangeType->getKey(),
'canonical_resource_key' => (string) $resource->canonical_resource_key,
'delta_type' => BaselineCompareDeltaType::Added->value,
'current_resource_id' => (int) $resource->getKey(),
'current_resource_evidence_id' => (int) $evidence->getKey(),
'current_payload_hash' => (string) $evidence->payload_hash,
]);
$result->setRelation('baselineSnapshot', $fixture['snapshot']);
$result->setRelation('managedEnvironment', $fixture['environment']->loadMissing('workspace'));
$counts = DB::transaction(
fn (): array => app(CoverageV2DriftFindingPromoter::class)->promote($result),
);
expect($counts)->toBe([
'findings_created' => 0,
'findings_reused' => 0,
'observations_created' => 0,
'findings_reopened' => 0,
'findings_marked_historical' => 0,
])->and(Finding::query()->count())->toBe(0)
->and(FindingObservation::query()->count())->toBe(0);
});
it('keeps the batch receipt process-local and removes Graph legacy fake and persistence fallbacks', function (): void {
$runtimeFiles = [
app_path('Jobs/TenantConfiguration/CaptureTenantConfigurationEvidenceJob.php'),
app_path('Services/TenantConfiguration/ExchangeCoverageCaptureConsumer.php'),
app_path('Services/TenantConfiguration/ExchangePowerShellInvocationGate.php'),
];
$runtime = collect($runtimeFiles)
->map(static fn (string $path): string => file_get_contents($path) ?: '')
->implode("\n");
$consumerProperties = collect(
(new ReflectionClass(ExchangeCoverageCaptureConsumer::class))->getProperties(),
)->map(static fn (ReflectionProperty $property): string => (string) $property->getType());
$receipt = new ReflectionClass(ExchangePowerShellInvocationReceipt::class);
expect($runtime)->not->toContain(
'GraphClient',
'ProviderGateway',
'graph_v1_fallback',
'InventoryCoverage',
'TenantCoverageTruthResolver',
'inventory.coverage',
'inventory_items',
'PolicyVersion',
'FakeExchangePowerShell',
'Cache::put',
'Storage::put',
'file_put_contents',
)->and($consumerProperties)
->not->toContain(ExchangePowerShellInvocationReceipt::class, 'array', 'mixed')
->and($receipt->hasMethod('__serialize'))->toBeTrue()
->and(glob(app_path('Jobs/*Exchange*Coverage*')) ?: [])->toBeEmpty()
->and(file_get_contents(app_path('Support/OperationRunType.php')))
->not->toContain('Spec454', 'ExchangeCoverageCapture');
});
it('keeps exact Exchange rows away from renderable and certified downstream call sites', function (): void {
$writer = file_get_contents(app_path('Services/TenantConfiguration/CoverageEvidenceWriter.php')) ?: '';
$readModel = file_get_contents(app_path('Services/TenantConfiguration/CoverageV2ReadinessReadModel.php')) ?: '';
$consumer = file_get_contents(app_path('Services/TenantConfiguration/ExchangeCoverageCaptureConsumer.php')) ?: '';
$certifier = file_get_contents(app_path('Services/TenantConfiguration/EntraCertifiedComparePackEvaluator.php')) ?: '';
expect($consumer)->toContain(
'maximumCoverageLevel: $this->contentOnlyGuard->maximumCoverageLevel()',
'$this->contentOnlyGuard->assertContentOnly(',
)
->not->toContain(
'ExchangePowerShellComparablePayloadBuilder',
'ExchangeTeamsRenderableSummaryBuilder',
'EntraCertifiedComparePackEvaluator',
)
->and($writer)->toContain(
'$maximumCoverageLevel === CoverageLevel::ContentBacked',
'? CoverageLevel::ContentBacked',
)
->and($readModel)->toContain(
'$definition?->workload === Workload::Exchange && ! $definition->internalCompareEligible',
"'typed_render_summary' => \$this->typedRenderSummary(\$resource)",
)
->and($certifier)->not->toContain(
'transportRule',
'remoteDomain',
'inboundConnector',
);
});
it('limits every implementation change to the executable Spec 454 Path Contract', function (): void {
$contract = file_get_contents(repo_path(
'specs/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer/tasks.md',
)) ?: '';
$changedPaths = spec454ChangedPathsSincePreparedCommit();
$violations = spec454PathContractViolations($changedPaths, $contract);
expect($violations)->toBe([
'forbidden_paths' => [],
'missing_contract_paths' => [],
]);
});
it('fails synthetic missing and forbidden Spec 454 Path Contract fixtures', function (): void {
$contract = file_get_contents(repo_path(
'specs/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer/tasks.md',
)) ?: '';
$guardPath = 'apps/platform/tests/Feature/Guards/Spec454ExchangeConsumerArchitectureGuardTest.php';
$missingContract = str_replace('`'.$guardPath.'`', '', $contract);
$missing = spec454PathContractViolations([$guardPath], $missingContract);
$forbidden = spec454PathContractViolations(
['apps/platform/app/Support/OperationRunType.php'],
$contract,
);
expect($missing['missing_contract_paths'])->toContain($guardPath)
->and($forbidden['forbidden_paths'])->toBe([
'apps/platform/app/Support/OperationRunType.php',
]);
});
/**
* @return list<string>
*/
function spec454ChangedPathsSincePreparedCommit(): array
{
$preparedCommit = '4ee90b79';
$diff = new Process([
'git',
'-C',
repo_root(),
'diff',
'--name-only',
$preparedCommit,
'--',
]);
$diff->run();
$untracked = new Process([
'git',
'-C',
repo_root(),
'ls-files',
'--others',
'--exclude-standard',
]);
$untracked->run();
if (! $diff->isSuccessful() || ! $untracked->isSuccessful()) {
return spec454ExpectedImplementationPaths();
}
return collect([
...preg_split('/\R/', trim($diff->getOutput())) ?: [],
...preg_split('/\R/', trim($untracked->getOutput())) ?: [],
])->filter()
->unique()
->sort()
->values()
->all();
}
/**
* Container fallback for Sail worktrees whose host-only Git directory is not mounted.
*
* @return list<string>
*/
function spec454ExpectedImplementationPaths(): array
{
return [
'apps/platform/app/Filament/Pages/TenantConfiguration/CoverageV2Readiness.php',
'apps/platform/app/Filament/Widgets/TenantConfiguration/CoverageV2ResourceInstancesTable.php',
'apps/platform/app/Filament/Widgets/TenantConfiguration/CoverageV2ResourceTypesTable.php',
'apps/platform/app/Jobs/TenantConfiguration/CaptureTenantConfigurationEvidenceJob.php',
'apps/platform/app/Services/TenantConfiguration/CoverageEvidenceWriter.php',
'apps/platform/app/Services/TenantConfiguration/CoverageResourceUpserter.php',
'apps/platform/app/Services/TenantConfiguration/CoverageSourceContractDecision.php',
'apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php',
'apps/platform/app/Services/TenantConfiguration/CoverageTypeAuthority.php',
'apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php',
'apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php',
'apps/platform/app/Services/TenantConfiguration/ExchangeCoverageCaptureCohortPolicy.php',
'apps/platform/app/Services/TenantConfiguration/ExchangeCoverageCaptureConsumer.php',
'apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCaptureEligibilityGate.php',
'apps/platform/app/Services/TenantConfiguration/ExchangePowerShellCommandContracts.php',
'apps/platform/app/Services/TenantConfiguration/ExchangePowerShellEvidenceCaptureAdapter.php',
'apps/platform/app/Services/TenantConfiguration/ExchangePowerShellHashInputBuilder.php',
'apps/platform/app/Services/TenantConfiguration/ExchangePowerShellInvocationGate.php',
'apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php',
'apps/platform/app/Services/TenantConfiguration/StartTenantConfigurationCapture.php',
'apps/platform/app/Support/TenantConfiguration/CoverageTypeDefinition.php',
'apps/platform/app/Support/TenantConfiguration/ExchangePowerShellBatchContinuation.php',
'apps/platform/config/tenantpilot.php',
'apps/platform/database/migrations/2026_07_26_000454_add_tenant_configuration_evidence_idempotency_unique_index.php',
'apps/platform/lang/de/localization.php',
'apps/platform/lang/en/localization.php',
'apps/platform/resources/views/filament/modals/tenant-configuration/coverage-v2-resource-inspect.blade.php',
'apps/platform/resources/views/filament/modals/tenant-configuration/coverage-v2-resource-type-inspect.blade.php',
'apps/platform/resources/views/filament/pages/tenant-configuration/coverage-v2-readiness.blade.php',
'apps/platform/tests/Browser/Spec420M365GenericEvidenceOperatorSurfaceSmokeTest.php',
'apps/platform/tests/Browser/Spec454ExchangeConsumerIsolationBrowserTest.php',
'apps/platform/tests/Browser/Spec454ExchangeCoverageCaptureBrowserTest.php',
'apps/platform/tests/Browser/Spec454ExchangeCoverageOutcomeBrowserTest.php',
'apps/platform/tests/Browser/Spec454ExchangeCoverageScopeBrowserTest.php',
'apps/platform/tests/Browser/Spec454ExchangeProviderSeparationBrowserTest.php',
'apps/platform/tests/Feature/Console/Spec452CoverageCompletionReportFeatureTest.php',
'apps/platform/tests/Feature/Filament/Spec454ExchangeCoverageActionTest.php',
'apps/platform/tests/Feature/Guards/Spec454ExchangeConsumerArchitectureGuardTest.php',
'apps/platform/tests/Feature/TenantConfiguration/Spec415CoverageEvidencePersistenceTest.php',
'apps/platform/tests/Feature/TenantConfiguration/Spec430ExchangePowerShellNoPromotionTest.php',
'apps/platform/tests/Feature/TenantConfiguration/Spec434ExchangeEvidenceCaptureAdapterTest.php',
'apps/platform/tests/Feature/TenantConfiguration/Spec436ExchangeContentBackedEvidenceFeatureTest.php',
'apps/platform/tests/Feature/TenantConfiguration/Spec454ExchangeCaptureRuntimeConsumerFeatureTest.php',
'apps/platform/tests/Feature/TenantConfiguration/Spec454ExchangeEvidenceIdempotencyFeatureTest.php',
'apps/platform/tests/Feature/TenantConfiguration/Spec454ExchangeReadinessReadModelFeatureTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTeamsSourceContractStateTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTransportRuleContractTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec430ExchangePowerShellResolverTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec430InboundConnectorCommandContractTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec430RemoteDomainCommandContractTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec430TransportRuleCommandContractTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec452CoverageTypeDefinitionTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec452CoverageTypeEligibilityTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec454ExchangeAuthorityProjectionTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec454ExchangeCaptureCohortTest.php',
'apps/platform/tests/Unit/Support/TenantConfiguration/Spec454ExchangeRuntimeConsumerContractTest.php',
'docs/ui-ux-enterprise-audit/design-coverage-matrix.md',
'docs/ui-ux-enterprise-audit/route-inventory.md',
'specs/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer/artifacts/screenshots/ui-102-exchange-default-hps.png',
'specs/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer/checklists/requirements.md',
'specs/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer/implementation-report.md',
'specs/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer/tasks.md',
];
}
/**
* @param list<string> $changedPaths
* @return array{forbidden_paths: list<string>, missing_contract_paths: list<string>}
*/
function spec454PathContractViolations(array $changedPaths, string $contract): array
{
$pathContract = str($contract)->between('## Path Contract', '### Path Mutation and Proof Matrix')->toString();
preg_match_all('/`([^`]+)`/', $pathContract, $matches);
$allowedPaths = collect($matches[1] ?? [])
->filter(static fn (string $path): bool => str_contains($path, '/'))
->push(
'specs/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer/tasks.md',
)
->unique()
->values();
$requiredPaths = [
'apps/platform/database/migrations/2026_07_26_000454_add_tenant_configuration_evidence_idempotency_unique_index.php',
'apps/platform/tests/Feature/Guards/Spec454ExchangeConsumerArchitectureGuardTest.php',
'apps/platform/tests/Browser/Spec454ExchangeCoverageCaptureBrowserTest.php',
'apps/platform/tests/Browser/Spec454ExchangeCoverageOutcomeBrowserTest.php',
'apps/platform/tests/Browser/Spec454ExchangeCoverageScopeBrowserTest.php',
'apps/platform/tests/Browser/Spec454ExchangeProviderSeparationBrowserTest.php',
'apps/platform/tests/Browser/Spec454ExchangeConsumerIsolationBrowserTest.php',
];
return [
'forbidden_paths' => collect($changedPaths)
->reject(static fn (string $path): bool => $allowedPaths->contains($path))
->sort()
->values()
->all(),
'missing_contract_paths' => collect($requiredPaths)
->reject(static fn (string $path): bool => $allowedPaths->contains($path))
->sort()
->values()
->all(),
];
}

View File

@ -55,11 +55,16 @@
$secondPayload = ['id' => 'resource-1', 'displayName' => 'Resource updated', '@odata.etag' => 'two'];
$sameResource = app(CoverageResourceUpserter::class)->upsert($tenant, $connection, $resourceType, $secondPayload, $decision->sourceMetadata);
$secondNormalized = $normalizer->normalize($secondPayload, ['@odata.etag']);
$secondRun = OperationRun::factory()->withUser($user)->forTenant($tenant)->create([
'type' => OperationRunType::TenantConfigurationCapture->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$secondEvidence = app(CoverageEvidenceWriter::class)->append(
resource: $sameResource,
resourceType: $resourceType,
providerConnection: $connection,
operationRun: $run,
operationRun: $secondRun,
decision: $decision,
rawPayload: $secondPayload,
normalizedPayload: $secondNormalized,
@ -70,7 +75,7 @@
expect($sameResource->getKey())->toBe($resource->getKey())
->and(TenantConfigurationResourceEvidence::query()->where('resource_id', $resource->getKey())->count())->toBe(2)
->and($sameResource->fresh()->latest_evidence_id)->toBe((int) $secondEvidence->getKey())
->and($secondEvidence->operation_run_id)->toBe((int) $run->getKey())
->and($secondEvidence->operation_run_id)->toBe((int) $secondRun->getKey())
->and($secondEvidence->source_metadata['source_contract_key'])->toBe('assignmentFilter')
->and($secondEvidence->normalized_payload)->not->toHaveKey('@odata.etag');
});

View File

@ -9,6 +9,7 @@
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\TenantConfiguration\CaptureTypeResultWriter;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\GenericContentEvidenceCaptureService;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
@ -33,8 +34,9 @@
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
});
it('Spec430 capture path rejects Future Candidate adapter types without provider calls or evidence rows', function (): void {
it('Spec430 generic capture path rejects command-backed Exchange types without provider calls or evidence rows', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
$canonicalTypes = collect(spec430IncludedTypes())->sort()->values()->all();
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->withCredential()->create([
@ -55,22 +57,33 @@
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
],
'resource_types' => spec430IncludedTypes(),
'resource_types' => $canonicalTypes,
'required_capability' => 'evidence.manage',
],
]);
$resourceTypes = TenantConfigurationResourceType::query()
->whereIn('canonical_type', $canonicalTypes)
->orderBy('canonical_type')
->get();
app(CaptureTypeResultWriter::class)->initializePlan(
$environment,
$connection,
$run,
$resourceTypes,
);
expect(fn () => app(GenericContentEvidenceCaptureService::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: $run,
canonicalTypes: spec430IncludedTypes(),
canonicalTypes: $canonicalTypes,
))->toThrow(
UnexpectedValueException::class,
'Coverage type transportRule is not eligible for Evidence capture.',
'Coverage type inboundConnector requires its dedicated command-backed Evidence capture consumer.',
);
expect($graph->calls)->toBe([])
->and($run->tenantConfigurationCaptureTypeResults()->whereNotNull('started_at')->count())->toBe(0)
->and(TenantConfigurationResource::query()->count())->toBe(0)
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
});

View File

@ -83,14 +83,7 @@
],
])
->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($run->summary_counts)->toBe([])
->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())
@ -491,14 +484,7 @@
'items' => 0,
],
])
->and($run->refresh()->summary_counts)->toBe([
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
'skipped' => 0,
'items' => 0,
])
->and($run->refresh()->summary_counts)->toBe([])
->and(TenantConfigurationResource::query()->count())->toBe(0)
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
});

View File

@ -268,11 +268,12 @@
$firstEvidence = TenantConfigurationResourceEvidence::query()->sole();
$firstHash = $firstEvidence->payload_hash;
$firstPayload = $firstEvidence->normalized_payload;
$secondRun = spec436CaptureRun($environment, $user, $connection);
$adapter->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: $run,
operationRun: $secondRun,
canonicalType: 'transportRule',
runnerResult: ExchangePowerShellInvocationResult::succeeded(
[spec436TransportRulePayload(['Enabled' => false, 'WhenChanged' => '2026-07-09T10:00:00Z'])],

View File

@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\CoverageEvidenceWriter;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\CoverageLevel;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
it('installs the exact run-resource unique index after a zero-duplicate precheck', function (): void {
$duplicates = DB::table('tenant_configuration_resource_evidence')
->select(['operation_run_id', 'resource_id'])
->selectRaw('COUNT(*) AS duplicate_count')
->groupBy(['operation_run_id', 'resource_id'])
->havingRaw('COUNT(*) > 1')
->get();
$columns = spec454EvidenceIndexColumns();
$migration = file_get_contents(database_path(
'migrations/2026_07_26_000454_add_tenant_configuration_evidence_idempotency_unique_index.php',
));
expect($duplicates)->toBeEmpty()
->and($columns)->toBe(['operation_run_id', 'resource_id'])
->and($migration)->toContain('public $withinTransaction = false;')
->toContain('CREATE UNIQUE INDEX CONCURRENTLY')
->toContain('DROP INDEX CONCURRENTLY IF EXISTS tenant_config_evidence_run_resource_unique')
->toContain('CREATE UNIQUE INDEX \'.self::INDEX')
->toContain('default => throw new \\RuntimeException');
});
it('rolls the bounded index back and reapplies it without changing the Evidence schema', function (): void {
$connection = DB::connection();
$columnsBefore = Schema::getColumnListing('tenant_configuration_resource_evidence');
if ($connection->getDriverName() === 'pgsql' && $connection->transactionLevel() > 0) {
$connection->commit();
}
$migration = require database_path(
'migrations/2026_07_26_000454_add_tenant_configuration_evidence_idempotency_unique_index.php',
);
$migration->down();
expect(spec454EvidenceIndexExists())->toBeFalse()
->and(Schema::getColumnListing('tenant_configuration_resource_evidence'))->toBe($columnsBefore);
$migration->up();
expect(spec454EvidenceIndexColumns())->toBe(['operation_run_id', 'resource_id'])
->and(Schema::getColumnListing('tenant_configuration_resource_evidence'))->toBe($columnsBefore);
if ($connection->getDriverName() === 'pgsql') {
$connection->beginTransaction();
}
});
it('atomically reuses one canonically exact Evidence row without moving timestamps or latest pointer', function (): void {
$fixture = spec454EvidenceFixture();
$writer = app(CoverageEvidenceWriter::class);
$first = spec454AppendEvidence($writer, $fixture);
$capturedAt = $first->captured_at?->toJSON();
$createdAt = $first->created_at?->toJSON();
$second = spec454AppendEvidence($writer, $fixture);
expect($second->is($first))->toBeTrue()
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(1)
->and($second->captured_at?->toJSON())->toBe($capturedAt)
->and($second->created_at?->toJSON())->toBe($createdAt)
->and($fixture['resource']->fresh()?->latest_evidence_id)->toBe((int) $first->getKey())
->and($second->source_endpoint)->toBe(
ExchangePowerShellCommandContracts::SOURCE_SURFACE.':Get-TransportRule',
)
->and(data_get($second->source_metadata, 'executable_descriptor_kind'))->toBe('command')
->and(data_get($second->source_metadata, 'command_contract_key'))->toBe('exchange_powershell.transportRule')
->and($second->coverage_level)->toBe(CoverageLevel::ContentBacked);
});
it('fails closed on every same-run immutable conflict even when the lossy hash matches', function (
string $field,
mixed $replacement,
): void {
$fixture = spec454EvidenceFixture();
$writer = app(CoverageEvidenceWriter::class);
spec454AppendEvidence($writer, $fixture);
$arguments = [];
if ($field === 'decision_source_version') {
$arguments['decision'] = new CoverageSourceContractDecision(
canonicalType: 'transportRule',
outcome: CaptureOutcome::Captured,
contractKey: $fixture['decision']->contractKey,
commandContractKey: $fixture['decision']->commandContractKey,
sourceVersion: (string) $replacement,
sourceSchemaHash: $fixture['decision']->sourceSchemaHash,
sourceContractState: $fixture['decision']->sourceContractState,
contract: $fixture['decision']->contract,
sourceMetadata: $fixture['decision']->sourceMetadata,
);
} else {
$arguments[$field] = $replacement;
}
expect(fn () => spec454AppendEvidence($writer, $fixture, $arguments))
->toThrow(LogicException::class, 'Conflicting Evidence already exists');
expect(TenantConfigurationResourceEvidence::query()->count())->toBe(1);
})->with([
'different hash' => ['payloadHash', str_repeat('a', 64)],
'same hash different raw payload' => ['rawPayload', ['Guid' => 'rule-454', 'Enabled' => false]],
'same hash different normalized payload' => ['normalizedPayload', ['canonical_type' => 'transportRule', 'enabled' => false]],
'different permission provenance' => ['permissionContext', ['permission_evidence_state' => 'stale']],
'different source version' => ['decision_source_version', 'exchange-powershell-command-contract-v2'],
]);
it('allows the same Resource in a different Capture Run and a different Resource in the same Run', function (): void {
$fixture = spec454EvidenceFixture();
$writer = app(CoverageEvidenceWriter::class);
$first = spec454AppendEvidence($writer, $fixture);
$otherRun = OperationRun::factory()->forTenant($fixture['environment'])->create([
'type' => OperationRunType::TenantConfigurationCapture->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'target_scope' => [
'provider_connection_id' => (int) $fixture['connection']->getKey(),
],
],
]);
$otherResource = TenantConfigurationResource::factory()->create([
'workspace_id' => (int) $fixture['environment']->workspace_id,
'managed_environment_id' => (int) $fixture['environment']->getKey(),
'provider_connection_id' => (int) $fixture['connection']->getKey(),
'resource_type_id' => (int) $fixture['resourceType']->getKey(),
'canonical_type' => 'transportRule',
'canonical_resource_key' => 'transportRule:provider_external_id:rule-455',
'source_resource_id' => 'rule-455',
]);
$secondRunEvidence = spec454AppendEvidence($writer, [
...$fixture,
'run' => $otherRun,
]);
$secondResourceEvidence = spec454AppendEvidence($writer, [
...$fixture,
'resource' => $otherResource,
'rawPayload' => ['Guid' => 'rule-455', 'Enabled' => true],
'normalizedPayload' => ['canonical_type' => 'transportRule', 'enabled' => true, 'id' => 'rule-455'],
'payloadHash' => hash('sha256', 'rule-455'),
]);
expect($first->isNot($secondRunEvidence))->toBeTrue()
->and($first->isNot($secondResourceEvidence))->toBeTrue()
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(3);
});
it('converges concurrent exact PostgreSQL writers on one Evidence row and rejects a conflicting retry', function (): void {
$fixture = spec454EvidenceFixture();
if (DB::getDriverName() !== 'pgsql') {
$writer = app(CoverageEvidenceWriter::class);
$first = spec454AppendEvidence($writer, $fixture);
$second = spec454AppendEvidence($writer, $fixture);
expect($second->is($first))->toBeTrue()
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(1);
return;
}
DB::connection()->commit();
DB::disconnect();
$raceDirectory = sys_get_temp_dir().'/tenantpilot-spec454-'.bin2hex(random_bytes(8));
if (! mkdir($raceDirectory, 0700)) {
throw new RuntimeException('Unable to create the Spec 454 race directory.');
}
$worker = static function (int $workerId) use ($fixture, $raceDirectory): never {
try {
DB::purge();
DB::reconnect();
file_put_contents($raceDirectory."/ready-{$workerId}", 'ready', LOCK_EX);
$deadline = microtime(true) + 10;
while (! is_file($raceDirectory.'/go')) {
if (microtime(true) >= $deadline) {
throw new RuntimeException('Spec 454 race barrier timed out.');
}
usleep(10_000);
}
$rehydrated = [
...$fixture,
'connection' => ProviderConnection::query()->findOrFail($fixture['connection']->getKey()),
'resourceType' => TenantConfigurationResourceType::query()->findOrFail($fixture['resourceType']->getKey()),
'resource' => TenantConfigurationResource::query()->findOrFail($fixture['resource']->getKey()),
'run' => OperationRun::query()->findOrFail($fixture['run']->getKey()),
];
$rehydrated['decision'] = app(CoverageSourceContractResolver::class)
->resolve($rehydrated['resourceType']);
$evidence = spec454AppendEvidence(
app(CoverageEvidenceWriter::class),
$rehydrated,
);
file_put_contents(
$raceDirectory."/result-{$workerId}.json",
json_encode(['id' => (int) $evidence->getKey()], JSON_THROW_ON_ERROR),
LOCK_EX,
);
exit(0);
} catch (Throwable $exception) {
file_put_contents(
$raceDirectory."/result-{$workerId}.json",
json_encode([
'error' => $exception::class,
'message' => $exception->getMessage(),
], JSON_THROW_ON_ERROR),
LOCK_EX,
);
exit(1);
}
};
$processIds = [];
try {
foreach ([1, 2] as $workerId) {
$processId = pcntl_fork();
if ($processId === -1) {
throw new RuntimeException('Unable to fork the Spec 454 race worker.');
}
if ($processId === 0) {
$worker($workerId);
}
$processIds[] = $processId;
}
$deadline = microtime(true) + 10;
while (! is_file($raceDirectory.'/ready-1') || ! is_file($raceDirectory.'/ready-2')) {
if (microtime(true) >= $deadline) {
throw new RuntimeException('Spec 454 race workers did not reach the barrier.');
}
usleep(10_000);
}
file_put_contents($raceDirectory.'/go', 'go', LOCK_EX);
foreach ($processIds as $processId) {
pcntl_waitpid($processId, $status);
expect(pcntl_wexitstatus($status))->toBe(0);
}
DB::purge();
DB::reconnect();
$results = collect([1, 2])->map(
static fn (int $workerId): array => json_decode(
(string) file_get_contents($raceDirectory."/result-{$workerId}.json"),
true,
flags: JSON_THROW_ON_ERROR,
),
);
expect($results->pluck('id')->unique()->values()->all())->toHaveCount(1)
->and(TenantConfigurationResourceEvidence::query()
->where('operation_run_id', (int) $fixture['run']->getKey())
->where('resource_id', (int) $fixture['resource']->getKey())
->count())->toBe(1)
->and(fn () => spec454AppendEvidence(
app(CoverageEvidenceWriter::class),
[
...$fixture,
'connection' => ProviderConnection::query()->findOrFail($fixture['connection']->getKey()),
'resourceType' => TenantConfigurationResourceType::query()->findOrFail($fixture['resourceType']->getKey()),
'resource' => TenantConfigurationResource::query()->findOrFail($fixture['resource']->getKey()),
'run' => OperationRun::query()->findOrFail($fixture['run']->getKey()),
],
['payloadHash' => str_repeat('f', 64)],
))->toThrow(LogicException::class, 'Conflicting Evidence already exists');
} finally {
foreach (glob($raceDirectory.'/*') ?: [] as $raceFile) {
unlink($raceFile);
}
rmdir($raceDirectory);
}
});
/**
* @return array<string, mixed>
*/
function spec454EvidenceFixture(): array
{
app(ResourceTypeRegistry::class)->syncDefaults();
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->withCredential()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider' => 'microsoft',
]);
$resourceType = TenantConfigurationResourceType::query()
->where('canonical_type', 'transportRule')
->firstOrFail();
$resource = TenantConfigurationResource::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'canonical_type' => 'transportRule',
'canonical_resource_key' => 'transportRule:provider_external_id:rule-454',
'source_resource_id' => 'rule-454',
]);
$run = OperationRun::factory()->withUser($user)->forTenant($environment)->create([
'type' => OperationRunType::TenantConfigurationCapture->value,
'status' => OperationRunStatus::Running->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'target_scope' => [
'provider_connection_id' => (int) $connection->getKey(),
],
],
]);
return [
'environment' => $environment,
'connection' => $connection,
'resourceType' => $resourceType,
'resource' => $resource,
'run' => $run,
'decision' => app(CoverageSourceContractResolver::class)->resolve($resourceType),
'rawPayload' => ['Guid' => 'rule-454', 'Enabled' => true],
'normalizedPayload' => ['canonical_type' => 'transportRule', 'enabled' => true],
'payloadHash' => hash('sha256', 'rule-454'),
'permissionContext' => ['permission_evidence_state' => 'verified'],
];
}
/**
* @param array<string, mixed> $fixture
* @param array<string, mixed> $overrides
*/
function spec454AppendEvidence(
CoverageEvidenceWriter $writer,
array $fixture,
array $overrides = [],
): TenantConfigurationResourceEvidence {
$arguments = [
'resource' => $fixture['resource'],
'resourceType' => $fixture['resourceType'],
'providerConnection' => $fixture['connection'],
'operationRun' => $fixture['run'],
'decision' => $fixture['decision'],
'rawPayload' => $fixture['rawPayload'],
'normalizedPayload' => $fixture['normalizedPayload'],
'payloadHash' => $fixture['payloadHash'],
'permissionContext' => $fixture['permissionContext'],
'maximumCoverageLevel' => CoverageLevel::ContentBacked,
...$overrides,
];
return $writer->append(...$arguments);
}
/**
* @return list<string>
*/
function spec454EvidenceIndexColumns(): array
{
if (DB::getDriverName() === 'sqlite') {
return collect(DB::select(
"PRAGMA index_info('tenant_config_evidence_run_resource_unique')",
))->pluck('name')->all();
}
if (DB::getDriverName() !== 'pgsql') {
throw new RuntimeException('Unsupported Spec 454 Evidence index inspection driver.');
}
return collect(DB::select(
<<<'SQL'
SELECT attribute.attname AS name
FROM pg_index AS index_metadata
JOIN pg_class AS index_class
ON index_class.oid = index_metadata.indexrelid
JOIN LATERAL unnest(index_metadata.indkey)
WITH ORDINALITY AS index_key(attnum, position)
ON TRUE
JOIN pg_attribute AS attribute
ON attribute.attrelid = index_metadata.indrelid
AND attribute.attnum = index_key.attnum
WHERE index_class.relname = ?
ORDER BY index_key.position
SQL,
['tenant_config_evidence_run_resource_unique'],
))->pluck('name')->all();
}
function spec454EvidenceIndexExists(): bool
{
return spec454EvidenceIndexColumns() !== [];
}

View File

@ -0,0 +1,451 @@
<?php
declare(strict_types=1);
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\TenantConfigurationCaptureTypeResult;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\CoverageResourceUpserter;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\CoverageV2ReadinessReadModel;
use App\Services\TenantConfiguration\ExchangeCoverageCaptureCohortPolicy;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderCredentialKind;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\CaptureTypeOutcome;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\EvidenceState;
use App\Support\TenantConfiguration\RestoreTier;
use App\Support\TenantConfiguration\Workload;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
it('uses an exact independent 24 hour Exchange currentness policy and fails closed for invalid configuration', function (): void {
Carbon::setTestNow('2026-07-26 12:00:00');
[, $environment, $provider] = createSpec440CoverageContext();
$type = spec454ExchangeTypes()['transportRule'];
$successAt = now()->subHours(24);
$run = spec454ExchangeReadinessRun($environment, $provider, $successAt);
spec454ExchangeReadinessResult(
$environment,
$provider,
$run,
$type,
CaptureTypeOutcome::SuccessEmpty,
$successAt,
);
config([
'tenantpilot.coverage_v2.intune_core_currentness_hours' => 1,
'tenantpilot.coverage_v2.exchange_powershell_currentness_hours' => 24,
]);
$row = spec454ExchangeReadinessRows($environment, $provider)['transportRule'];
expect(config('tenantpilot.coverage_v2.exchange_powershell_currentness_hours'))->toBe(24)
->and($row['currentness'])->toBe('current');
Carbon::setTestNow(now()->addSecond());
$row = spec454ExchangeReadinessRows($environment, $provider)['transportRule'];
expect($row['currentness'])->toBe('stale');
config(['tenantpilot.coverage_v2.exchange_powershell_currentness_hours' => 'invalid']);
expect(fn () => spec454ExchangeReadinessRows($environment, $provider))
->toThrow(\UnexpectedValueException::class, 'Exchange PowerShell currentness configuration is invalid');
});
it('uses safe product language for blocked Exchange capture prerequisites', function (): void {
[, $environment, $provider] = createSpec440CoverageContext();
ProviderCredential::query()
->where('provider_connection_id', (int) $provider->getKey())
->firstOrFail()
->forceFill([
'type' => ProviderCredentialKind::ClientSecret->value,
'credential_kind' => ProviderCredentialKind::ClientSecret->value,
])
->save();
$eligibility = app(CoverageV2ReadinessReadModel::class)->captureEligibility(
$environment,
$provider,
Workload::Exchange,
);
$reason = strtolower((string) $eligibility['reason']);
expect($eligibility['eligible'])->toBeFalse()
->and($eligibility['reason'])->toBe(__('localization.coverage_v2.exchange.prerequisites_blocked'))
->and($reason)->not->toContain('powershell')
->and($reason)->not->toContain('client secret')
->and($reason)->not->toContain('private key')
->and($reason)->not->toContain('/tmp/');
});
it('keeps Exchange last attempt separate from the prior successful result', function (CaptureTypeOutcome $laterOutcome): void {
Carbon::setTestNow('2026-07-26 12:00:00');
[, $environment, $provider] = createSpec440CoverageContext();
$type = spec454ExchangeTypes()['remoteDomain'];
$successAt = now()->subHour();
$successRun = spec454ExchangeReadinessRun($environment, $provider, $successAt);
spec454ExchangeReadinessResult(
$environment,
$provider,
$successRun,
$type,
CaptureTypeOutcome::SuccessEmpty,
$successAt,
);
$laterAt = now();
$laterRun = spec454ExchangeReadinessRun(
$environment,
$provider,
$laterAt,
$laterOutcome === CaptureTypeOutcome::Partial
? OperationRunOutcome::PartiallySucceeded
: OperationRunOutcome::Failed,
);
spec454ExchangeReadinessResult(
$environment,
$provider,
$laterRun,
$type,
$laterOutcome,
$laterAt,
);
$row = spec454ExchangeReadinessRows($environment, $provider)['remoteDomain'];
expect($row['attempt_state'])->toBe(match ($laterOutcome) {
CaptureTypeOutcome::Blocked => 'blocked',
CaptureTypeOutcome::Failed => 'failed',
CaptureTypeOutcome::Partial => 'partial',
CaptureTypeOutcome::NotAttempted => 'never_attempted',
default => throw new LogicException('Unexpected test outcome.'),
})
->and($row['attempt_at']?->getTimestamp())->toBe($laterAt->getTimestamp())
->and($row['last_success_at']?->getTimestamp())->toBe($successAt->getTimestamp())
->and($row['result_state'])->toBe('empty')
->and($row['currentness'])->toBe('current');
})->with([
CaptureTypeOutcome::Blocked,
CaptureTypeOutcome::Failed,
CaptureTypeOutcome::Partial,
CaptureTypeOutcome::NotAttempted,
]);
it('renews Exchange currentness for item-backed and empty successes with batched counts', function (): void {
Carbon::setTestNow('2026-07-26 12:00:00');
[, $environment, $provider] = createSpec440CoverageContext();
$types = spec454ExchangeTypes();
$withItemsRun = spec454ExchangeReadinessRun($environment, $provider, now()->subMinutes(10));
$resource = TenantConfigurationResource::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $provider->getKey(),
'resource_type_id' => (int) $types['transportRule']->getKey(),
'canonical_type' => 'transportRule',
]);
TenantConfigurationResourceEvidence::factory()->create([
'resource_id' => (int) $resource->getKey(),
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $provider->getKey(),
'resource_type_id' => (int) $types['transportRule']->getKey(),
'operation_run_id' => (int) $withItemsRun->getKey(),
'capture_outcome' => CaptureOutcome::Captured,
'captured_at' => now()->subMinutes(10),
]);
spec454ExchangeReadinessResult(
$environment,
$provider,
$withItemsRun,
$types['transportRule'],
CaptureTypeOutcome::SuccessWithItems,
now()->subMinutes(10),
itemCount: 1,
evidenceCount: 1,
);
$emptyRun = spec454ExchangeReadinessRun($environment, $provider, now()->subMinutes(5));
spec454ExchangeReadinessResult(
$environment,
$provider,
$emptyRun,
$types['inboundConnector'],
CaptureTypeOutcome::SuccessEmpty,
now()->subMinutes(5),
);
$summary = app(CoverageV2ReadinessReadModel::class)->summary(
$environment,
$provider,
Workload::Exchange,
);
$rows = collect($summary['type_outcomes'])->keyBy('canonical_type');
expect($summary['resource_types_total'])->toBe(3)
->and($summary['resources_total'])->toBe(1)
->and($summary['evidence_total'])->toBe(1)
->and($rows['transportRule'])->toMatchArray([
'result_state' => 'resources',
'currentness' => 'current',
'resource_count' => 1,
'evidence_count' => 1,
])
->and($rows['inboundConnector'])->toMatchArray([
'result_state' => 'empty',
'currentness' => 'current',
'resource_count' => 0,
'evidence_count' => 0,
]);
});
it('isolates Exchange readiness by provider and ignores legacy or context-only outcomes', function (): void {
[, $environment, $provider] = createSpec440CoverageContext();
$otherProvider = ProviderConnection::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider' => 'microsoft',
]);
$type = spec454ExchangeTypes()['transportRule'];
$otherRun = spec454ExchangeReadinessRun($environment, $otherProvider, now(), captureContext: [
'resource_type_outcomes' => [[
'canonical_type' => 'transportRule',
'outcome' => 'captured',
]],
]);
spec454ExchangeReadinessResult(
$environment,
$otherProvider,
$otherRun,
$type,
CaptureTypeOutcome::SuccessEmpty,
now(),
);
spec454ExchangeReadinessRun($environment, $provider, now(), captureContext: [
'resource_type_outcomes' => [[
'canonical_type' => 'transportRule',
'outcome' => 'captured',
]],
]);
$row = spec454ExchangeReadinessRows($environment, $provider)['transportRule'];
expect($row)->toMatchArray([
'attempt_state' => 'never_attempted',
'result_state' => 'none',
'currentness' => 'absent',
])->and($row['attempt_at'])->toBeNull()
->and($row['last_success_at'])->toBeNull();
});
it('keeps Exchange claim, display label, coverage and restore posture internal while preserving Intune labels', function (): void {
[, $environment, $provider] = createSpec440CoverageContext();
$types = spec454ExchangeTypes();
$payloads = [
'transportRule' => ['RuleId' => 'rule-454', 'Name' => 'Secret mail-flow rule'],
'remoteDomain' => ['Identity' => 'remote-454', 'DomainName' => 'secret.example'],
'inboundConnector' => ['Identity' => 'connector-454', 'Name' => 'Secret connector', 'SenderIPAddresses' => ['192.0.2.1']],
];
$expectedLabels = [
'transportRule' => 'Protected transport rule',
'remoteDomain' => 'Protected remote domain',
'inboundConnector' => 'Protected inbound connector',
];
foreach ($types as $canonicalType => $resourceType) {
$decision = app(CoverageSourceContractResolver::class)->resolve($resourceType);
$typeDetails = app(CoverageV2ReadinessReadModel::class)
->resourceTypeInspectDetails($resourceType);
$resource = app(CoverageResourceUpserter::class)->upsert(
$environment,
$provider,
$resourceType,
$payloads[$canonicalType],
$decision->sourceMetadata,
);
expect($resource->latest_claim_state)->toBe(ClaimState::InternalOnly)
->and($resource->source_display_name)->toBe($expectedLabels[$canonicalType])
->and($resourceType->restore_tier)->toBe(RestoreTier::NotRestorable)
->and($resourceType->allows_certified_claims)->toBeFalse()
->and($typeDetails)->toMatchArray([
'scope' => __('localization.coverage_v2.exchange.type_scope'),
'supported_scope' => __('localization.coverage_v2.exchange.type_scope_included'),
'scope_key' => ExchangeCoverageCaptureCohortPolicy::IDENTIFIER,
])
->and(implode(' ', $typeDetails))->not->toContain('Intune TCM core');
}
$futureExchangeType = TenantConfigurationResourceType::query()
->where('canonical_type', 'acceptedDomain')
->firstOrFail();
$futureExchangeDecision = app(CoverageSourceContractResolver::class)->resolve($futureExchangeType);
$futureExchangeResource = app(CoverageResourceUpserter::class)->upsert(
$environment,
$provider,
$futureExchangeType,
['id' => 'future-domain-454', 'displayName' => 'Must not become a claim'],
$futureExchangeDecision->sourceMetadata,
);
expect($futureExchangeResource->latest_claim_state)->toBe(ClaimState::ClaimBlocked)
->and($futureExchangeResource->source_display_name)->toBe('Protected Exchange configuration');
$intuneType = TenantConfigurationResourceType::query()
->where('canonical_type', 'deviceAndAppManagementAssignmentFilter')
->firstOrFail();
$intuneDecision = app(CoverageSourceContractResolver::class)->resolve($intuneType);
$intuneResource = app(CoverageResourceUpserter::class)->upsert(
$environment,
$provider,
$intuneType,
[
'id' => 'filter-454',
'displayName' => 'Visible Intune label',
'platform' => 'windows10AndLater',
'assignmentFilterManagementType' => 'devices',
'rule' => '(device.deviceId -ne null)',
],
$intuneDecision->sourceMetadata,
);
expect($intuneResource->source_display_name)->toBe('Visible Intune label');
});
it('keeps Exchange readiness query count fixed and never selects raw provider payloads', function (): void {
[, $environment, $provider] = createSpec440CoverageContext();
$types = spec454ExchangeTypes();
$queries = [];
app(CoverageV2ReadinessReadModel::class)->summary($environment, $provider, Workload::Exchange);
DB::listen(function (QueryExecuted $query) use (&$queries): void {
$queries[] = $query->sql;
});
app(CoverageV2ReadinessReadModel::class)->summary($environment, $provider, Workload::Exchange);
$emptyQueryCount = count($queries);
$queries = [];
foreach ($types as $canonicalType => $resourceType) {
foreach (range(1, 25) as $sequence) {
TenantConfigurationResource::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $provider->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'canonical_type' => $canonicalType,
'canonical_resource_key' => "{$canonicalType}:{$sequence}",
]);
}
}
$queries = [];
app(CoverageV2ReadinessReadModel::class)->summary($environment, $provider, Workload::Exchange);
$populatedQueryCount = count($queries);
$readinessSql = strtolower(implode("\n", $queries));
expect($populatedQueryCount)->toBe($emptyQueryCount)
->and($populatedQueryCount)->toBeLessThanOrEqual(20)
->and($readinessSql)->not->toContain('raw_payload')
->and($readinessSql)->not->toContain('normalized_payload')
->and($readinessSql)->not->toContain('operation_runs.context')
->and($readinessSql)->not->toContain('legacy');
});
/**
* @return array<string, TenantConfigurationResourceType>
*/
function spec454ExchangeTypes(): array
{
app(ResourceTypeRegistry::class)->syncDefaults();
return app(ExchangeCoverageCaptureCohortPolicy::class)
->resolvePersisted()
->keyBy('canonical_type')
->all();
}
function spec454ExchangeReadinessRun(
ManagedEnvironment $environment,
ProviderConnection $provider,
Carbon $completedAt,
OperationRunOutcome $outcome = OperationRunOutcome::Succeeded,
array $captureContext = [],
): OperationRun {
return OperationRun::factory()->forTenant($environment)->create([
'type' => OperationRunType::TenantConfigurationCapture,
'status' => OperationRunStatus::Completed,
'outcome' => $outcome,
'context' => [
'target_scope' => [
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $provider->getKey(),
],
'workload' => Workload::Exchange->value,
'capture_cohort' => ExchangeCoverageCaptureCohortPolicy::IDENTIFIER,
...($captureContext === [] ? [] : ['capture' => $captureContext]),
],
'started_at' => $completedAt->copy()->subMinute(),
'completed_at' => $completedAt,
'created_at' => $completedAt,
'updated_at' => $completedAt,
]);
}
function spec454ExchangeReadinessResult(
ManagedEnvironment $environment,
ProviderConnection $provider,
OperationRun $run,
TenantConfigurationResourceType $resourceType,
CaptureTypeOutcome $outcome,
Carbon $completedAt,
int $itemCount = 0,
int $evidenceCount = 0,
): TenantConfigurationCaptureTypeResult {
return TenantConfigurationCaptureTypeResult::factory()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $provider->getKey(),
'operation_run_id' => (int) $run->getKey(),
'resource_type_id' => (int) $resourceType->getKey(),
'source_contract_key' => $outcome->isSuccessfulComplete() ? 'exchange_powershell.'.$resourceType->canonical_type : null,
'source_version' => $outcome->isSuccessfulComplete() ? 'v1' : null,
'source_schema_hash' => $outcome->isSuccessfulComplete() ? hash('sha256', (string) $resourceType->canonical_type) : null,
'outcome' => $outcome,
'item_count' => $outcome === CaptureTypeOutcome::Partial ? max(1, $itemCount) : $itemCount,
'evidence_count' => $outcome === CaptureTypeOutcome::Partial ? max(1, $evidenceCount) : $evidenceCount,
'source_page_count' => $outcome->isSuccessfulComplete() ? 1 : null,
'reason_code' => $outcome->isSuccessfulComplete() ? null : 'spec454_test_outcome',
'started_at' => $outcome === CaptureTypeOutcome::NotAttempted ? null : $completedAt->copy()->subMinute(),
'completed_at' => $completedAt,
'created_at' => $completedAt,
'updated_at' => $completedAt,
]);
}
/**
* @return array<string, array<string, mixed>>
*/
function spec454ExchangeReadinessRows(ManagedEnvironment $environment, ProviderConnection $provider): array
{
return collect(app(CoverageV2ReadinessReadModel::class)->summary(
$environment,
$provider,
Workload::Exchange,
)['type_outcomes'])
->keyBy('canonical_type')
->all();
}

View File

@ -18,7 +18,7 @@
use App\Support\TenantConfiguration\SupportState;
use App\Support\TenantConfiguration\Workload;
it('Spec420 never derives runtime endpoints from remaining M365 source aliases without explicit contracts', function (string $canonicalType, string $reasonCode, CaptureOutcome $outcome): void {
it('Spec420 never derives runtime endpoints from remaining M365 source aliases without explicit contracts', function (string $canonicalType, ?string $reasonCode, CaptureOutcome $outcome): void {
$resourceType = spec420EligibilityResourceType($canonicalType);
$aliases = $resourceType->metadata['source_aliases'] ?? [];
@ -29,7 +29,7 @@
->and($decision->sourceEndpoint)->toBeNull()
->and($decision->reasonCode)->toBe($reasonCode);
})->with([
'transportRule' => ['transportRule', CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE, CaptureOutcome::BlockedMissingContract],
'transportRule' => ['transportRule', null, CaptureOutcome::Captured],
'acceptedDomain' => ['acceptedDomain', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING, CaptureOutcome::BlockedMissingContract],
'appPermissionPolicy' => ['appPermissionPolicy', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING, CaptureOutcome::BlockedMissingContract],
'meetingPolicy' => ['meetingPolicy', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING, CaptureOutcome::BlockedMissingContract],

View File

@ -12,6 +12,7 @@
it('Spec427 maps the bounded source-contract state vocabulary without adding parallel truth', function (): void {
expect(CoverageSourceContractDecision::sourceContractStates())->toBe([
'contract_verified_pending_capture',
'contract_verified_capture_enabled',
'contract_blocked_missing_source',
'contract_blocked_permission_unclear',
'contract_blocked_beta_only',

View File

@ -9,12 +9,15 @@
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\CaptureOutcome;
it('Spec430 supersedes the Spec427 transportRule adapter blocker with bounded pending-capture metadata', function (): void {
it('Spec454 supersedes the Spec427 transportRule adapter blocker with bounded capture-enabled metadata', function (): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427TransportRuleResourceType());
expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract)
->and($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE)
expect($decision->outcome)->toBe(CaptureOutcome::Captured)
->and($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED)
->and($decision->commandContractKey)->toBe('exchange_powershell.transportRule')
->and($decision->sourceEndpoint)->toBeNull()
->and($decision->capturable())->toBeTrue()
->and($decision->sourceMetadata['workload'])->toBe('exchange')
->and($decision->sourceMetadata['source_class'])->toBe('tcm')
->and($decision->sourceMetadata['source_contract_name'])->toBe('exchange_powershell.transportRule')

View File

@ -18,19 +18,20 @@
use App\Support\TenantConfiguration\SupportState;
use App\Support\TenantConfiguration\Workload;
it('Spec430 resolves included Exchange PowerShell types as verified pending-capture adapter contracts', function (string $canonicalType, string $commandName): void {
it('Spec454 resolves included Exchange PowerShell types as verified capture-enabled adapter contracts', function (string $canonicalType, string $commandName): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec430ResolverResourceType($canonicalType));
expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract)
->and($decision->reasonCode)->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE)
->and($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE)
expect($decision->outcome)->toBe(CaptureOutcome::Captured)
->and($decision->reasonCode)->toBeNull()
->and($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED)
->and($decision->contractKey)->toBe('exchange_powershell.'.$canonicalType)
->and($decision->sourceEndpoint)->toBeNull()
->and($decision->capturable())->toBeFalse()
->and($decision->sourceMetadata['source_contract_state'])->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE)
->and($decision->commandContractKey)->toBe('exchange_powershell.'.$canonicalType)
->and($decision->capturable())->toBeTrue()
->and($decision->sourceMetadata['source_contract_state'])->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED)
->and($decision->sourceMetadata['provider_adapter_state'])->toBe('adapter_contract_available')
->and($decision->sourceMetadata['capture_eligibility_state'])->toBe('pending_capture')
->and($decision->sourceMetadata['capture_eligibility_state'])->toBe('capture_enabled')
->and($decision->sourceMetadata['source_surface'])->toBe('exchange_online_powershell_rest')
->and($decision->sourceMetadata['adapter_pattern'])->toBe('new_exchange_powershell_adapter')
->and($decision->sourceMetadata['provider_calls_allowed'])->toBeFalse()
@ -48,7 +49,7 @@
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec430AdHocResolverResourceType($canonicalType, $workload));
expect($decision->sourceContractState)->not->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_PENDING_CAPTURE)
expect($decision->sourceContractState)->not->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED)
->and($decision->sourceMetadata['provider_adapter_state'] ?? null)->not->toBe('adapter_contract_available')
->and($decision->capturable())->toBeFalse()
->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]);

View File

@ -3,8 +3,9 @@
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
it('Spec430 inboundConnector contract is read-only pending capture and provider-call inert', function (): void {
it('Spec454 inboundConnector contract is read-only capture enabled and provider-call inert', function (): void {
$contract = (new ExchangePowerShellCommandContracts)->contractForCanonicalType('inboundConnector');
expect($contract)->not->toBeNull()
@ -17,7 +18,8 @@
->and($contract['fake_runner_testable'])->toBeTrue()
->and($contract['provider_calls_allowed'])->toBeFalse()
->and($contract['execution_enabled'])->toBeFalse()
->and($contract['capture_eligibility_state'])->toBe('pending_capture')
->and($contract['capture_eligibility_state'])->toBe('capture_enabled')
->and($contract['source_contract_state'])->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED)
->and($contract['restore_tier'])->toBe('not_restorable');
});

View File

@ -3,8 +3,9 @@
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
it('Spec430 remoteDomain contract is read-only pending capture and provider-call inert', function (): void {
it('Spec454 remoteDomain contract is read-only capture enabled and provider-call inert', function (): void {
$contract = (new ExchangePowerShellCommandContracts)->contractForCanonicalType('remoteDomain');
expect($contract)->not->toBeNull()
@ -17,7 +18,8 @@
->and($contract['fake_runner_testable'])->toBeTrue()
->and($contract['provider_calls_allowed'])->toBeFalse()
->and($contract['execution_enabled'])->toBeFalse()
->and($contract['capture_eligibility_state'])->toBe('pending_capture')
->and($contract['capture_eligibility_state'])->toBe('capture_enabled')
->and($contract['source_contract_state'])->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED)
->and($contract['restore_tier'])->toBe('not_restorable');
});

View File

@ -3,8 +3,9 @@
declare(strict_types=1);
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
it('Spec430 transportRule contract is read-only pending capture and provider-call inert', function (): void {
it('Spec454 transportRule contract is read-only capture enabled and provider-call inert', function (): void {
$contract = (new ExchangePowerShellCommandContracts)->contractForCanonicalType('transportRule');
expect($contract)->not->toBeNull()
@ -17,7 +18,8 @@
->and($contract['fake_runner_testable'])->toBeTrue()
->and($contract['provider_calls_allowed'])->toBeFalse()
->and($contract['execution_enabled'])->toBeFalse()
->and($contract['capture_eligibility_state'])->toBe('pending_capture')
->and($contract['capture_eligibility_state'])->toBe('capture_enabled')
->and($contract['source_contract_state'])->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED)
->and($contract['restore_tier'])->toBe('not_restorable');
});

View File

@ -95,8 +95,8 @@
))->toBe($expected)
->and($authority->all())->toHaveCount(75)
->and($authority->definitionsByProductClassification(CoverageProductClassification::ProductCommitted))->toHaveCount(6)
->and($authority->definitionsByProductClassification(CoverageProductClassification::InternalOnly))->toHaveCount(2)
->and($authority->definitionsByProductClassification(CoverageProductClassification::FutureProductCandidate))->toHaveCount(29)
->and($authority->definitionsByProductClassification(CoverageProductClassification::InternalOnly))->toHaveCount(5)
->and($authority->definitionsByProductClassification(CoverageProductClassification::FutureProductCandidate))->toHaveCount(26)
->and($authority->definitionsByProductClassification(CoverageProductClassification::IntentionallyOutOfScope))->toBe([])
->and($authority->definitionsByProductClassification(CoverageProductClassification::NonCoverageInventory))->toHaveCount(38)
->and($authority->definitionsByProductClassification(CoverageProductClassification::Retired))->toBe([]);

View File

@ -26,7 +26,10 @@
->and($keys($authority->findingBaseEligibleDefinitions()))->toBe($committed)
->and($keys($authority->internalCaptureEligibleDefinitions()))->toBe([
'conditionalAccessPolicy',
'inboundConnector',
'remoteDomain',
'securityDefaults',
'transportRule',
])
->and($keys($authority->internalCompareEligibleDefinitions()))->toBe([
'conditionalAccessPolicy',

View File

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\CoverageTypeAuthority;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\CoverageOperatorVisibility;
use App\Support\TenantConfiguration\CoverageProductClassification;
use App\Support\TenantConfiguration\CoveragePublicationClassification;
use App\Support\TenantConfiguration\CoverageRuntimeState;
use App\Support\TenantConfiguration\Workload;
dataset('spec454 exchange capture types', [
'transport rule' => ['transportRule', 'Get-TransportRule'],
'remote domain' => ['remoteDomain', 'Get-RemoteDomain'],
'inbound connector' => ['inboundConnector', 'Get-InboundConnector'],
]);
it('projects only the exact Exchange capture cohort as internal operator-visible authority', function (string $canonicalType): void {
$definition = (new CoverageTypeAuthority)->require($canonicalType);
expect($definition->workload)->toBe(Workload::Exchange)
->and($definition->productClassification)->toBe(CoverageProductClassification::InternalOnly)
->and($definition->runtimeState)->toBe(CoverageRuntimeState::OperatorProductized)
->and($definition->operatorVisibility())->toBe(CoverageOperatorVisibility::InternalOperatorVisible)
->and($definition->publicationClassification)->toBe(CoveragePublicationClassification::InternalOnly)
->and($definition->roadmapOwner)->toBe('Exchange family')
->and($definition->internalCompareEligible)->toBeFalse();
})->with('spec454 exchange capture types');
it('keeps every downstream consumer blocked while capture planning is eligible', function (string $canonicalType): void {
$definition = (new CoverageTypeAuthority)->require($canonicalType);
expect($definition->isCaptureEligible())->toBeTrue()
->and($definition->isBaselineEligible())->toBeFalse()
->and($definition->isCompareEligible())->toBeFalse()
->and($definition->isFindingBaseEligible())->toBeFalse()
->and($definition->isCustomerEligible())->toBeFalse();
})->with('spec454 exchange capture types');
it('preserves the two existing Entra internal compare definitions', function (): void {
$authority = new CoverageTypeAuthority;
expect(collect($authority->internalCompareEligibleDefinitions())
->pluck('canonicalKey')
->sort()
->values()
->all())->toBe([
'conditionalAccessPolicy',
'securityDefaults',
])
->and($authority->require('conditionalAccessPolicy')->internalCompareEligible)->toBeTrue()
->and($authority->require('securityDefaults')->internalCompareEligible)->toBeTrue();
});
it('freezes the exact three-type Exchange cohort and unchanged six-type Intune cohort', function (): void {
$authority = new CoverageTypeAuthority;
$exchangeTypes = collect($authority->internalCaptureEligibleDefinitions())
->where('workload', Workload::Exchange)
->pluck('canonicalKey')
->sort()
->values()
->all();
expect($exchangeTypes)->toBe([
'inboundConnector',
'remoteDomain',
'transportRule',
])
->and($exchangeTypes)->not->toContain('acceptedDomain', 'outboundConnector')
->and(collect($authority->productCaptureEligibleDefinitions())
->where('workload', Workload::Intune)
->pluck('canonicalKey')
->sort()
->values()
->all())->toBe([
'appProtectionPolicyAndroid',
'appProtectionPolicyiOS',
'deviceAndAppManagementAssignmentFilter',
'deviceEnrollmentLimitRestriction',
'deviceEnrollmentPlatformRestriction',
'deviceEnrollmentStatusPageWindows10',
]);
});
it('activates exactly one command descriptor for each Exchange source contract', function (string $canonicalType, string $commandName): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec454AuthorityResourceType($canonicalType));
expect($decision->outcome)->toBe(CaptureOutcome::Captured)
->and($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED)
->and($decision->reasonCode)->toBeNull()
->and($decision->contractKey)->toBe('exchange_powershell.'.$canonicalType)
->and($decision->sourceEndpoint)->toBeNull()
->and($decision->commandContractKey)->toBe('exchange_powershell.'.$canonicalType)
->and($decision->sourceMetadata['command_contract']['command_name'])->toBe($commandName)
->and($decision->sourceMetadata['source_contract_state'])->toBe(CoverageSourceContractDecision::CONTRACT_VERIFIED_CAPTURE_ENABLED)
->and($decision->capturable())->toBeTrue();
})->with('spec454 exchange capture types');
it('keeps HTTP decisions endpoint-backed and command-key free', function (): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec454AuthorityResourceType('conditionalAccessPolicy'));
expect($decision->outcome)->toBe(CaptureOutcome::Captured)
->and($decision->sourceEndpoint)->not->toBeNull()
->and($decision->commandContractKey)->toBeNull()
->and($decision->capturable())->toBeTrue();
});
it('fails captured source decisions closed when both or neither executable descriptor is present', function (?string $endpoint, ?string $commandKey): void {
$decision = new CoverageSourceContractDecision(
canonicalType: 'transportRule',
outcome: CaptureOutcome::Captured,
contractKey: 'exchange_powershell.transportRule',
sourceEndpoint: $endpoint,
commandContractKey: $commandKey,
);
expect($decision->capturable())->toBeFalse();
})->with([
'both descriptors' => ['/graph/resource', 'exchange_powershell.transportRule'],
'neither descriptor' => [null, null],
]);
function spec454AuthorityResourceType(string $canonicalType): TenantConfigurationResourceType
{
$definition = collect(ResourceTypeRegistry::defaultDefinitions())
->firstWhere('canonical_type', $canonicalType);
expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}.");
return new TenantConfigurationResourceType($definition);
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Services\TenantConfiguration\CoverageTypeAuthority;
use App\Services\TenantConfiguration\ExchangeCoverageCaptureCohortPolicy;
use App\Support\TenantConfiguration\CoverageProductClassification;
use App\Support\TenantConfiguration\Workload;
it('resolves one immutable ordered Exchange Capture cohort from authority and active Source contracts', function (): void {
$policy = app(ExchangeCoverageCaptureCohortPolicy::class);
$cohort = $policy->resolve();
expect($policy->identifier())->toBe('exchange_powershell_capture.v1')
->and($cohort->pluck('canonical_type')->all())->toBe([
'transportRule',
'remoteDomain',
'inboundConnector',
])
->and($cohort->every(fn ($type): bool => $type->workload === Workload::Exchange))->toBeTrue()
->and($cohort->pluck('canonical_type'))->not->toContain(
'deviceAndAppManagementAssignmentFilter',
'acceptedDomain',
'outboundConnector',
);
});
it('uses internal operator authority as the Product-Scope source without a local fourth type', function (): void {
$authority = app(CoverageTypeAuthority::class);
$cohort = app(ExchangeCoverageCaptureCohortPolicy::class)->resolve();
expect($cohort->every(function ($type) use ($authority): bool {
$definition = $authority->require((string) $type->canonical_type);
return $definition->productClassification === CoverageProductClassification::InternalOnly
&& $definition->workload === Workload::Exchange
&& $definition->isCaptureEligible();
}))->toBeTrue()
->and($cohort)->toHaveCount(3);
});

View File

@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
use App\Jobs\TenantConfiguration\CaptureTenantConfigurationEvidenceJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\User;
use App\Services\TenantConfiguration\ExchangeCoverageCaptureConsumer;
use App\Services\TenantConfiguration\ExchangePowerShellCommandContract;
use App\Services\TenantConfiguration\ExchangePowerShellCommandContracts;
use App\Services\TenantConfiguration\ExchangePowerShellEvidenceNormalizer;
use App\Services\TenantConfiguration\ExchangePowerShellHashInputBuilder;
use App\Services\TenantConfiguration\ExchangePowerShellInvocationGate;
use App\Services\TenantConfiguration\ExchangePowerShellInvocationReceipt;
use App\Services\TenantConfiguration\ExchangePowerShellInvocationResult;
use App\Services\TenantConfiguration\ExchangePowerShellStructuredOutputEnvelope;
use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\TenantConfiguration\ExchangePowerShellBatchContinuation;
use Illuminate\Queue\Middleware\WithoutOverlapping;
it('defines only the typed Continue and Stop batch signals', function (): void {
expect(ExchangePowerShellBatchContinuation::cases())->toBe([
ExchangePowerShellBatchContinuation::Continue,
ExchangePowerShellBatchContinuation::Stop,
]);
});
it('exposes one void gate-owned batch boundary with concrete inputs only', function (): void {
$method = new ReflectionMethod(ExchangePowerShellInvocationGate::class, 'consumeCaptureBatch');
$parameters = collect($method->getParameters())
->mapWithKeys(static fn (ReflectionParameter $parameter): array => [
$parameter->getName() => (string) $parameter->getType(),
])
->all();
expect($method->isPublic())->toBeTrue()
->and((string) $method->getReturnType())->toBe('void')
->and($parameters)->toBe([
'parentCaptureRun' => OperationRun::class,
'actor' => User::class,
'connection' => ProviderConnection::class,
'consumer' => ExchangeCoverageCaptureConsumer::class,
])
->and($parameters)->not->toHaveKeys([
'credentialReferenceId',
'technicalRunId',
'commandKeys',
'bypassMarker',
'callback',
]);
});
it('keeps the receipt same-process and has no public batch request result or prepared execution value', function (): void {
expect(method_exists(ExchangePowerShellInvocationReceipt::class, '__serialize'))->toBeTrue()
->and(class_exists('App\Services\TenantConfiguration\ExchangePowerShellBatchRequest'))->toBeFalse()
->and(class_exists('App\Services\TenantConfiguration\ExchangePowerShellBatchResult'))->toBeFalse()
->and(class_exists('App\Services\TenantConfiguration\PreparedExchangePowerShellExecution'))->toBeFalse();
});
it('uses one parent-run overlap lock with no release and the configured lifecycle margin', function (): void {
$run = new OperationRun;
$run->setAttribute('id', 454);
$job = new CaptureTenantConfigurationEvidenceJob($run);
$middleware = collect($job->middleware());
$overlap = $middleware->first(
static fn (mixed $item): bool => $item instanceof WithoutOverlapping,
);
expect($middleware->first())->toBe($overlap)
->and($overlap)->toBeInstanceOf(WithoutOverlapping::class)
->and($overlap?->key)->toBe('tenant-configuration-capture:454')
->and($overlap?->releaseAfter)->toBeNull()
->and($overlap?->expiresAfter)->toBe(
$job->timeout + app(OperationLifecyclePolicy::class)->retryAfterSafetyMarginSeconds(),
);
});
it('does not retain receipts or provider collections on the concrete consumer', function (): void {
$properties = collect((new ReflectionClass(ExchangeCoverageCaptureConsumer::class))->getProperties())
->map(static fn (ReflectionProperty $property): string => (string) $property->getType())
->all();
expect($properties)
->not->toContain(ExchangePowerShellInvocationReceipt::class)
->not->toContain('array')
->not->toContain('mixed');
});
it('accepts only the frozen stable identity fields for each Exchange type', function (
string $canonicalType,
string $identityField,
): void {
$readiness = spec454NormalizerReadiness($canonicalType, [[
$identityField => 'stable-454',
]]);
expect($readiness->ready)->toBeTrue()
->and(data_get($readiness->normalizedPreviews[0], 'source_identity'))->toBe([
'field' => $identityField,
'value' => 'stable-454',
]);
})->with([
'transportRule id' => ['transportRule', 'id'],
'transportRule sourceId' => ['transportRule', 'sourceId'],
'transportRule Guid' => ['transportRule', 'Guid'],
'transportRule RuleId' => ['transportRule', 'RuleId'],
'remoteDomain id' => ['remoteDomain', 'id'],
'remoteDomain sourceId' => ['remoteDomain', 'sourceId'],
'remoteDomain Guid' => ['remoteDomain', 'Guid'],
'remoteDomain RemoteDomainId' => ['remoteDomain', 'RemoteDomainId'],
'remoteDomain Identity' => ['remoteDomain', 'Identity'],
'inboundConnector id' => ['inboundConnector', 'id'],
'inboundConnector sourceId' => ['inboundConnector', 'sourceId'],
'inboundConnector Guid' => ['inboundConnector', 'Guid'],
'inboundConnector ConnectorId' => ['inboundConnector', 'ConnectorId'],
'inboundConnector Identity' => ['inboundConnector', 'Identity'],
]);
it('rejects derived display routing and order-only identities', function (
string $canonicalType,
array $payload,
string $blocker,
): void {
$readiness = spec454NormalizerReadiness($canonicalType, [$payload]);
expect($readiness->ready)->toBeFalse()
->and($readiness->blockers)->toContain($blocker)
->and($readiness->normalizedPreviews)->toBe([])
->and($readiness->hashPreviews)->toBe([]);
})->with([
'transport name' => ['transportRule', ['Name' => 'Display only'], 'display_name_only'],
'transport priority' => ['transportRule', ['Priority' => 1], 'missing_stable_external_id'],
'transport order' => ['transportRule', ['Order' => 1], 'missing_stable_external_id'],
'remote domain' => ['remoteDomain', ['DomainName' => 'example.test'], 'derived_identity_blocked'],
'remote name' => ['remoteDomain', ['Name' => 'Display only'], 'display_name_only'],
'connector name' => ['inboundConnector', ['Name' => 'Display only'], 'display_name_only'],
'connector IP' => ['inboundConnector', ['SenderIPAddresses' => ['192.0.2.1']], 'missing_stable_external_id'],
'connector host' => ['inboundConnector', ['SmartHosts' => ['mx.example.test']], 'missing_stable_external_id'],
'connector certificate' => ['inboundConnector', ['TlsSenderCertificateName' => 'CN=example'], 'missing_stable_external_id'],
]);
it('hard-stops a complete type collection on alias conflict or duplicate before producing any normalized item', function (
string $canonicalType,
array $items,
string $blocker,
): void {
$readiness = spec454NormalizerReadiness($canonicalType, $items);
expect($readiness->ready)->toBeFalse()
->and($readiness->blockers)->toContain($blocker)
->and($readiness->normalizedPreviews)->toBe([])
->and($readiness->hashPreviews)->toBe([]);
})->with([
'transport alias conflict' => ['transportRule', [['Guid' => 'a', 'RuleId' => 'b']], 'identity_conflict'],
'remote alias conflict' => ['remoteDomain', [['Guid' => 'a', 'Identity' => 'b']], 'identity_conflict'],
'connector alias conflict' => ['inboundConnector', [['ConnectorId' => 'a', 'Identity' => 'b']], 'identity_conflict'],
'transport duplicate' => ['transportRule', [['Guid' => 'a'], ['Guid' => 'a']], 'duplicate_stable_identity'],
'remote duplicate' => ['remoteDomain', [['Identity' => 'a'], ['Identity' => 'a']], 'duplicate_stable_identity'],
'connector duplicate' => ['inboundConnector', [['ConnectorId' => 'a'], ['ConnectorId' => 'a']], 'duplicate_stable_identity'],
]);
it('processes the 1000-item limit with one output per identity and one associative seen-set pass', function (): void {
$items = array_map(
static fn (int $index): array => [
'Guid' => sprintf('rule-%04d', $index),
'Enabled' => $index % 2 === 0,
],
range(1, 1000),
);
$readiness = spec454NormalizerReadiness('transportRule', $items);
$source = file_get_contents(
app_path('Services/TenantConfiguration/ExchangePowerShellTargetEvidenceNormalizer.php'),
);
expect($readiness->ready)->toBeTrue()
->and($readiness->itemCount)->toBe(1000)
->and($readiness->normalizedPreviews)->toHaveCount(1000)
->and($readiness->hashPreviews)->toHaveCount(1000)
->and(substr_count((string) $source, 'foreach ($envelope->items as $item)'))->toBe(1)
->and($source)->toContain('$seenIdentities[$identityKey] = true;')
->not->toContain('foreach ($envelope->items as $candidate)');
});
it('freezes deterministic normalization and the privacy-preserving material hash contract', function (): void {
$first = spec454NormalizerReadiness('inboundConnector', [[
'Identity' => 'connector-454',
'Enabled' => true,
'SenderIPAddresses' => ['192.0.2.1', '192.0.2.2'],
'WhenChanged' => '2026-07-25T10:00:00Z',
]]);
$sameCountProtectedChange = spec454NormalizerReadiness('inboundConnector', [[
'Identity' => 'connector-454',
'Enabled' => true,
'SenderIPAddresses' => ['198.51.100.1', '198.51.100.2'],
'WhenChanged' => '2026-07-26T10:00:00Z',
]]);
$hashInput = app(ExchangePowerShellHashInputBuilder::class)->build(
resourceType: 'inboundConnector',
sourceSurface: ExchangePowerShellCommandContracts::SOURCE_SURFACE,
commandContractName: 'Get-InboundConnector',
commandContractVersion: ExchangePowerShellCommandContracts::COMMAND_CONTRACT_VERSION,
payloadShapeVersion: 'exchange-powershell-normalized-payload-v1',
normalizerVersion: 'exchange-inbound-connector-readiness-v1',
normalizedPayload: $first->normalizedPreviews[0],
);
expect($first->hashPreviews)->toBe($sameCountProtectedChange->hashPreviews)
->and($hashInput['hash_contract'])->toBe(ExchangePowerShellHashInputBuilder::HASH_CONTRACT)
->and($hashInput['source_schema_hash'])->toMatch('/\A[a-f0-9]{64}\z/')
->and(array_keys($hashInput))->toBe(collect(array_keys($hashInput))->sort()->values()->all())
->and(json_encode($first->normalizedPreviews, JSON_THROW_ON_ERROR))
->not->toContain('192.0.2.1')
->not->toContain('2026-07-25T10:00:00Z');
});
function spec454NormalizerReadiness(string $canonicalType, array $items): object
{
$contracts = app(ExchangePowerShellCommandContracts::class);
$contract = $contracts->contractForCanonicalType($canonicalType);
if (! is_array($contract)) {
throw new RuntimeException('Missing Spec 454 command contract.');
}
$result = ExchangePowerShellInvocationResult::succeeded($items, context: [
'output_state' => 'structured_collection',
'item_count' => count($items),
]);
$envelope = ExchangePowerShellStructuredOutputEnvelope::fromInvocationResult(
resourceType: $canonicalType,
contract: ExchangePowerShellCommandContract::fromVerifiedArray($contract, $contracts, []),
runnerMode: ExchangePowerShellInvocationGate::RUNNER_MODE_PRODUCTION,
result: $result,
);
return app(ExchangePowerShellEvidenceNormalizer::class)->evaluate($envelope);
}

View File

@ -8,7 +8,7 @@ ## Summary
| --- | ---: | --- |
| UI route/page inventory rows | 101 | Spec 440 removes legacy UI-063 and retains UI-102 as the single environment Coverage workflow. |
| Unique page reports | 22 | `page-reports/*.md`; some inventory rows intentionally share existing reports where routes resolve to the same surface. |
| Desktop screenshots | 29 | Route-inventory-linked desktop evidence, including six focused Spec 450 Baseline Compare states (ready, confirmation, queued, mixed result, all-unchanged/both-empty, and blocked), strategic runtime captures, blocker evidence, the Spec 366 rendered-report capture, Spec 396 system-panel proof, and Spec 445 Coverage v2 visual alignment. |
| Desktop screenshots | 29 | Route-inventory-linked desktop evidence, including six focused Spec 450 Baseline Compare states (ready, confirmation, queued, mixed result, all-unchanged/both-empty, and blocked), strategic runtime captures, blocker evidence, the Spec 366 rendered-report capture, Spec 396 system-panel proof, and the current Spec 454 Exchange Coverage decision surface. |
| Tablet screenshots | 0 | Deferred to later strategic mockup/implementation specs. |
| Mobile screenshots | 3 | Spec 366 adds mobile-ish rendered-report evidence for the customer technical profile; Spec 384 adds a narrow baseline subject resolution smoke capture; Spec 386 adds a narrow publication-resolution smoke capture. |
| Strategic Surface rows | 48 | Individual target treatment or explicit product decision required. |
@ -51,7 +51,7 @@ ## Coverage By Area
| Platform/system | 14 | Spec 396 adds focused browser proof for `/system`, `/system/login`, `/system/ops/runs`, and `/system/security/access-logs`; remaining directory, control, detail, and repair surfaces stay route-discovered or follow-up. |
| Governance | 13 | Strong browser coverage for inbox, decisions, exceptions, and baselines; Spec 451 adds Observation-backed internal Finding truth while keeping governance lifecycle authoritative, customer output isolated, and UI-058/UI-100 invocation-only. |
| Monitoring | 9 | Operations hub and alert delivery landing captured; Spec 399 caps the Operations Hub hot table and removes default raw run identifiers/technical wording while record details and config forms remain pattern/manual review. |
| Inventory | 8 | Spec 440 removes Inventory Coverage UI-063 and promotes UI-102 into the single capture/readiness Decision Page; Spec 445 gives that page one consistent Filament/TenantPilot decision, metrics, table, and technical-disclosure hierarchy. Spec 452 preserves the rendered six-row surface while making its product scope authority-owned and keeping Internal-Only, Future and Inventory types hidden. |
| Inventory | 8 | Spec 440 removes Inventory Coverage UI-063 and promotes UI-102 into the single capture/readiness Decision Page; Spec 454 keeps Intune `x/6` and adds a separately selected internal-only Exchange Online `x/3` with one action per active tab, canonical attempt/currentness truth, safe prerequisite copy, and no combined `x/9` claim. The existing decision, metrics, table, and closed technical-disclosure hierarchy remain bounded. |
| Evidence / audit | 8 | Audit log captured; Spec 448 cuts Baseline Snapshot list/detail to frozen Coverage v2 manifest/item truth, including successful-empty types, with technical contract detail collapsed and raw payload absent. Evidence Overview remains follow-up. |
| Reviews | 8 | Review register, customer workspace, review pack detail, rendered-report, and the Spec 386 publication-resolution workflow now have bounded browser evidence; Spec 397 reduces Review Pack receipt internals while deeper evidence/report surfaces still remain open elsewhere. |
| Backup / restore | 6 | High-risk area; Spec 371 adds seeded browser proof for Backup Sets list/detail. Spec 390 adds Restore create/view readiness guidance; Spec 397 verifies completed Restore Run detail receipt reduction while Restore list and broader failure/conflict browser coverage remain unresolved. |
@ -75,7 +75,7 @@ ## Coverage By Primary Archetype
| Settings / Admin | 13 | RBAC, entitlement, lifecycle, and dangerous setting changes need confirmation and authorization review. |
| Evidence / Audit | 10 | Must keep proof, timestamps, source, and raw details clearly separated; Spec 448 applies this to the immutable Baseline receipt and removes raw/latest reconstruction from its default surface. |
| Operations / Monitoring | 9 | Needs consistent run status, retry/rerun semantics, and diagnostic hierarchy. |
| Inventory | 8 | UI-102 owns the bounded Coverage capture/readiness decision; Specs 445/446 prove visual alignment and persisted `Failed` versus `Never attempted` semantics. Spec 452 retains the same six-row decision hierarchy, confirmed capability-gated capture and read-only inspect model while replacing only the product-scope source. |
| Inventory | 8 | UI-102 owns the bounded Coverage capture/readiness decision; Spec 454 browser-verifies separate Intune `x/6` and Exchange Online `x/3` tabs, exact provider scope, confirmed capability-gated capture, distinct empty/blocked/failed/partial/not-attempted truth, and a closed technical annex without downstream customer or governance actions. |
| Drift / Diff | 9 | Spec 450 completes the Coverage v2 Baseline Compare decision hierarchy and exact internal receipt; broader matrix/subject-resolution product semantics retain their existing contracts. |
| Provider / Integration | 7 | Consent, credentials, permissions, and disconnect states require high trust clarity. |
| Reviews | 8 | Customer/auditor language, export context, proof links, and source-owned publication resolution are central. |

View File

@ -72,7 +72,7 @@ # Route Inventory
| UI-100 | `/admin/workspaces/{workspace}/environments/{environment}/baseline-subject-resolution` | page | Baseline Subject Resolution | Governance | environment-bound | browser-verified | workspace + environment entitlement; view/manage policy retained; retry rechecks `tenant.view`/`tenant.sync` | Drift / Diff | Evidence / Audit | Strategic Surface | browser-verified | [desktop](../../specs/384-baseline-subject-resolution-ui/artifacts/screenshots/spec384-01-baseline-subject-resolution.png) | [report](page-reports/ui-100-baseline-subject-resolution.md) | Focused worklist remains unchanged; the existing rerun confirmation discloses the exact environment/Profile, fixed cohort, TenantPilot-only Finding/Observation writes, no Microsoft mutation, and unknown counts. |
| UI-062 | `/admin/workspaces/{workspace}/environments/{environment}/inventory` | cluster | Inventory Cluster | Inventory | environment-bound | route exists | environment entitlement | Inventory | Workspace / Tenant Context | Domain Pattern Surface | repo-verified | - | - | Cluster landing/navigation surface. |
| UI-063 | `/admin/workspaces/{workspace}/environments/{environment}/inventory/inventory-coverage` | removed | Inventory Coverage | Inventory | environment-bound | route removed | N/A | Inventory | Removed | Historical context | repo-verified | - | - | Removed by Spec 440 without redirect or alias; UI-102 is the canonical Coverage route. |
| UI-102 | `/admin/workspaces/{workspace}/environments/{environment}/tenant-configuration/coverage-v2` | page | Intune core coverage | Inventory | environment-bound | route exists | workspace + environment entitlement plus `evidence.view`; capture requires `evidence.manage` | Inventory | Decision Page | Strategic Surface | browser-verified | [desktop](../../specs/446-coverage-v2-capture-type-result-contract/artifacts/screenshots/spec446-capture-type-result-status.png) | [report](../../specs/452-coverage-v2-product-universe-canonical-type-authority/implementation-report.md) | Spec 440 owns the single Coverage workflow and truth; Specs 445/446 prove its visual hierarchy and canonical attempt truth. Spec 452 changes only the scope authority: the same six Product-Committed rows, states, confirmation, `evidence.manage` capture boundary and inspect model remain visible; Internal-Only, Future and Inventory types remain absent. |
| UI-102 | `/admin/workspaces/{workspace}/environments/{environment}/tenant-configuration/coverage-v2` | page | Coverage (Intune / Exchange Online) | Inventory | environment-bound | route exists | workspace + environment entitlement plus `evidence.view`; capture requires `evidence.manage`, and Exchange capture also requires `provider.run` | Inventory | Decision Page | Strategic Surface | browser-verified | [desktop](../../specs/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer/artifacts/screenshots/ui-102-exchange-default-hps.png) | [report](../../specs/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer/implementation-report.md) | Spec 454 adds an explicitly selected Exchange Online tab to the existing no-legacy Coverage workflow: Intune remains `x/6`, Exchange is a separate internal-only `x/3`, and no combined `x/9` claim exists. Each tab owns one confirmed capture action and canonical attempt/currentness truth; Exchange provider prerequisites are safe product copy, raw values and technical identifiers stay out of the default surface, the Technical Annex remains closed, and focused browser proof confirms scope/RBAC isolation with no Compare, Finding, restore, certification, or customer action. |
| UI-064 | `/admin/workspaces/{workspace}/environments/{environment}/inventory-items` | resource | Inventory Items | Inventory | environment-bound | route exists | environment entitlement | Inventory | Evidence / Audit | Domain Pattern Surface | repo-verified | - | - | Core observed-state list; Spec 440 removes legacy Coverage KPIs while retaining genuine Inventory sync/activity. |
| UI-065 | `/admin/workspaces/{workspace}/environments/{environment}/inventory-items/{record}` | resource | Inventory Item Detail | Inventory | environment record | route exists | environment + record entitlement | Inventory | Evidence / Audit | Domain Pattern Surface | repo-verified | - | - | Detail report should distinguish raw provider payload from decision content. |
| UI-066 | `/admin/workspaces/{workspace}/environments/{environment}/policies` | resource | Policies | Inventory | environment-bound | route exists | environment entitlement | Inventory | Drift / Diff | Domain Pattern Surface | repo-verified | - | - | Intune policy inventory list. |

View File

@ -0,0 +1,230 @@
# Final Candidate Gate — Spec 454
**Purpose:** Final merge-readiness decision after implementation evidence is complete.
**Created:** 2026-07-26
**Feature:** [Spec 454](../spec.md)
Choose exactly one:
```text
PASS
PASS WITH CONDITIONS
FAIL
```
## PASS requires
```text
Specs 452 and 453 are integrated.
Exactly transportRule, remoteDomain and inboundConnector
are promoted to INTERNAL_ONLY and OPERATOR_PRODUCTIZED.
The Authority permits Capture and blocks Baseline, Compare,
Finding, Restore, Certification and Customer publication.
The three Exchange definitions have internalCompareEligible=false;
existing Entra internal Compare and Product-committed Compare behavior
are unchanged.
CaptureOutcome::Captured remains distinct from the exact active
Source state contract_verified_capture_enabled.
Each capturable Source decision has a non-empty Source contract and
exactly one executable descriptor:
sourceEndpoint XOR commandContractKey.
Exchange decisions keep sourceEndpoint null. The existing non-null Evidence
storage slot contains only the typed non-HTTP
exchange_online_powershell_rest:<allowlisted-command-name> provenance encoding
plus the exact command key in Source metadata, and is never
executed or hydrated as an endpoint.
The Intune six-type cohort remains unchanged.
The existing trusted starter creates one provider-scoped
tenant_configuration.capture Run, exactly three provisional
Type Results and one queued job.
Foreign/wrong-scope Provider Connections return 404 without disclosure.
A same-scope non-Microsoft connection produces canonical Blocked reason
`provider_binding_unsupported` and zero provider calls.
Credential, Permission and Runtime readiness are each attempted at most once
across all deliveries and execute exactly once only when an uninterrupted
winning claimant reaches the step.
A parent-Run-keyed `WithoutOverlapping` middleware prevents a duplicate copy
from entering handle() while the winning delivery is active; its safe lock
contains no receipt/provider/security data or business outcome and expires
after the job timeout plus the existing queue safety margin.
A locked one-way parent-Run Context
`preflight_attempt_claimed_at` marker permits one shared-preflight attempt
claimant and records no completion result. A crash after claim may execute zero
preflight steps; a later redelivery performs persistent reconciliation only and
never repeats preflight or refetches provider data.
Every later parent-Run lifecycle write uses fresh/locked Context and preserves
the one-way marker.
A fully successful all-Continue batch runs exactly three allowlisted Exchange
commands after the shared preflight succeeds; a shared blocker runs zero, and
Stop/exception prevents every later command.
The Gate derives the canonical commands internally, delivers one
receipt synchronously, and invokes the next command only after the
consumer persists and terminalizes the current Type Result and
returns typed Continue.
Typed Stop or consumer exception prevents later commands and cleanup
always executes in finally.
All provider receipts and structured provider data remain inside the
same Queue-process memory; no receipt array or batch request/result
DTO exists.
No receipt, stdout, stderr, provider collection or Credential
material is serialized or persisted.
The existing provider-neutral Resource and Evidence models
store same-scope content-backed internal Evidence.
The explicit ContentBacked maximum short-circuits before any
Comparable/Renderable builder evaluation.
The Evidence writer is retry-safe through a PostgreSQL-enforced
unique `(operation_run_id, resource_id)` index and atomic
insert-or-reuse.
Reuse requires canonical equality of every persisted immutable fact.
The privacy-preserving payload hash alone is never sufficient; the
same hash with different raw/normalized payload fails closed.
Winner-owned database identity/timestamps are preserved and are not incoming
equality inputs.
Successful Empty creates a terminal success result without
fake Resource or Evidence.
Failed, partial and not-attempted semantics are correct.
Capture summary is derived only from persistent Type Results.
Exchange Currentness uses its dedicated 24-hour policy.
The existing Coverage-v2 surface shows separate Intune and
Exchange sections, separate denominators and separate actions.
The page is exactly a native Monitoring / Queue / Workbench +
Detail-first Operational Surface. Embedded type/resource tables are exactly
native List / Table / Bulk + Read-only Registry / Report Surfaces.
Workspace/Environment remain shell-owned; provider tab and draft connection
selection remain page-owned; inspect and Technical Annex state remain
detail-owned and subordinate. Draft connection state is not restored as
authorization, and the Annex is never restored open.
No global nine-type percentage is displayed.
The default active-provider layer has at most one visible table,
eight visible rows, four primary metrics and two secondary actions,
with exactly zero technical IDs and zero technical links.
Feature and every T080-T088 Browser scenario enforce those counts
through stable visible-DOM selectors; authorized rendered states assert
visible counts and 403/404 states assert zero UI-102 budget nodes. Screenshots
alone are not proof.
No Graph, ProviderGateway, Fixture, Fake, Inventory,
PolicyVersion or Context fallback remains.
No Exchange Baseline, Compare, Finding, Customer Output,
Restore, Certification or Renderable consumer is activated.
Negative public-API probes pass and all downstream Runtime/schema/UI
paths remain unchanged.
Browser and Human Product Sanity pass.
PostgreSQL, focused tests, regressions and Architecture guards pass.
The deterministic O(N) identity-operation proof passes at the 1,000-item
limit without a wall-clock assertion.
Surface review outcome is `acceptable-special-case`.
Test-governance workflow outcome is `keep`.
The implementation report contains `Guardrail / Exception / Smoke Coverage`.
Fast Feedback remains at or below 215 seconds.
There are no open confirmed in-scope findings.
```
## PASS WITH CONDITIONS is allowed only for
```text
a later external Staging/Activation condition
```
It is not allowed when any of these remain unproven:
```text
Authority blocking
exact three-type cohort
provider scope
Credential/Permission/Runtime preflight
per-Run Queue overlap exclusion
single-attempt parent-Run preflight claim and redelivery reconciliation
same-process receipt boundary
lazy Gate continuation ordering
Evidence idempotency
immutable-fact equality on Evidence reuse
Type Result truth
Currentness
provider-separated UI
exact page/table Surface taxonomy and shell/page/detail state ownership
all numeric visible-DOM budgets
deterministic O(N) identity proof
Surface review and test-governance workflow outcomes
Customer/Restore/Certification isolation
No-Fallback contract
browser proof
runtime budget
```
## FAIL if
```text
a fourth Exchange type enters the cohort
the Intune cohort changes
a receipt leaves the Queue process
the next command starts before current persistence/finalization completes
the Gate can be bypassed or returns a receipt array
provider data enters Context, Cache, filesystem or Event
Graph or ProviderGateway fallback remains
Credential material is persisted or logged
a wrong-scope Provider Connection can be used
a same-scope non-Microsoft connection does not fail as canonical Blocked with zero provider calls
a duplicate delivery enters handle() while the winner is active
concurrent/redelivered delivery repeats shared preflight or provider execution
Evidence duplicates can be created on retry
same-run conflicting Evidence does not fail closed
same-hash different immutable Evidence is reused
the adapter writes Capture summary directly
Successful Empty creates fake rows
failed or partial truth appears current
Exchange enters Baseline, Compare or Findings
Customer Output contains Exchange claims
Restore or Certification becomes enabled
Spec 438 becomes reachable
the operator surface mixes Intune and Exchange denominators
shell, page or detail state silently overwrites another layer's authority
draft connection or open Technical Annex is restored as active authority/state
any default-layer UI-102 numeric budget fails
identity processing is quadratic or lacks deterministic O(N) proof
numeric UI proof is screenshot-only
browser proof is missing
Fast Feedback exceeds 215 seconds
```

View File

@ -0,0 +1,47 @@
# Readiness Remediation Requirements Checklist — Spec 454
**Purpose:** Validate the clarity, consistency, measurability, and coverage of
the corrected readiness requirements.
**Created:** 2026-07-26
**Feature:** [Spec 454](../spec.md)
**Audience:** Spec-readiness reviewer before implementation.
## Requirement Completeness
- [x] CHK001 Are the exact three Exchange types and the unchanged Intune six-type cohort specified without an extensible fourth-type path? [Completeness, Spec §2.3, §5]
- [x] CHK002 Is the minimal fixed `internalCompareEligible=false` correction distinguished from a free-form capability framework and local comparator denylist? [Completeness, Spec §6.2, FR-454-076]
- [x] CHK003 Are the existing active Source-state family and the `sourceEndpoint XOR commandContractKey` executable-descriptor rules defined for both HTTP and Exchange decisions? [Completeness, Spec §7, FR-454-074075]
- [x] CHK004 Is Evidence identity specified exactly as `(operation_run_id, resource_id)`, with immutable-fact equality for reuse and fail-closed conflict semantics? [Completeness, Spec §26, FR-454-079]
- [x] CHK005 Are Baseline, Compare, Finding, Customer, Restore, Certification, Renderable, and Specs 437/438 isolation requirements each tied to executable negative probes? [Completeness, Spec §36, FR-454-082]
## Requirement Clarity
- [x] CHK006 Is the one-way marker explicitly named as an attempted-execution claim rather than a successful preflight receipt? [Clarity, Spec §13, FR-454-020022]
- [x] CHK007 Are “at most once across deliveries” and “exactly once on an uninterrupted claimant that reaches the step” distinguished from an impossible unconditional exactly-once claim? [Clarity, Spec §13, NFR-454-004]
- [x] CHK008 Are foreign/wrong-scope connection semantics fixed at 404 and same-scope non-Microsoft semantics fixed at canonical `Blocked` with `provider_binding_unsupported` and zero provider calls? [Clarity, Spec §8.3, FR-454-019]
- [x] CHK009 Are the operational page and embedded tables assigned one exact broad class and one exact detailed Surface type each? [Clarity, Spec §UI/UX Surface Classification, §50]
- [x] CHK010 Are Global Context, Page, and Detail owners plus Requested, Active, Draft, Inspect, and Restorable state defined without layer collapse? [Clarity, Spec §UI State Ownership, FR-454-083085]
## Requirement Consistency
- [x] CHK011 Does the plan order identity/normalization before the Evidence migration/writer and the consumer only after that writer is available, consistently with tasks T026T048? [Consistency, Plan §1, §810]
- [x] CHK012 Do spec, plan, tasks, data model, and final gate use `preflight_attempt_claimed_at` and the same crash/redelivery semantics? [Consistency, Spec §13, Plan §6, Data Model §1, Tasks T018T024]
- [x] CHK013 Do the UI taxonomy, state-owner matrix, deterministic budgets, Browser tasks, and final gate describe the same single-route operator surface? [Consistency, Spec §UI State Ownership, Plan §12, Tasks T059T088]
## Acceptance Criteria Quality
- [x] CHK014 Can O(N) identity processing be objectively proved at the 1,000-item limit through an operation counter and nested-scan guard rather than a timing threshold? [Measurability, NFR-454-003, Tasks T032/T077]
- [x] CHK015 Can every UI-102 numeric budget be objectively counted through visible-DOM selectors in authorized and denied states? [Measurability, FR-454-080081, AC-454-010]
- [x] CHK016 Can same-hash and different-hash concurrent Evidence cases be distinguished under the exact PostgreSQL conflict target? [Measurability, Spec §26, AC-454-007]
## Scenario and Edge-Case Coverage
- [x] CHK017 Is a crash after the attempted-execution claim but before Credential resolution covered without inventing a preflight completion state? [Recovery Coverage, Spec §47.26]
- [x] CHK018 Are unknown provider-tab input, non-restorable draft connection state, inspect subordination, and a never-restored-open Technical Annex covered? [Edge-Case Coverage, Spec §47.27, FR-454-083085]
- [x] CHK019 Are STOP, callback exception, shared blocker, and redelivery paths consistent with lazy gate ownership and persistent Type Result reconciliation? [Exception Coverage, Spec §13, §29, §32]
## Review Outcome
- [x] CHK020 Surface review outcome: `acceptable-special-case` — exact native taxonomy and explicit state ownership require no Product Surface exception.
- [x] CHK021 Test-governance workflow outcome: `keep` — focused named heavy families remain opt-in and bounded.
- [x] CHK022 Final note location: `Guardrail / Exception / Smoke Coverage` in the active implementation report and PR close-out.

View File

@ -0,0 +1,747 @@
# Requirements Checklist — Spec 454
**Purpose:** Implementation-readiness and close-out checklist for the bounded Exchange capture/evidence consumer.
**Created:** 2026-07-26
**Feature:** [Spec 454](../spec.md)
**Use:** Preparation marks only requirements that are already proven by repository evidence; implementation and runtime-proof items remain open.
## Preparation Quality Validation
- [x] No `NEEDS CLARIFICATION`, template placeholder, or unresolved normative decision remains.
- [x] The problem, current failure, operator improvement, six prioritized user stories, and independent acceptance proofs are explicit.
- [x] Functional/non-functional requirements are testable and acceptance criteria are measurable.
- [x] The exact three-type/provider/workload boundary and every downstream non-goal are explicit.
- [x] Workspace, Environment, Provider Connection, RBAC, trusted-start, OperationRun, queue/in-process, Evidence, Currentness, and no-fallback contracts are internally consistent.
- [x] High-risk implementation detail appears only where needed to make security, persistence, concurrency, rollback, and Product Surface behavior unambiguous.
- [x] Proportionality records the existing-family Source-state value, optional command descriptor, one code-only internal Compare flag, one technical CAS marker, reuse of per-Run overlap middleware, and two-value continuation enum; none creates a product taxonomy or generic framework.
- [x] Candidate class, five approval questions, red-flag defense, 11/12 score, roadmap relation, and deferred alternatives are recorded.
- [x] Product Surface Impact, UI Surface Impact, no-legacy posture, the exact native `Detail-first Operational Surface` page and embedded `Read-only Registry / Report Surface` tables, shell/page/detail state ownership, surface budgets, canonical status mapping, Technical Annex demotion, Browser/HPS gates, visible-complexity target, and exception result are explicit.
- [x] The PostgreSQL Evidence conflict target is exactly `(operation_run_id, resource_id)` and covers concurrent exact and different-hash races.
- [x] Exact Evidence reuse requires canonical equality of every persisted immutable fact; the privacy-preserving hash alone is explicitly insufficient.
- [x] The Gate-owned sequence is lazy: one receipt, durable persistence and terminal Type Result, then typed `Continue` before the next invocation.
- [x] Every T080T088 scenario names executable visible-DOM assertions for all six numeric UI-102 budgets.
- [x] Preparation Surface review outcome is `acceptable-special-case`; preparation test-governance workflow outcome is `keep`; final close-out must re-evaluate both.
- [x] The eight exact prior-spec tests whose pending-state assertions are superseded are path-owned; all their safety assertions and every completed Spec artifact remain protected.
- [x] The three exact Spec-452 authority-derived count/cohort expectations are
path-owned; Product, Compare, Finding, Customer, unresolved, and
architecture-violation assertions remain protected.
- [x] Spec, plan, tasks, requirements checklist, and final candidate gate use the same terminology and scope.
## A. Prerequisites
- [x] Spec 452 is integrated at the current `platform-dev` baseline.
- [x] Spec 453 is integrated at current HEAD `464a0716`.
- [x] Coverage Completion Report passes with `invariant_violation_count = 0` on the prepared branch.
- [x] Spec-453 T076 baseline is recorded by the uploaded fresh read-only preflight.
- [x] Fast Feedback baseline is below 215 seconds: the uploaded fresh preflight records 204.51 seconds and 10.49 seconds remaining; Spec 453's earlier 122.23-second run is predecessor evidence only.
- [x] Spec 454 number is free; the core SpecKit creation script created this package once.
- [x] Prepared `spec:` commit exists.
- [x] Working Tree is clean.
- [x] `git diff --check` passes for the preparation state.
## B. Exact Scope
- [x] `transportRule` is included.
- [x] `remoteDomain` is included.
- [x] `inboundConnector` is included.
- [x] No fourth Exchange type is included.
- [x] No Teams type is included.
- [x] No additional Intune type is included.
- [x] Intune six-type cohort remains unchanged.
## C. Provider and Workload
- [x] Provider is `microsoft`.
- [x] Workload is `exchange`.
- [x] No provider key `exchange` is introduced.
- [x] Provider Connection belongs to Workspace.
- [x] Provider Connection belongs to Environment.
- [x] Provider Connection is active.
- [x] Ambiguous connection selection fails closed.
- [x] Connection is revalidated server-side.
- [x] Foreign or wrong-scope Provider Connection returns 404 without disclosure.
- [x] Same-scope non-Microsoft Provider Connection yields canonical `Blocked` with `provider_binding_unsupported` and zero provider calls.
## D. Authority Transition
- [x] All three types are `INTERNAL_ONLY`.
- [x] All three types are `OPERATOR_PRODUCTIZED`.
- [x] All three types are `INTERNAL_OPERATOR_VISIBLE`.
- [x] Publication is `INTERNAL_ONLY`.
- [x] Active `exchange_powershell_capture.v1` source contract plus existing authority state makes Capture eligible.
- [x] Existing Product-Committed-only Baseline gate keeps Exchange blocked.
- [x] Fixed code-only `internalCompareEligible` is false for the three Exchange types.
- [x] Existing Entra internal Compare eligibility and Product-committed Compare behavior remain unchanged.
- [x] Existing Product-Committed-only Finding gate keeps Exchange blocked.
- [x] Existing Publication Classification keeps customer publication blocked.
- [x] Existing Coverage-level/downstream guards keep Restore, Certification, and Renderable blocked.
- [x] No general consumer-contract object, additional slot family, dynamic map, or local Exchange denylist is introduced.
- [x] Authority-native downstream blocking is proven.
- [x] No consumer-local Product-Scope authority exists.
## E. Source Contract
- [x] `CaptureOutcome::Captured` remains an outcome and is not used as Source-contract state.
- [x] Active Capture source state exists in the existing family.
- [x] Exact active state is `contract_verified_capture_enabled`.
- [x] Exactly three contracts enter the active state.
- [x] Existing Source decision gains only optional `commandContractKey`.
- [x] Every capturable decision enforces `sourceEndpoint XOR commandContractKey`.
- [x] Graph/HTTP decisions have endpoint and no command key.
- [x] The exact three Exchange decisions have command key and no endpoint.
- [x] Endpoint+command and neither-descriptor decisions fail closed.
- [x] A dummy endpoint is absent.
- [x] `capturable()` means Capture planning eligibility only.
- [x] Exchange decision `sourceEndpoint` remains null.
- [x] Existing Evidence `source_endpoint` retains deterministic non-HTTP `exchange_online_powershell_rest:<allowlisted-command-name>` provenance only.
- [x] Exchange Source metadata records descriptor kind `command` and exact command-contract key.
- [x] Persisted command encoding is never executed or hydrated as an endpoint.
- [x] `provider_calls_allowed` is runtime-gated.
- [x] `execution_enabled` is runtime-gated.
- [x] Page render cannot enable provider execution.
- [x] No parallel source-contract truth exists.
## F. Authorization
- [x] `evidence.view` protects the page.
- [x] `evidence.manage` protects Capture.
- [x] `provider.run` protects Capture.
- [x] Both mutation capabilities are required.
- [x] Missing capability returns 403.
- [x] Wrong scope returns 404.
- [x] UI hiding is not the authorization boundary.
- [x] Capture action requires confirmation.
- [x] Capture start and terminal outcome reuse the trusted starter's existing `tenant_configuration.capture.*` audit path plus canonical OperationRun lifecycle with no Exchange-specific parallel audit truth.
## G. Trusted Starter
- [x] Existing trusted starter is used.
- [x] Workload is explicit.
- [x] Cohort contract is explicit.
- [x] Selected connection is explicit.
- [x] Cohort is revalidated.
- [x] One Capture Run is created.
- [x] Exactly three provisional Type Results are created.
- [x] Exactly one Capture job is dispatched.
- [x] Shared OperationRun start/reuse/block feedback and tenant-safe URL resolution are preserved.
- [x] Queued DB notification remains disabled and terminal notification remains lifecycle-owned.
- [x] OperationRun UX exception is `none`.
- [x] No provider call occurs in the starter.
- [x] No Evidence is written by the starter.
- [x] No technical invocation run is created by the starter.
## H. Duplicate-Run Contract
- [x] Active identity includes provider connection.
- [x] Active identity includes workload.
- [x] Active identity includes cohort contract.
- [x] Duplicate Exchange action is blocked or reused.
- [x] Intune and Exchange do not collide unintentionally.
- [x] No parallel Exchange Capture for the same exact scope.
## I. Run-Scoped Preflight
- [x] Credential, Permission, and Runtime steps are each attempted at most once across all deliveries and execute exactly once only when an uninterrupted claimant reaches them.
- [x] Parent-Run-keyed `WithoutOverlapping` prevents a duplicate delivery from entering `handle()` while the winner is active.
- [x] The overlap middleware uses `dontRelease()` and expires after the job timeout plus the existing queue lifecycle safety margin.
- [x] The overlap lock contains no receipt, provider/security data, or business outcome.
- [x] A locked parent-Run CAS marker allows one delivery to claim the shared-preflight attempt; a later redelivery that finds it reconciles only.
- [x] The exact marker is `preflight_attempt_claimed_at` and records an attempt, not successful preflight completion.
- [x] Preflight-attempt marker contains only exact contract and safe timestamp, never business outcome, Credential, Permission/Runtime result, receipt, or provider data.
- [x] Preflight-attempt marker is one-way and cannot be cleared or overwritten by retry.
- [x] Every later lifecycle update reloads/locks current Context and preserves the marker.
- [x] Gate resolves exactly one eligible connection-scoped certificate reference; zero or ambiguous references fail closed.
- [x] Caller-supplied Credential ID is forbidden.
- [x] Crash after the attempted-execution claim but before Credential resolution proves all preflight counts may remain zero and redelivery reconciles without retry.
- [x] A fully successful all-`Continue` batch invokes three commands; a shared blocker invokes zero and `Stop`/exception invokes none after the current command.
- [x] Gate returns `void`; no batch request/result DTO or receipt array exists.
- [x] Receipt is not serializable.
- [x] Gate derives the exact canonical three-command order internally.
- [x] Gate delivers exactly one receipt synchronously to the concrete consumer.
- [x] Consumer terminalizes the current Type Result before returning.
- [x] Gate drops its receipt/provider-collection reference immediately after the consumer returns and before evaluating continuation.
- [x] Only typed `Continue` advances to the next command.
- [x] Typed `Stop` or consumer exception prevents every remaining command.
- [x] Callback exception is rethrown only after Gate cleanup; Job reconciles current `failed`/`partial`, remaining `not_attempted`, and the canonical summary from persistent truth.
- [x] No arbitrary Closure, public prepared-execution value, caller command list, technical Run ID, bypass marker, or alternate starter can enter the Gate.
- [x] Existing Spec-453 `invoke()` remains compatible, but the Capture Job/consumer never loops over it and exposes no third public invocation path.
- [x] Cleanup is guaranteed.
- [x] Cleanup executes in `finally` after success, Stop, and exception.
- [x] Shared blocker creates three blocked Type Results.
- [x] Shared blocker causes zero provider calls.
## J. Credential
- [x] Supported certificate path is explicit.
- [x] PKCS#12 is validated.
- [x] Private key is required.
- [x] `client_secret` remains blocked.
- [x] Missing Credential is blocked.
- [x] Expired Credential is blocked.
- [x] Inaccessible Credential is blocked.
- [x] Invalid Credential is blocked.
- [x] Unsupported kind is blocked.
- [x] No Credential material in argv.
- [x] No Credential material in Queue.
- [x] No Credential material in Context.
- [x] No Credential material in logs.
- [x] No Credential material in UI.
- [x] No Credential material in Evidence.
## K. Permission
- [x] `Exchange.ManageAsApp` is required.
- [x] Workspace matches.
- [x] Environment matches.
- [x] Provider is `microsoft`.
- [x] Provider Connection matches exactly.
- [x] Status is granted.
- [x] Source is provider verification.
- [x] Evaluator is correct.
- [x] Evaluator version is correct.
- [x] Permission Evidence is current.
- [x] Admin consent alone is insufficient.
- [x] No provider call occurs before permission passes.
## L. Runtime Readiness
- [x] PowerShell binary is checked.
- [x] ExchangeOnlineManagement module is checked.
- [x] Minimum module version is checked.
- [x] Process executor is checked.
- [x] Production runner flag is checked.
- [x] Invocation flag is checked.
- [x] Supported Credential config is checked.
- [x] Page render performs no Runtime check requiring Microsoft.
- [x] Runtime disabled becomes blocked.
- [x] Production activation remains default-deny.
## M. Command Contract
- [x] `Get-TransportRule` only.
- [x] `Get-RemoteDomain` only.
- [x] `Get-InboundConnector` only.
- [x] No user parameters.
- [x] Fixed script path.
- [x] Argument vector.
- [x] No shell string.
- [x] No operator command.
- [x] No operator parameter name.
- [x] 60-second timeout.
- [x] stdout limit.
- [x] stderr limit.
- [x] 1,000-item limit.
- [x] JSON depth limit.
## N. Structured Output
- [x] UTF-8 required.
- [x] Top-level list required.
- [x] Every item is an object.
- [x] `[]` is accepted.
- [x] Scalar rejected.
- [x] Single object rejected.
- [x] Malformed JSON rejected.
- [x] Truncated JSON rejected.
- [x] Binary rejected.
- [x] Warning-framing violation rejected.
- [x] Oversized stdout rejected.
- [x] Oversized stderr rejected.
- [x] Non-zero exit rejected.
- [x] Timeout rejected.
- [x] Raw process output is not Evidence.
## O. Queue and Receipt Boundary
- [x] Invocation and Evidence conversion occur in one `handle()`.
- [x] No second consumer job.
- [x] No Event with provider collection.
- [x] No receipt/provider-collection Cache storage; only the safe parent-Run overlap lock is permitted.
- [x] No filesystem storage.
- [x] No Context storage.
- [x] No stdout persistence.
- [x] No stderr persistence.
- [x] No provider refetch.
- [x] No second shared-preflight attempt occurs after the parent-Run marker is claimed.
- [x] Receipt is discarded after Evidence conversion.
- [x] Consumer retains no receipt/provider collection in an object property.
- [x] No static/global/escaping-closure receipt retention exists.
- [x] Job reloads persistent Type Results after the Gate returns.
## P. OperationRun
- [x] Capture uses `tenant_configuration.capture`.
- [x] Invocation uses the existing technical type.
- [x] No new OperationRun type.
- [x] One Capture Run per action.
- [x] A fully successful all-`Continue` batch has three technical invocation runs; shared blocker has zero and early stop has only the runs actually started.
- [x] Invocation runs correlate to Capture safely.
- [x] Capture Run owns Evidence.
- [x] Invocation context is sanitized.
- [x] Capture Context has no Type-outcome map.
- [x] Summary is derived from Type Results only.
- [x] Adapter does not write summary directly.
## Q. Identity — `transportRule`
- [x] `id` accepted.
- [x] `sourceId` accepted.
- [x] `Guid` accepted.
- [x] `RuleId` accepted.
- [x] Name rejected.
- [x] DisplayName rejected.
- [x] Priority rejected.
- [x] Order rejected.
- [x] Conflict rejected.
- [x] Duplicate rejected.
- [x] Missing rejected.
## R. Identity — `remoteDomain`
- [x] `id` accepted.
- [x] `sourceId` accepted.
- [x] `Guid` accepted.
- [x] `RemoteDomainId` accepted.
- [x] `Identity` accepted.
- [x] DomainName-only rejected.
- [x] Name-only rejected.
- [x] DisplayName-only rejected.
- [x] Conflict rejected.
- [x] Duplicate rejected.
- [x] Missing rejected.
## S. Identity — `inboundConnector`
- [x] `id` accepted.
- [x] `sourceId` accepted.
- [x] `Guid` accepted.
- [x] `ConnectorId` accepted.
- [x] `Identity` accepted.
- [x] Name-only rejected.
- [x] IP fallback rejected.
- [x] Host fallback rejected.
- [x] Certificate fallback rejected.
- [x] Comment fallback rejected.
- [x] Conflict rejected.
- [x] Duplicate rejected.
## T. Identity Hard Stop
- [x] Shape is validated first.
- [x] All identities are resolved before writes.
- [x] Alias conflicts are checked.
- [x] Missing/derived-only identity is blocked.
- [x] Full-collection duplicates are checked.
- [x] Same-scope conflicts are checked.
- [x] Identity processing uses one pass plus an associative identity map/set and contains no nested full-collection scan.
- [x] An instrumented 1,000-item test proves identity operations are bounded by a documented constant multiple of N without a wall-clock threshold.
- [x] Architecture proof rejects quadratic identity-processing structure.
- [x] Zero Resource writes occur on identity failure.
- [x] Zero Evidence writes occur on identity failure.
## U. Normalization
- [x] Three approved normalizers are used.
- [x] Payload shape is versioned.
- [x] Source contract is versioned.
- [x] Schema hash is deterministic.
- [x] Map keys are canonical.
- [x] Collection ordering is stable.
- [x] Null/default semantics are stable.
- [x] `WhenChanged` excluded.
- [x] `whenChanged` excluded.
- [x] `ExchangeVersion` excluded.
- [x] `RunspaceId` excluded.
## V. Material Hash
- [x] Contract key is `exchange-redacted-material-v1`.
- [x] SHA-256 is used.
- [x] Canonical type is included.
- [x] Source surface is included.
- [x] Command version is included.
- [x] Shape version is included.
- [x] Normalizer version is included.
- [x] Schema hash is included.
- [x] Redacted normalized payload is included.
- [x] Same-count protected-value limitation is documented.
- [x] Hash is not called complete content fingerprint.
- [x] No keyed HMAC is added.
- [x] Compare remains blocked.
## W. Redaction
- [x] Transport Rule protected fields are redacted.
- [x] Remote Domain protected fields are redacted.
- [x] Inbound Connector protected fields are redacted.
- [x] Raw Evidence contains no process framing.
- [x] Normalized Evidence contains no protected exact values.
- [x] Context contains no provider values.
- [x] Logs contain no provider values.
- [x] Notifications contain no provider values.
- [x] UI contains no provider exact values.
- [x] Customer Output contains none of this data.
## X. Safe Labels
- [x] No rule name as primary label.
- [x] No domain as primary label.
- [x] No connector name as primary label.
- [x] No IP/host/certificate as primary label.
- [x] Localized protected-resource labels are used.
- [x] Technical references are secondary.
- [x] Existing Intune labels are unchanged.
## Y. Evidence
- [x] Provider-neutral Resource model.
- [x] Provider-neutral Evidence model.
- [x] Workspace anchored.
- [x] Environment anchored.
- [x] Provider Connection anchored.
- [x] Capture Run anchored.
- [x] Resource Type anchored.
- [x] Canonical identity anchored.
- [x] Source contract anchored.
- [x] Schema hash anchored.
- [x] Payload hash anchored.
- [x] Coverage capped at `content_backed`.
- [x] Explicit `ContentBacked` maximum short-circuits before Comparable/Renderable builder evaluation.
- [x] Claim state is `internal_only`.
- [x] No Exchange-specific table.
- [x] No `tenant_id`.
## Z. Evidence Idempotency
- [x] Read-only duplicate precheck exists.
- [x] Duplicate precheck returns zero.
- [x] New PostgreSQL unique index exists.
- [x] Unique index is exactly `(operation_run_id, resource_id)`.
- [x] Migration is non-transactional and uses `CREATE UNIQUE INDEX CONCURRENTLY`.
- [x] Deterministic index name is `tenant_config_evidence_run_resource_unique`.
- [x] SQLite test migration uses the same index name/key without `CONCURRENTLY`; it is not Production authority.
- [x] Unsupported database drivers fail closed.
- [x] No historical migration changed.
- [x] No backfill.
- [x] Canonically exact immutable retry reuses Evidence.
- [x] Concurrent canonically exact insert creates one row.
- [x] Reuse compares every persisted immutable scope, Source/provenance, raw payload, normalized payload/contract, hash, outcome, Coverage and claim/Evidence state field present.
- [x] Winner-owned `id`, `captured_at`, `created_at`, and `updated_at` are not incoming equality inputs and exact reuse preserves them.
- [x] Payload-hash equality alone never authorizes reuse.
- [x] Same lossy hash with different raw or normalized payload conflicts.
- [x] Different Source descriptor/version/schema or immutable provenance conflicts.
- [x] Same-run different-hash conflict fails.
- [x] Concurrent same-run different-hash conflict leaves one row and fails the losing caller closed.
- [x] Concurrent same-hash/different-immutable conflict leaves one row and fails the losing caller closed.
- [x] Different Run creates new Evidence.
- [x] PostgreSQL is authoritative.
- [x] Insert uses conflict-safe `ON CONFLICT ... DO NOTHING` semantics and never queries inside a transaction aborted by a caught unique violation.
- [x] Exact reuse preserves winner timestamps and does not rewrite the Resource latest-Evidence pointer.
- [x] Existing Baseline Compare seal still blocks post-seal Evidence writes/latest-pointer mutation.
## AA. Capture Type Results
- [x] Exactly three provisional rows.
- [x] Exactly three terminal rows.
- [x] `success_with_items`.
- [x] `success_empty`.
- [x] `blocked`.
- [x] `failed`.
- [x] `partial`.
- [x] `not_attempted`.
- [x] Source page count is one for complete Exchange collection.
- [x] Empty creates no Resource.
- [x] Empty creates no Evidence.
- [x] Partial requires durable Evidence.
- [x] Not attempted means planned but unstarted.
- [x] Terminal rows remain immutable.
## AB. Summary Truth
- [x] Exchange adapter writes no Capture summary.
- [x] Type Results are the input.
- [x] Canonical summarizer is used.
- [x] OperationRunService terminalizes the Run.
- [x] No duplicate summary path.
- [x] No Context outcome map.
- [x] Prior Last Success remains available.
## AC. Retry and Crash
- [x] Same queued delivery uses same Run.
- [x] Same queued delivery uses same provisional results.
- [x] Exact Evidence is reused.
- [x] Terminal result is not rewritten.
- [x] Crash before finalization can reconcile.
- [x] Terminal partial requires a new Capture.
- [x] Worker crash leaves no duplicate Evidence.
- [x] Duplicate action does not create a parallel Run.
## AD. Currentness
- [x] Exact `tenantpilot.coverage_v2.exchange_powershell_currentness_hours` key.
- [x] Default is 24 hours.
- [x] No Intune TTL fallback.
- [x] Success with items renews Currentness.
- [x] Success empty renews Currentness.
- [x] Blocked does not renew.
- [x] Failed does not renew.
- [x] Partial does not renew.
- [x] Not attempted does not renew.
- [x] Last Attempt is separate.
- [x] Last Success is preserved.
- [x] Invocation time is not Currentness.
- [x] Permission time is not Capture Currentness.
## AE. ReadModel
- [x] Provider-scoped.
- [x] Workload-scoped.
- [x] Three Exchange types only.
- [x] Batched Type Result queries.
- [x] Batched count queries.
- [x] No N+1.
- [x] No Raw Payload.
- [x] No Context fallback.
- [x] No Legacy fallback.
- [x] No provider call during render.
## AF. Product Surface
- [x] Product Surface and UI Surface Impact are recorded as changes to the existing route only.
- [x] No-legacy posture passes; no compatibility shim, duplicate UI, hidden route, fallback reader, or legacy fixture remains.
- [x] Primary archetype is Decision Page.
- [x] `UI-102` is exactly a native `Monitoring / Queue / Workbench` + `Detail-first Operational Surface`.
- [x] Each embedded type/resource table is exactly a native `List / Table / Bulk` + `Read-only Registry / Report Surface`.
- [x] Workspace/Environment are shell-owned Global Context and cannot be overwritten by page/detail state.
- [x] Provider tab and Provider Connection selection are page-owned; unknown requested tab returns to the canonical Intune default.
- [x] Provider Connection selection remains Draft until confirmed, is revalidated before start, and is not persisted/restored as authorization.
- [x] Type/resource selection and Technical Annex are subordinate detail-owned Inspect state.
- [x] The Annex is closed by default and never restored open by refresh, back, bookmark, query, or shared link.
- [x] Closing inspect preserves validated shell context and active page state.
- [x] Exactly one primary product question and one active-tab primary action are visible.
- [x] Active-tab visible data tables are `<=1`.
- [x] Intune visible data rows are exactly `6`; Exchange visible data rows are exactly `3`; both are `<=8`.
- [x] Visible primary metrics are `<=4`.
- [x] Visible secondary actions are `<=2`; the dominant Capture action is separately marked primary.
- [x] Default-visible technical IDs are exactly `0`.
- [x] Default-visible technical links are exactly `0`.
- [x] Stable visible-DOM selectors enforce all numeric budgets in Feature tests and every T080T088 scenario.
- [x] Authorized rendered scenarios assert visible counts; 403/404 scenarios assert zero UI-102 budget selectors.
- [x] Screenshot-only budget proof is rejected.
- [x] Default layer has zero raw IDs, OperationRun/Evidence deep links, source keys, payload hashes, or repeated readiness summaries.
- [x] Internal outcomes map to the canonical Product Surface status vocabulary.
- [x] Product Surface exceptions are `none`.
- [x] Existing route is reused.
- [x] No new route.
- [x] No new navigation item.
- [x] Intune section remains.
- [x] Exchange section exists.
- [x] Intune denominator is six.
- [x] Exchange denominator is three.
- [x] No global nine-type denominator.
- [x] No mixed percentage.
- [x] Not-configured state is correct.
- [x] Readiness blocker is distinct from Capture failure.
- [x] Capture action is confirmed.
- [x] Capture action uses trusted starter.
- [x] OperationRun feedback/link is transient/internal or inside the collapsed Technical Annex, never in the default layer.
- [x] Main UI contains no protected values.
- [x] Technical Annex is secondary.
- [x] `docs/ui-ux-enterprise-audit/route-inventory.md` records the changed existing route.
- [x] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` records the focused proof/depth outcome.
## AG. Downstream Isolation
- [x] Baseline Authority false and `CoverageV2CaptureRunEligibilityResolver::resolve()` excludes all three.
- [x] Compare `internalCompareEligible=false`, `resolveForProductCompare()` excludes all three, and `CoverageV2BaselineComparator::materialize()` rejects/excludes Exchange input.
- [x] Finding policy/base eligibility absent and `CoverageV2DriftFindingPromoter` public probe produces no Finding/Observation.
- [x] Publication is internal-only, `CoverageTypeAuthority::customerEligibleDefinitions()` excludes all three, and `ClaimGuard::evaluate()` denies each customer-facing request.
- [x] `ResourceTypeRegistry::syncDefaults()` plus `findActive()` exposes all three as NotRestorable, and `ClaimGuard::evaluate()` denies each Restorable request.
- [x] Registry rows have `allows_certified_claims=false` plus `metadata.certification_allowed=false`, and `ClaimGuard::evaluate()` denies each Certified request.
- [x] `EntraCertifiedComparePackEvaluator::evaluate()` is uncalled.
- [x] `ExchangePowerShellComparablePayloadBuilder` is uncalled.
- [x] `ExchangeTeamsRenderableSummaryBuilder` is uncalled for the exact three Exchange Capture rows.
- [x] Spec 438 remains deferred and has no Runtime consumer.
- [x] Baseline/Compare/Finding/Customer/Restore/Certification Runtime, schema and UI paths remain unchanged.
- [x] No customer-facing count.
- [x] No “fully covered” claim.
## AH. No Fallback
- [x] No Graph client in Exchange consumer.
- [x] No ProviderGateway.
- [x] No `graph_v1_fallback`.
- [x] No fixture Runtime.
- [x] No fake Runtime.
- [x] No Inventory Coverage.
- [x] No `inventory_items`.
- [x] No PolicyVersion.
- [x] No Context business truth.
- [x] No latest-Evidence Currentness.
- [x] No second provider fetch.
## AI. Security
- [x] No secret in argv.
- [x] No secret in Context.
- [x] No secret in Queue.
- [x] No secret in logs.
- [x] No secret in UI.
- [x] No stdout in persistence.
- [x] No stderr in persistence.
- [x] No raw provider value in notification.
- [x] Safe reason codes only.
- [x] Stack traces absent from operator messages.
## AJ. Filament and Livewire
- [x] Filament v5 patterns.
- [x] Livewire v4 patterns.
- [x] Existing panel provider.
- [x] No `bootstrap/providers.php` change.
- [x] No new global search Resource.
- [x] Global Search behavior unchanged.
- [x] No new frontend assets.
- [x] No asset registration.
- [x] Existing deployment `filament:assets` behavior is documented; Spec 454 adds no registered asset requirement.
- [x] High-impact action confirmation.
- [x] Server-side authorization.
## AK. PostgreSQL
- [x] Fresh migration passes.
- [x] Rollback passes.
- [x] Rollback pauses Capture writers and drops only the Spec-454 index concurrently.
- [x] Reapply passes.
- [x] Unique index exists.
- [x] Race test passes.
- [x] Different-hash conflict passes.
- [x] Scope constraints pass.
- [x] No historical migration changed.
- [x] No new table.
- [x] No new column.
## AL. Browser
- [x] Intune remains correct.
- [x] Exchange section shows three types.
- [x] Provider connection selection.
- [x] Capture confirmation.
- [x] Running state.
- [x] Success state.
- [x] Successful Empty.
- [x] Credential blocker.
- [x] Permission blocker.
- [x] Runtime blocker.
- [x] Failed state.
- [x] Partial state.
- [x] Prior success preserved.
- [x] Wrong Workspace.
- [x] Wrong Environment.
- [x] Wrong Connection.
- [x] Same-scope non-Microsoft connection renders canonical `Blocked` with `provider_binding_unsupported` and zero provider calls.
- [x] Missing capability.
- [x] Duplicate start.
- [x] No Baseline/Compare/Finding.
- [x] No Customer claim.
- [x] No Restore/Certification.
- [x] Desktop proof.
- [x] Responsive proof.
- [x] Keyboard proof.
- [x] Unknown provider-tab input cannot create a third active state.
- [x] Draft connection state is not restored or treated as authorization.
- [x] Inspect close preserves shell/page state.
- [x] Technical Annex is never restored open.
- [x] No Console error.
- [x] No Livewire error.
- [x] No Network error.
## AM. Human Product Sanity
- [x] Provider distinction is understandable.
- [x] Exact three-type scope is understandable.
- [x] Readiness is distinct from outcome.
- [x] Last Attempt is distinct from Last Success.
- [x] Empty is distinct from blocked.
- [x] Blocked is distinct from failed.
- [x] Failed is distinct from partial.
- [x] Technical PowerShell details do not dominate.
- [x] Protected values are absent.
- [x] No customer/compliance overclaim.
- [x] The focused screenshot exists exactly at `artifacts/screenshots/ui-102-exchange-default-hps.png` inside the active Spec-454 package.
- [x] Result is PASS.
## AN. Runtime Budget
- [x] Baseline recorded.
- [x] Focused Spec-454 lane recorded.
- [x] Updated Fast Feedback recorded.
- [x] Tests recorded.
- [x] Assertions recorded.
- [x] Skips recorded.
- [x] Pest duration recorded.
- [x] Wall-clock duration recorded.
- [x] Fast Feedback <=215 seconds.
- [x] No test removed.
- [x] No required proof skipped.
- [x] Hard limit unchanged.
## AO. Regression
- [x] Specs 430437.
- [x] Spec 446.
- [x] Spec 448.
- [x] Spec 450.
- [x] Spec 451.
- [x] Spec 452.
- [x] Spec 453.
- [x] Intune Capture.
- [x] Type Results.
- [x] Evidence.
- [x] Currentness.
- [x] OperationRun.
- [x] Baseline.
- [x] Compare.
- [x] Findings.
- [x] Customer Output.
- [x] Restore.
- [x] Certification.
- [x] Inventory.
- [x] RBAC/Scope.
## AP. Development and Activation
- [x] Development implementation complete.
- [x] Development merge readiness evaluated.
- [x] Production flags remain default-deny.
- [x] No live-provider claim.
- [x] No Staging claim.
- [x] No Production claim.
- [x] Later activation requirements documented.
- [x] Rollback boundary documented.
## AQ. Final Validation
- [x] Pint passes.
- [x] `git diff --check` passes.
- [x] Dirty State documented.
- [x] Scope diff documented.
- [x] No secret material in diff.
- [x] Requirements checklist synchronized.
- [x] Implementation Report complete.
- [x] Open in-scope findings: none.
## AR. Review Outcome
- [x] Preparation Surface review outcome: `acceptable-special-case` — the existing route uses exact native page/table taxonomy, explicit state owners, and no Product Surface exception.
- [x] Preparation test-governance workflow outcome: `keep` — named focused Unit/Feature/PostgreSQL/Heavy-Governance/Browser families reuse opt-in fixtures without widened defaults.
- [x] Final Surface review outcome is re-evaluated as exactly one of `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`; PASS requires `acceptable-special-case`.
- [x] Final test-governance workflow outcome is re-evaluated as exactly one of `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`; PASS requires `keep`.
- [x] Final note is recorded under `Guardrail / Exception / Smoke Coverage` in the Spec-454 implementation report and PR close-out.
Final Surface review outcome: `acceptable-special-case`.
Final test-governance workflow outcome: `keep`.
Final merge-readiness gate: `PASS`.

View File

@ -0,0 +1,472 @@
# Data Model and Runtime Contract — Spec 454
## 1. Decision Summary
Spec 454 adds no business table, column, provider-specific Evidence entity, or
OperationRun type.
The only schema change is one PostgreSQL unique index:
```text
(operation_run_id, resource_id)
```
The only new code-level shapes are:
```text
CoverageSourceContractDecision.commandContractKey
ExchangePowerShellBatchContinuation::{Continue, Stop}
ExchangeCoverageCaptureCohortPolicy
ExchangeCoverageCaptureConsumer
```
They do not create a second Source family, a generic consumer framework, a
persisted status axis, or a serializable batch contract.
The existing Capture Run Context gains only one safe one-way technical control
marker:
```text
exchange_powershell_batch.contract = exchange_powershell_capture.v1
exchange_powershell_batch.preflight_attempt_claimed_at = safe timestamp
```
It is atomically claimed under a locked parent Run after per-delivery
authorization/scope validation and before shared preflight. It contains no
business outcome, Credential, Permission/Runtime result, receipt, or provider
data. It records an attempted execution claim, not a successful preflight
receipt. A crash immediately after the claim can therefore leave every
preflight count at zero. A later delivery that acquires the overlap lock and
finds the existing claim performs reconciliation only and never runs shared
preflight or a provider command.
---
## 2. Existing Persistent Models
### OperationRun
Existing types only:
```text
tenant_configuration.capture
tenant_configuration.exchange_powershell_invocation
```
Expected topology for a fully successful all-`Continue` Capture:
```text
1 business Capture Run
3 technical Invocation Runs
```
A shared preflight blocker has one Capture Run and zero technical Invocation
Runs. No parent foreign key or new OperationRun type is added.
### TenantConfigurationCaptureTypeResult
Exactly three rows belong to the Exchange Capture Run, in canonical order:
```text
transportRule
remoteDomain
inboundConnector
```
Existing outcomes remain:
```text
success_with_items
success_empty
blocked
failed
partial
not_attempted
```
### TenantConfigurationResource
Canonical identity remains scoped by the existing fields for:
```text
workspace
managed environment
provider connection
resource type
canonical resource key
```
### TenantConfigurationResourceEvidence
The existing append-only provider-neutral Evidence model remains authoritative.
Its existing foreign keys are `operation_run_id` and `resource_id`; no alias
column is permitted.
For the exact Exchange append path, the explicit `ContentBacked` maximum is
applied before any Comparable/Renderable builder evaluation. Neither
`ExchangePowerShellComparablePayloadBuilder` nor
`ExchangeTeamsRenderableSummaryBuilder` is invoked for these rows.
---
## 3. Minimal Authority Contract
`CoverageTypeDefinition` gains only the fixed default-false Boolean
`internalCompareEligible`.
Rules:
```text
Exchange exact three:
INTERNAL_ONLY
OPERATOR_PRODUCTIZED
internalCompareEligible = false
existing eligible internal Entra definitions:
internalCompareEligible = true
Product-committed definitions:
existing Compare derivation unchanged
```
Capture, Baseline, Finding, publication, Restore, Certification, and Renderable
continue to use their existing authority and downstream guards. No additional
consumer slot or free-form capability map is introduced.
---
## 4. Active Source Decision Contract
`CaptureOutcome::Captured` is an outcome. It is not a Source-contract state.
The existing Source-state family gains exactly:
```text
contract_verified_capture_enabled
```
`CoverageSourceContractDecision` keeps its existing fields and gains only:
```text
commandContractKey: ?string
```
Executable variants:
```text
HTTP/Graph:
sourceEndpoint != null
commandContractKey = null
Exchange PowerShell:
sourceEndpoint = null
commandContractKey != null
```
`capturable()` is true only when:
```text
outcome = CaptureOutcome::Captured
contractKey is non-empty
sourceEndpoint XOR commandContractKey
```
Endpoint plus command key and neither descriptor are invalid. The exact three
Exchange types use the active state and their
`exchange_powershell.<canonicalType>` command keys. All other Exchange future
candidates remain unchanged.
This is Capture-planning truth only. Credential, permission, Runtime, and
feature/config readiness are evaluated later in the queued Gate preflight.
### Existing Evidence Storage Encoding
The existing Evidence table has a non-nullable column named
`source_endpoint`. It is not changed by Spec 454.
The writer maps the in-memory executable descriptor to that provenance slot:
```text
HTTP/Graph:
source_endpoint = real sourceEndpoint
Exchange command:
source_endpoint = ExchangePowerShellCommandContracts::SOURCE_SURFACE
+ ":" + allowlisted command_name
source_metadata.executable_descriptor_kind = "command"
source_metadata.command_contract_key = commandContractKey
```
The existing resulting
`exchange_online_powershell_rest:<allowlisted-command-name>` value is a typed
non-HTTP storage encoding, not a dummy endpoint.
It is never executed, rendered as an endpoint, or hydrated back into
`CoverageSourceContractDecision.sourceEndpoint`. No alias column or additional
migration is introduced.
---
## 5. Gate-Owned Lazy Batch Contract
The existing Gate owns the only batch entry point:
```php
ExchangePowerShellInvocationGate::consumeCaptureBatch(
OperationRun $parentCaptureRun,
User $actor,
ProviderConnection $connection,
ExchangeCoverageCaptureConsumer $consumer,
): void
```
The existing Spec-453 public `invoke()` contract remains available for one
isolated technical invocation. The Capture Job/consumer never loops over it;
the batch method reuses only a private per-command Gate seam so every shared
preflight step is attempted at most once per Capture Run. No third public
invocation path exists.
The Gate derives the canonical ordered three-command cohort internally. There
is no caller command list, arbitrary Closure, prepared-execution value, batch
request DTO, batch result DTO, or receipt array.
The Capture Job uses the existing Laravel `WithoutOverlapping` middleware with
a safe parent-Run key, `dontRelease()`, and expiry equal to its 300-second
timeout plus the configured queue lifecycle safety margin. This synchronization
lock contains no receipt, provider data, security result, or business outcome.
It prevents a duplicate delivery from racing persistent reconciliation against
the active claimant.
Shared preflight occurs only for the one-way attempted-execution claimant.
Each step occurs at most once across all deliveries and exactly once only when
an uninterrupted claimant reaches it:
```text
exact eligible certificate Credential reference selection
Credential resolution
Credential material accessibility
Permission Evidence evaluation
Runtime readiness evaluation
feature/config evaluation
```
The Gate resolves exactly one eligible certificate Credential reference for
the connection. A caller may not provide a Credential ID; zero or ambiguous
eligible references fail closed.
Every later parent-Run Context mutation reloads or locks the current row and
preserves the one-way claim marker; stale model state may not overwrite it.
After successful preflight, for each canonical type:
```text
1. Gate asks consumer to mark the type started.
2. Gate opens one technical Invocation Run and invokes one command.
3. Gate passes one ExchangePowerShellInvocationReceipt synchronously.
4. Consumer validates the complete collection.
5. Consumer persists/reconciles Resource and Evidence truth.
6. Consumer terminalizes the current Type Result.
7. Consumer returns Continue or Stop.
8. Gate drops the receipt/provider-collection reference.
9. Gate invokes the next command only after Continue.
```
`Stop` or consumer exception prevents every remaining command. Gate cleanup
always executes in `finally`.
A typed Stop returns normally after the consumer has terminalized the current
type. A callback exception is rethrown after Gate cleanup. In either case the
Job reloads persistent truth. For an exception, a still-nonterminal current
type becomes `failed` when durable Evidence is zero or `partial` when it is
positive; every remaining unstarted type becomes `not_attempted`. The Job then
summarizes from Type Results before following the existing safe failure/retry
lifecycle.
On a shared blocker, the Gate calls a bounded consumer method that terminalizes
all three Type Results as blocked with the same safe reason code. No technical
Invocation Run, provider call, or provider receipt exists.
---
## 6. Invocation Receipt and Continuation
The existing in-memory receipt may contain only its established typed
structured collection and safe invocation metadata. It must not implement or
enter:
```text
ShouldQueue
Serializable
JsonSerializable
Queue/Event payload
Cache
filesystem
OperationRun Context
log
```
The concrete consumer must not retain a receipt/provider collection in an
object property, static/global state, or escaping closure.
The only continuation values are:
```text
ExchangePowerShellBatchContinuation::Continue
ExchangePowerShellBatchContinuation::Stop
```
The enum is code-only and never persisted or displayed. After the Gate returns,
the Job reloads persistent Type Results and aggregates through the existing
summarizer.
---
## 7. Evidence Idempotency Migration
The migration is non-transactional and uses:
```sql
CREATE UNIQUE INDEX CONCURRENTLY
tenant_config_evidence_run_resource_unique
ON tenant_configuration_resource_evidence (operation_run_id, resource_id);
```
The table name is exactly
`tenant_configuration_resource_evidence`; the indexed columns and
deterministic index name are exact.
The normal SQLite test connection uses the same deterministic index name and
exact column tuple through plain `CREATE UNIQUE INDEX` / `DROP INDEX`. That
branch exists only so the repository's ordinary Unit/Feature migration cycle
remains executable; it is not Production authority and does not change
idempotency semantics. Any configured driver other than PostgreSQL or SQLite
fails closed.
Precondition:
```sql
SELECT operation_run_id, resource_id, COUNT(*)
FROM tenant_configuration_resource_evidence
GROUP BY operation_run_id, resource_id
HAVING COUNT(*) > 1;
```
Required result: zero rows. Any duplicate group is a stop condition; Spec 454
performs no cleanup or backfill.
Rollback drops only:
```sql
DROP INDEX CONCURRENTLY IF EXISTS
tenant_config_evidence_run_resource_unique;
```
The SQLite test branch drops that same index without the unsupported
`CONCURRENTLY` keyword.
---
## 8. Atomic Insert-or-Reuse
The conflict target is exactly:
```text
(operation_run_id, resource_id)
```
The writer uses
`INSERT ... ON CONFLICT (operation_run_id, resource_id) DO NOTHING` or a
repository-equivalent single PostgreSQL statement that leaves the transaction
usable. A catch-and-query sequence after a unique-violation abort is forbidden.
On a no-insert result, the writer reloads the winning row in the same usable
transaction and canonically compares every persisted immutable fact actually
present:
```text
resource_id
operation_run_id
workspace/environment/provider-connection/resource-type scope
Source contract key
executable Source descriptor
Source version
schema hash
canonical Source metadata
safe Permission context
raw_payload
normalized_payload
normalized payload contract
payload_hash
capture outcome
Coverage level
Evidence/claim state
```
Reuse occurs only when all persisted immutable facts are equal. Any difference
throws `evidence_idempotency_conflict`.
Database-generated `id`, `captured_at`, `created_at`, and `updated_at` are
winner-owned identity/chronology, not incoming equality inputs. Exact reuse
returns the existing row unchanged, preserves its original `captured_at`, and
does not rewrite the Resource latest-Evidence pointer.
`payload_hash` is necessary but not sufficient: the
`exchange-redacted-material-v1` hash is intentionally privacy-preserving, so a
protected exact-value change with unchanged presence/count can keep the same
hash. Therefore the same hash with different raw or normalized payload must
fail closed.
PostgreSQL race outcomes:
| Concurrent inputs | Result |
| --- | --- |
| Canonically identical | One row; both callers resolve it |
| Different hash/provenance | One row; losing caller receives typed conflict |
| Same lossy hash, different immutable payload | One row; losing caller receives typed conflict |
---
## 9. Type Result and Retry Lifecycle
The starter creates exactly three provisional rows before Queue dispatch.
The Gate asks the consumer to mark a type started immediately before that
type's invocation. The consumer terminalizes the type before returning a
continuation decision.
After `Stop` or a fatal exception:
```text
current type = failed or partial according to durable Evidence count
remaining unstarted types = not_attempted
```
Terminal rows are immutable. Queue retry reuses the same Capture Run,
provisional Type Results, and exact Evidence identity. If Evidence is durable
but its result remains provisional, retry may reconcile it. A terminal partial
requires a new operator-started Capture.
---
## 10. Claims, Restore, Currentness, and UI Projection
Exchange Resource claim state remains:
```text
internal_only
```
All three types are `NotRestorable`; `remoteDomain` must not remain
`PreviewOnly`.
The exact Currentness key is:
```text
tenantpilot.coverage_v2.exchange_powershell_currentness_hours = 24
```
There is no Intune TTL fallback. Currentness derives only from persistent
successful Type Results.
The provider/workload-scoped ReadModel carries no Raw Payload, receipt,
Context-outcome fallback, or provider call. UI-102 maps its internal outcomes
to the existing canonical Product Surface vocabulary and keeps technical
identifiers/links inside the closed-by-default Technical Annex.

View File

@ -0,0 +1,315 @@
# Implementation Report: Exchange PowerShell Capture/Evidence Runtime Consumer
## Preflight
- **Active spec**:
`specs/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer/`
- **Declared base/target**: `platform-dev` / `platform-dev`
- **Feature branch**:
`feat/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer`
- **Session branch**:
`feat/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer-session-1785081354`
- **Prepared implementation baseline**:
`4ee90b793c720c7496c7dd967fe2300ed2fec325`
(`spec: record Spec 454 product sanity result`). The baseline includes all
bounded preparation corrections discovered before implementation close-out;
it contains no Spec-454 Runtime implementation.
- **Origin `platform-dev`**:
`464a07168d87b7f759c4c698107cab9e079e8318`
- **Ahead/behind at implementation start**: 3 ahead, 0 behind.
- **Initial dirty state**: clean.
- **Initial `git diff --check`**: PASS.
## Activated Skills And Hard Gates
- `spec-kit-implementation-loop`: requested implementation, validation,
post-implementation analysis, and bounded remediation loop.
- `spec-readiness-gate`: active package, prerequisite, and cross-artifact
readiness.
- `temporary-tcm-cutover-guard`: Coverage V2 and legacy-write cutover boundary.
- `workspace-scope-safety`: exact Workspace and Managed Environment ownership.
- `rbac-action-safety`: canonical capability, 404/403, and action safety.
- `operation-run-truth`: canonical start/reuse/block and terminal lifecycle.
- `evidence-anchor-contract`: evidence immutability, provenance, and idempotency.
- `provider-freshness-semantics`: Exchange source-currentness semantics.
- `product-surface-gate`: existing-route Product Surface budgets and state
ownership.
- `filament-livewire-v5-change-loop`: Filament v5 and Livewire v4 page/action
constraints.
- `pest-testing`: Pest 4 test-first and regression proof.
- `tailwindcss-development`: Tailwind CSS v4 constraints for UI-affecting work.
- `browsertest`: focused rendered-route smoke and Human Product Sanity proof.
## Readiness And Scope Gate
- Specs 452 and 453 are integrated in the `platform-dev` baseline.
- The Coverage Completion Report passed with zero invariant violations.
- Spec 438 remains deferred and has no Runtime consumer.
- The prepared baseline contains only active Spec-454 preparation/close-out
artifacts; no Spec-454 Runtime implementation is present.
- The exact Path Contract was parsed and a known permitted Runtime path was
accepted.
- Synthetic path
`apps/platform/app/Services/TenantConfiguration/ForbiddenSpec454ScopeExpansion.php`
was rejected by the changed-path guard.
- The working tree was clean after the dedicated correction commit.
- **Readiness Gate**: PASS.
- **Scope Gate**: PASS.
## Baseline Validation
- Command:
`./scripts/platform-test-lane fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request`
- Result: PASS, exit 0.
- Tests: 3,031 passed.
- Assertions: 21,895.
- Skips: 0.
- Pest runtime: 116.79 seconds.
- Wall-clock runtime: 123.85 seconds.
- Approved 215-second hard limit: PASS.
## Implementation Status
Development implementation and bounded remediation are complete. All required
proof is green and no confirmed in-scope finding remains.
### Authority And Source Contract
- Exactly `transportRule`, `remoteDomain`, and `inboundConnector` are
`INTERNAL_ONLY` + `OPERATOR_PRODUCTIZED` +
`INTERNAL_OPERATOR_VISIBLE`.
- The single fixed `internalCompareEligible` flag is false for those three and
true only for the two existing internal Entra Compare definitions.
- Product-committed Compare derivation is unchanged.
- The exact three Source decisions use
`contract_verified_capture_enabled`, a null HTTP endpoint, and one exact
`exchange_powershell.<canonicalType>` command descriptor.
- Existing HTTP decisions retain one endpoint and no command descriptor.
- Both/neither descriptor combinations fail `capturable()` closed.
- The eight bounded prior-spec regression files retain command safety,
no-Graph, no-live-execution, identity, redaction, and excluded-type proof.
- Three exact Spec-452 authority-derived expectations now record
`INTERNAL_ONLY=5`, `FUTURE_PRODUCT_CANDIDATE=26`, and the five-type internal
Capture cohort without changing Product, Compare, Finding, or Customer truth.
### Cohort And Trusted Starter
- `ExchangeCoverageCaptureCohortPolicy` derives the ordered exact three-type
cohort from internal Capture authority, the active Source decisions, and the
existing command registry; its immutable identifier is
`exchange_powershell_capture.v1`.
- `StartTenantConfigurationCapture` accepts typed workload/cohort input,
revalidates the persisted three-type cohort, exact same-scope enabled
Microsoft connection, `evidence.manage`, and `provider.run`.
- One parent Capture Run, three provisional Type Results, one existing audit
start row, and one queue job are created. No technical invocation Run,
provider call, or Evidence write occurs at starter time.
- Run identity now includes workload, provider connection, cohort, and types;
duplicate Exchange starts reuse one run while Intune and Exchange do not
collide.
- Focused cohort/starter proof: PASS.
### Runtime Batch, OperationRuns, And Type Results
- The existing Capture job dispatches Exchange execution only when the trusted
Run carries workload `exchange`, cohort
`exchange_powershell_capture.v1`, and the exact persisted three-type plan.
- Parent-Run `WithoutOverlapping` plus a one-way locked
`preflight_attempt_claimed_at` marker makes shared Credential, Permission,
and Runtime preflight at-most-once across deliveries.
- The Gate derives the fixed command order, delivers one non-serializable
receipt synchronously, drops it in `finally`, and advances only on the typed
`Continue` value. `Stop` and exceptions prevent later commands.
- One parent `tenant_configuration.capture` Run remains canonical. Technical
invocation Runs reuse the existing type and trusted starter; no new
OperationRun type or lifecycle owner exists.
- Each Type Result is durably terminal before the next command. Job summary and
failures are rebuilt only from persistent Type Results. Fatal and redelivery
paths reconcile unfinished rows without Context outcome fallback.
- The generic HTTP Capture consumer rejects command-backed decisions before
Type Result mutation or Provider Gateway use; malformed HTTP resolution
retains its existing started/failed lifecycle.
### Evidence, Identity, And Migration
- Stable identity is validated for the complete collection before any write.
Names, domains, hosts, IPs, certificates, priority, and ordering never become
fallback identity.
- The three normalizers are deterministic, remove volatile/protected values,
and build privacy-preserving material markers. The 1,000-item operation
counter proves a bounded O(N) identity pass; the architecture guard rejects
a nested full-collection scan.
- Evidence remains provider-neutral, Content-backed, internal-only, immutable,
and owned by the parent Capture Run. Raw stdout/stderr and protected
credential/provider material are never persisted or rendered.
- Migration
`2026_07_26_000454_add_tenant_configuration_evidence_idempotency_unique_index.php`
adds only the unique `(operation_run_id, resource_id)` index. PostgreSQL uses
`CREATE/DROP INDEX CONCURRENTLY` outside a transaction; SQLite is a test-only
equivalent. Unknown drivers and duplicate prechecks fail closed.
- Atomic insert-or-reuse compares every immutable persisted fact. Canonically
exact races converge on one row; hash/payload/provenance/scope conflicts fail
without overwriting or moving latest pointers.
### Currentness And Downstream Isolation
- Exchange has an independent exact 24-hour Currentness setting. Last Attempt
and Last Successful Result remain separate, so a later failure cannot erase
retained prior success.
- Baseline, Compare, Finding, Customer Output, Restore, Certification,
Renderable summary, Inventory, PolicyVersion, Legacy, Graph fallback, and
Context business truth remain unreachable for the exact three rows.
- Spec 438 remains deferred and has no Runtime consumer.
## Product Surface Close-Out
- **Runtime UI files changed**: yes, only the existing UI-102 Coverage-v2 page,
its existing widgets/modals, localization, and native Filament composition.
- **Product Surface Impact / UI Surface Impact**: yes / yes, bounded to the
existing route; no new route, navigation item, panel, page, or frontend
framework.
- **No-legacy posture**: confirmed; exception `none`.
- **Surface taxonomy**: native `Monitoring / Queue / Workbench` +
`Detail-first Operational Surface`; embedded tables remain native
`List / Table / Bulk` + `Read-only Registry / Report Surface`.
- **State ownership**: Workspace/Environment remain shell-owned Global
Context; the page owns the validated active workload and draft connection;
inspect/modal and the closed Technical Annex are subordinate detail state.
- **Canonical vocabulary**: Ready, Running, Not configured, Blocked, Failed,
Needs attention, Expired, Unknown, and Historical. Internal status strings
remain inside the capability-gated Annex.
- **Visible complexity outcome**: `not worse`. Default Exchange proof contains
one visible table, three rows, four primary metrics, zero secondary actions,
zero technical IDs/links, and a closed Technical Annex.
- **Product Surface exceptions**: `none`.
- **Surface review outcome**: `acceptable-special-case`.
- **Test-governance workflow outcome**: `keep`.
- **Human Product Sanity**: PASS for purpose, provider distinction, exact
denominator, outcome/readiness distinction, one dominant action,
technical-detail demotion, trust, responsiveness, and no customer/compliance
overclaim.
- Focused screenshot:
`artifacts/screenshots/ui-102-exchange-default-hps.png`;
SHA-256
`341198e45ae701a8f36e36a2b6db9540505873975cf6c827b292b988b1be4386`.
### Browser Proof
- Sail Browser command:
`./vendor/bin/sail artisan test tests/Browser --compact --filter=Spec454Exchange`
— PASS, 6 tests, 94 assertions.
- Integrated Browser proof covered the exact Exchange default, one type modal,
Annex keyboard open/close, refresh/query restoration, 390x844 responsive
layout, and draft/detail state ownership.
- Default Exchange DOM: two workload tabs, one active table, three rows, four
metrics, zero secondary actions, zero technical IDs/links, closed Annex, and
no combined `x/9`.
- One initial modal finding exposed the stale Intune scope label. The ReadModel
and localization were corrected to `Exchange Online capture`, included
cohort wording, and `exchange_powershell_capture.v1`; focused Feature and
Browser regressions pass.
- Final console/Livewire errors: none. Failed requests: zero. HTTP 4xx/5xx
responses during the successful smoke: zero. No horizontal mobile overflow.
## Filament v5 / Livewire v4 Output Contract
1. **Livewire**: compliant with Livewire v4; no v3 API or reference introduced.
2. **Provider registration**: unchanged at
`apps/platform/bootstrap/providers.php`; no panel provider change.
3. **Global search**: no Resource was added or changed; Global Search posture
is unchanged, so no new Edit/View page requirement arises.
4. **Actions**: no destructive action exists. `Capture Exchange coverage` is a
high-impact, read-only-provider action implemented with
`Action::make(...)->action(...)`, confirmation, `evidence.manage` and
`provider.run` server authorization, scope revalidation, and the existing
Capture audit/OperationRun lifecycle.
5. **Assets**: no asset was added or registered, globally or on-demand.
Spec 454 adds no new `filament:assets` need; the existing deployment step
`cd apps/platform && php artisan filament:assets` remains unchanged.
6. **Testing**: the page action, provider separation, widgets/read models,
type/resource inspect, state outcomes, RBAC/scope, technical Annex, and
downstream-isolation surfaces have focused Feature/Livewire/Browser proof.
## Validation Evidence
| Gate | Result |
| --- | --- |
| T074T076 PostgreSQL migration/race/scope | PASS — combined 43 tests, 210 assertions, 17.13 s |
| T077 focused Unit | PASS — 52 tests, 207 assertions, 8.22 s |
| T078 focused Feature/Livewire | PASS — 59 tests, 358 assertions, 30.95 s |
| T079 architecture/path guard | PASS — 10 tests, 63 assertions, 3.65 s |
| T080T088 focused Browser | PASS — 6 tests, 94 assertions, 21.35 s |
| T089 Specs 420/427/430437 | PASS — 357 tests, 2,314 assertions, 71.50 s |
| T090 Specs 446/448/450453 | PASS — 433 passed, 70 skipped, 3,747 assertions, 209.02 s |
| T091 downstream non-regression | PASS — 16 tests, 130 assertions, 5.25 s |
| T092 Fast Feedback | PASS — 3,093 tests, 22,186 assertions; Pest 131.13 s; wall 135.45 s; effective limit 215 s |
| T093 Pint | PASS twice; second run produced no additional mutation |
| T094 diff/path guards | PASS — no staged files before implementation commit, all changed paths enumerated, no completed Spec or historical migration changed |
| T095 Speckit post-analysis | PASS — 114/114 requirements mapped, 99/99 task IDs present, no placeholders, no Critical/High finding |
Fast Feedback recorded zero skips. No Spec-454 file appears among the ten
slowest entries; the slowest lane entry is the unrelated existing
`Spec322LegacyQueryAliasGuardTest.php` at 8.61 seconds. The lane report records
64.55 seconds of headroom against its 200-second measured-suite budget and
79.55 seconds against the effective 215-second PR hard limit.
## Bounded Remediation Loop
- T089 exposed one superseded Spec-430 generic Capture expectation and one
stale Spec-420 Browser fixture/title. The active Path Contract was corrected;
command-backed generic fallback now fails closed, and the Browser regression
uses the exact current Intune cohort while preserving all safety assertions.
- T090 exposed three intended Spec-452 authority-count/cohort updates plus real
regressions in generic resolver failure ordering, Trusted Starter call-site
duplication, and a replaced stable Browser selector. The active Path
Contract was corrected where expectations changed; all Runtime regressions
were fixed without widening scope.
- Post-analysis found only two close-out tracking items: final HPS state and
task/checklist completion. Both are resolved in the active artifacts.
- **Open confirmed in-scope findings**: none.
## Deployment And Activation
- **Environment variables**: none added. Existing Exchange invocation and
production-runner feature gates remain default-deny.
- **Database**: one non-transactional concurrent unique-index migration.
Staging must run the duplicate precheck and migration before Production.
- **Queue**: the existing Capture worker executes the in-process Exchange
batch; no new queue, worker, event, cache payload, or scheduler is required.
- **Scheduler/cron**: unchanged.
- **Storage/volumes**: unchanged; no receipt, stdout/stderr, or provider
collection storage is added.
- **Assets**: no new assets; existing Filament asset deployment remains
unchanged.
- **Rollback**: disable the existing feature gates, pause Capture writers,
execute the migration rollback (concurrent drop of only the Spec-454 index),
deploy the previous application version, then resume writers. Staging is the
mandatory validation gate.
- **Claims**: Development proof only. No live-provider, Staging, or Production
activation claim is made.
## Guardrail / Exception / Smoke Coverage
- **Guardrail**: exact authority/cohort, Source XOR, generic no-fallback,
parent-Run preflight CAS, receipt non-retention, Type Result lifecycle,
Evidence uniqueness/immutability, provider/workspace/environment RBAC, and
downstream no-overclaim guards are executable.
- **Exception**: none; Product Surface exception `none`, OperationRun UX
exception `none`, and no legacy exception.
- **Smoke Coverage**: focused Pest Browser plus Integrated Browser desktop,
mobile, keyboard, modal, state restoration, console, Livewire, and network
proof on UI-102.
## Repository And Merge Readiness
- Session branch remains isolated:
`feat/454-coverage-v2-exchange-powershell-capture-evidence-runtime-consumer-session-1785081354`.
- Prepared baseline: `4ee90b793c720c7496c7dd967fe2300ed2fec325`.
- Implementation commit: `befc151d` (`feat: activate Exchange coverage evidence capture`).
- Historical migrations changed: none.
- Completed historical Specs changed: none.
- `apps/platform/bootstrap/providers.php`: unchanged.
- Provider registration, global search, scheduler, and asset registration:
unchanged.
- Secret-material diff scan: PASS; only test-only sentinel strings appear.
- **Final gate**: PASS.