feat: normalize provider connection scope contracts
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m28s

This commit is contained in:
Ahmed Darrazi 2026-05-07 21:27:15 +02:00
parent 2952e5ad3e
commit 19132dc433
51 changed files with 2804 additions and 205 deletions

View File

@ -93,6 +93,21 @@ public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?
return parent::getUrl($parameters, $isAbsolute, $panel ?? 'tenant', $tenant, $shouldGuessMissingParameters);
}
/**
* @return array<class-string<Widget> | WidgetConfiguration>
*/
protected function getHeaderWidgets(): array
{
return [
TenantDashboardContextChips::class,
];
}
public function getHeaderWidgetsColumns(): int|array
{
return 1;
}
/**
* @return array<class-string<Widget> | WidgetConfiguration>
*/
@ -100,7 +115,6 @@ public function getWidgets(): array
{
return [
TenantTriageArrivalContinuity::class,
TenantDashboardContextChips::class,
DashboardKpis::class,
TenantDashboardOverview::class,
];

View File

@ -1473,16 +1473,16 @@ private function readinessProviderSummary(?ProviderConnection $connection): ?arr
'verification_state' => $this->stringValue($connection->verification_status),
'readiness_summary' => 'Target scope needs review',
'target_scope_summary' => 'Target scope needs review',
'provider_context' => [
'provider' => (string) $connection->provider,
'details' => [],
],
'contextual_identity_line' => null,
'is_enabled' => (bool) $connection->is_enabled,
];
}
return array_merge($summary->toArray(), [
'target_scope_summary' => $summary->targetScopeSummary(),
'contextual_identity_line' => $summary->contextualIdentityLine(),
'is_enabled' => (bool) $connection->is_enabled,
]);
return $summary->toArray();
}
/**
@ -2658,7 +2658,10 @@ private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $
'shared_label' => 'Target scope',
'shared_help_text' => 'The platform scope this provider connection represents.',
],
'provider_identity_context' => [],
'provider_context' => [
'provider' => (string) $connection->provider,
'details' => [],
],
], $extra);
}
}

View File

@ -1657,6 +1657,21 @@ private static function targetScopeDisplay(OperationRun $record): ?string
return null;
}
$scopeDisplayName = $targetScope['scope_display_name'] ?? null;
$scopeIdentifier = $targetScope['scope_identifier'] ?? null;
$scopeDisplayName = is_string($scopeDisplayName) ? trim($scopeDisplayName) : null;
$scopeIdentifier = is_string($scopeIdentifier) ? trim($scopeIdentifier) : null;
if ($scopeDisplayName !== null && $scopeDisplayName !== '') {
return $scopeIdentifier !== null && $scopeIdentifier !== '' && $scopeIdentifier !== $scopeDisplayName
? "{$scopeDisplayName} ({$scopeIdentifier})"
: $scopeDisplayName;
}
if ($scopeIdentifier !== null && $scopeIdentifier !== '') {
return $scopeIdentifier;
}
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
$directoryContextId = $targetScope['directory_context_id'] ?? null;

View File

@ -506,7 +506,7 @@ private static function targetScopeSummary(?ProviderConnection $record): string
}
}
private static function providerIdentityContext(?ProviderConnection $record): ?string
private static function providerContextSummary(?ProviderConnection $record): ?string
{
if (! $record instanceof ProviderConnection) {
return null;
@ -539,7 +539,10 @@ public static function targetScopeAuditMetadata(ProviderConnection $record, arra
'shared_label' => 'Target scope',
'shared_help_text' => static::targetScopeHelpText(),
],
'provider_identity_context' => [],
'provider_context' => [
'provider' => (string) $record->provider,
'details' => [],
],
], $extra);
}
}
@ -681,9 +684,9 @@ public static function infolist(Schema $schema): Schema
->label('Migration review')
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
Infolists\Components\TextEntry::make('provider_identity_context')
->label('Provider identity details')
->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record))
Infolists\Components\TextEntry::make('provider_context')
->label('Provider context')
->state(fn (ProviderConnection $record): ?string => static::providerContextSummary($record))
->placeholder('n/a')
->columnSpanFull(),
Infolists\Components\TextEntry::make('last_error_reason_code')

View File

@ -62,6 +62,7 @@
use App\Support\Tenants\TenantOperabilityOutcome;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -3361,7 +3362,8 @@ public static function adminConsentUrl(ManagedEnvironment $tenant): ?string
* consent_status:?string,
* verification_status:?string,
* last_health_check_at:?string,
* last_error_reason_code:?string
* last_error_reason_code:?string,
* target_scope_summary:?string
* }
*/
private static function providerConnectionState(ManagedEnvironment $tenant): array
@ -3396,9 +3398,16 @@ private static function providerConnectionState(ManagedEnvironment $tenant): arr
'verification_status' => null,
'last_health_check_at' => null,
'last_error_reason_code' => null,
'target_scope_summary' => null,
];
}
try {
$targetScopeSummary = ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary();
} catch (\InvalidArgumentException) {
$targetScopeSummary = 'Target scope needs review';
}
return [
'state' => $connection->is_default ? 'default_configured' : 'configured',
'cta_url' => $ctaUrl,
@ -3415,6 +3424,7 @@ private static function providerConnectionState(ManagedEnvironment $tenant): arr
: (is_string($connection->verification_status) ? $connection->verification_status : null),
'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(),
'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null,
'target_scope_summary' => $targetScopeSummary,
];
}

View File

@ -14,6 +14,7 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -136,16 +137,19 @@ private function resolveEntraTenantName(ProviderConnection $connection, Provider
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
{
$context = is_array($run->context) ? $run->context : [];
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
$targetScope = $normalizer->descriptorForConnection($connection)->toArray();
if (is_string($entraTenantName) && $entraTenantName !== '') {
$targetScope['entra_tenant_name'] = $entraTenantName;
$targetScope['scope_display_name'] = $entraTenantName;
}
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
$context['target_scope'] = $targetScope;
$context['provider_context'] = $normalizer->providerContext(
provider: (string) $connection->provider,
details: $normalizer->contextualIdentityDetailsForConnection($connection),
);
$run->update(['context' => $context]);
}

View File

@ -23,6 +23,7 @@
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Verification\TenantPermissionCheckClusters;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Bus\Queueable;
@ -178,6 +179,9 @@ public function handle(
}
$permissionChecks = TenantPermissionCheckClusters::buildChecks($tenant, $permissionRows, $inventory);
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)
->descriptorForConnection($connection)
->toArray();
$report = VerificationReportWriter::write(
run: $this->operationRun,
@ -196,8 +200,8 @@ public function handle(
'value' => (int) $connection->getKey(),
],
[
'kind' => 'entra_tenant_id',
'value' => (string) $connection->entra_tenant_id,
'kind' => 'target_scope_identifier',
'value' => (string) ($targetScope['scope_identifier'] ?? $connection->entra_tenant_id),
],
is_numeric($result->meta['http_status'] ?? null) ? [
'kind' => 'http_status',
@ -224,7 +228,7 @@ public function handle(
],
identity: [
'provider_connection_id' => (int) $connection->getKey(),
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'target_scope' => $targetScope,
],
);
@ -360,17 +364,20 @@ private function resolveEntraTenantName(ProviderConnection $connection, HealthRe
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
{
$context = is_array($run->context) ? $run->context : [];
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
$targetScope = $normalizer->descriptorForConnection($connection)->toArray();
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
$targetScope['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
if (is_string($entraTenantName) && $entraTenantName !== '') {
$targetScope['entra_tenant_name'] = $entraTenantName;
$targetScope['scope_display_name'] = $entraTenantName;
}
$context['target_scope'] = $targetScope;
$context['provider_context'] = $normalizer->providerContext(
provider: (string) $connection->provider,
details: $normalizer->contextualIdentityDetailsForConnection($connection),
);
$run->update(['context' => $context]);
}
@ -453,9 +460,9 @@ private function logVerificationResult(
'credential_source' => $identity->credentialSource,
'effective_client_id' => $identity->effectiveClientId,
'target_scope' => $identity->targetScope?->toArray(),
'provider_identity_context' => array_map(
'provider_context' => array_map(
static fn ($detail): array => $detail->toArray(),
$identity->contextualIdentityDetails,
$identity->providerContextDetails,
),
'reason_code' => $reasonCode,
'operation_run_id' => (int) $run->getKey(),

View File

@ -14,6 +14,7 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -136,16 +137,19 @@ private function resolveEntraTenantName(ProviderConnection $connection, Provider
private function updateRunTargetScope(OperationRun $run, ProviderConnection $connection, ?string $entraTenantName): void
{
$context = is_array($run->context) ? $run->context : [];
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$targetScope['entra_tenant_id'] = $connection->entra_tenant_id;
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
$targetScope = $normalizer->descriptorForConnection($connection)->toArray();
if (is_string($entraTenantName) && $entraTenantName !== '') {
$targetScope['entra_tenant_name'] = $entraTenantName;
$targetScope['scope_display_name'] = $entraTenantName;
}
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
$context['target_scope'] = $targetScope;
$context['provider_context'] = $normalizer->providerContext(
provider: (string) $connection->provider,
details: $normalizer->contextualIdentityDetailsForConnection($connection),
);
$run->update(['context' => $context]);
}

View File

@ -25,7 +25,7 @@ public function make(ProviderConnection $connection, string $state): string
throw new RuntimeException($resolution->message ?? 'Provider identity could not be resolved for admin consent.');
}
$tenantSegment = trim($resolution->tenantContext) !== '' ? trim($resolution->tenantContext) : 'organizations';
$tenantSegment = $resolution->targetScopeIdentifier('organizations') ?? 'organizations';
return "https://login.microsoftonline.com/{$tenantSegment}/v2.0/adminconsent?".http_build_query([
'client_id' => $resolution->effectiveClientId,

View File

@ -44,10 +44,14 @@ public function getClientCredentials(ProviderConnection $connection): array
throw new RuntimeException('Provider credential payload is missing required keys.');
}
$tenantId = $payload['managed_environment_id'] ?? null;
$targetScopeIdentifier = $payload['managed_environment_id'] ?? null;
if (is_string($tenantId) && $tenantId !== '' && $tenantId !== $connection->entra_tenant_id) {
throw new InvalidArgumentException('Provider credential managed_environment_id does not match the connection entra_tenant_id.');
if (
is_string($targetScopeIdentifier)
&& $targetScopeIdentifier !== ''
&& $targetScopeIdentifier !== $connection->entra_tenant_id
) {
throw new InvalidArgumentException('Provider credential target scope does not match the connection target scope.');
}
return [

View File

@ -10,70 +10,70 @@
final class PlatformProviderIdentityResolver
{
/**
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
*/
public function resolve(
string $tenantContext,
string $targetScopeIdentifier,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
array $providerContextDetails = [],
): ProviderIdentityResolution {
$targetTenant = trim($tenantContext);
$targetScopeIdentifier = trim($targetScopeIdentifier);
$clientId = trim((string) config('graph.client_id'));
$clientSecret = trim((string) config('graph.client_secret'));
$authorityTenant = trim((string) config('graph.managed_environment_id', 'organizations'));
$redirectUri = trim((string) route('admin.consent.callback'));
if ($targetTenant === '') {
if ($targetScopeIdentifier === '') {
return ProviderIdentityResolution::blocked(
connectionType: ProviderConnectionType::Platform,
tenantContext: 'organizations',
credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
message: 'Provider connection is missing target tenant scope.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
$targetScope ??= ProviderConnectionTargetScopeDescriptor::fromInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: $targetScopeIdentifier,
);
if ($clientId === '') {
return ProviderIdentityResolution::blocked(
connectionType: ProviderConnectionType::Platform,
tenantContext: $targetTenant,
credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::PlatformIdentityMissing,
message: 'Platform app identity is not configured.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
if ($clientSecret === '' || $redirectUri === '') {
return ProviderIdentityResolution::blocked(
connectionType: ProviderConnectionType::Platform,
tenantContext: $targetTenant,
credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete,
message: 'Platform app identity is incomplete.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
return ProviderIdentityResolution::resolved(
connectionType: ProviderConnectionType::Platform,
tenantContext: $targetTenant,
targetScope: $targetScope,
effectiveClientId: $clientId,
credentialSource: 'platform_config',
clientSecret: $clientSecret,
authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations',
redirectUri: $redirectUri,
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails !== []
? array_values(array_merge($contextualIdentityDetails, array_filter([
ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'),
ProviderIdentityContextMetadata::redirectUri($redirectUri),
])))
: [],
providerContextDetails: array_values(array_merge($providerContextDetails, array_filter([
ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'),
ProviderIdentityContextMetadata::redirectUri($redirectUri),
]))),
);
}
}

View File

@ -62,7 +62,7 @@ public function graphOptions(ProviderConnection $connection, array $overrides =
}
return array_merge([
'tenant' => $resolution->tenantContext,
'tenant' => $resolution->targetScopeIdentifier('organizations'),
'client_id' => $resolution->effectiveClientId,
'client_secret' => $resolution->clientSecret,
'client_request_id' => (string) Str::uuid(),

View File

@ -10,12 +10,12 @@
final class ProviderIdentityResolution
{
/**
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
*/
private function __construct(
public readonly bool $resolved,
public readonly ProviderConnectionType $connectionType,
public readonly string $tenantContext,
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
public readonly ?string $effectiveClientId,
public readonly string $credentialSource,
public readonly ?string $clientSecret,
@ -23,25 +23,26 @@ private function __construct(
public readonly ?string $redirectUri,
public readonly ?string $reasonCode,
public readonly ?string $message,
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
public readonly array $contextualIdentityDetails,
public readonly array $providerContextDetails,
) {}
/**
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
*/
public static function resolved(
ProviderConnectionType $connectionType,
string $tenantContext,
ProviderConnectionTargetScopeDescriptor $targetScope,
string $effectiveClientId,
string $credentialSource,
?string $clientSecret,
?string $authorityTenant,
?string $redirectUri,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
array $providerContextDetails = [],
): self {
return new self(
resolved: true,
connectionType: $connectionType,
tenantContext: $tenantContext,
targetScope: $targetScope,
effectiveClientId: $effectiveClientId,
credentialSource: $credentialSource,
clientSecret: $clientSecret,
@ -49,26 +50,25 @@ public static function resolved(
redirectUri: $redirectUri,
reasonCode: null,
message: null,
targetScope: $targetScope ?? self::targetScopeFromContext($tenantContext),
contextualIdentityDetails: $contextualIdentityDetails !== []
? $contextualIdentityDetails
: self::contextualIdentityDetails($tenantContext, $authorityTenant, $redirectUri),
providerContextDetails: $providerContextDetails,
);
}
/**
* @param list<ProviderIdentityContextMetadata> $providerContextDetails
*/
public static function blocked(
ProviderConnectionType $connectionType,
string $tenantContext,
string $credentialSource,
string $reasonCode,
?string $message = null,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
array $providerContextDetails = [],
): self {
return new self(
resolved: false,
connectionType: $connectionType,
tenantContext: $tenantContext,
targetScope: $targetScope,
effectiveClientId: null,
credentialSource: $credentialSource,
clientSecret: null,
@ -76,10 +76,7 @@ public static function blocked(
redirectUri: null,
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
message: $message,
targetScope: $targetScope ?? (trim($tenantContext) !== '' ? self::targetScopeFromContext($tenantContext) : null),
contextualIdentityDetails: $contextualIdentityDetails !== []
? $contextualIdentityDetails
: self::contextualIdentityDetails($tenantContext),
providerContextDetails: $providerContextDetails,
);
}
@ -88,35 +85,51 @@ public function effectiveReasonCode(): string
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
}
private static function targetScopeFromContext(string $tenantContext): ProviderConnectionTargetScopeDescriptor
public function targetScopeIdentifier(?string $fallback = null): ?string
{
$identifier = trim($tenantContext) !== '' ? trim($tenantContext) : 'organizations';
$identifier = trim((string) $this->targetScope?->scopeIdentifier);
return ProviderConnectionTargetScopeDescriptor::fromInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: $identifier,
scopeDisplayName: $identifier,
);
if ($identifier !== '') {
return $identifier;
}
return $fallback;
}
/**
* @return list<ProviderIdentityContextMetadata>
* @return array{client_id: ?string, credential_source: string}
*/
private static function contextualIdentityDetails(
string $tenantContext,
?string $authorityTenant = null,
?string $redirectUri = null,
): array {
$details = [
ProviderIdentityContextMetadata::microsoftTenantId($tenantContext),
ProviderIdentityContextMetadata::authorityTenant($authorityTenant),
ProviderIdentityContextMetadata::redirectUri($redirectUri),
public function effectiveClientIdentity(): array
{
return [
'client_id' => $this->effectiveClientId,
'credential_source' => $this->credentialSource,
];
}
return array_values(array_filter(
$details,
static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata,
));
/**
* @return array{provider: string, details: list<array<string, string>>}
*/
public function providerContext(): array
{
$provider = $this->targetScope?->provider;
if (! is_string($provider) || trim($provider) === '') {
foreach ($this->providerContextDetails as $detail) {
if (trim($detail->provider) !== '') {
$provider = $detail->provider;
break;
}
}
}
return [
'provider' => is_string($provider) && trim($provider) !== '' ? trim($provider) : 'unknown',
'details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$this->providerContextDetails,
),
];
}
}

View File

@ -22,61 +22,58 @@ public function __construct(
public function resolve(ProviderConnection $connection): ProviderIdentityResolution
{
$tenantContext = trim((string) $connection->entra_tenant_id);
$targetScopeIdentifier = trim((string) $connection->entra_tenant_id);
$connectionType = $this->resolveConnectionType($connection);
$targetScopeResult = $this->targetScopeNormalizer->normalizeConnection($connection);
$targetScope = $targetScopeResult['target_scope'] ?? null;
$contextualIdentityDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection);
$providerContextDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection);
if ($connectionType === null) {
return ProviderIdentityResolution::blocked(
connectionType: ProviderConnectionType::Platform,
tenantContext: $tenantContext !== '' ? $tenantContext : 'organizations',
credentialSource: 'unknown',
reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid,
message: 'Provider connection type is invalid.',
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
if ($targetScopeResult['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
return ProviderIdentityResolution::blocked(
connectionType: $connectionType,
tenantContext: 'organizations',
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value,
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
message: $targetScopeResult['message'] ?? 'Provider connection target scope is invalid.',
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
if ((bool) $connection->migration_review_required) {
return ProviderIdentityResolution::blocked(
connectionType: $connectionType,
tenantContext: $tenantContext,
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value,
reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired,
message: 'Provider connection requires migration review before use.',
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
if ($connectionType === ProviderConnectionType::Platform) {
return $this->platformResolver->resolve(
tenantContext: $tenantContext,
targetScopeIdentifier: $targetScopeIdentifier,
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
return $this->resolveDedicatedIdentity(
connection: $connection,
tenantContext: $tenantContext,
targetScopeIdentifier: $targetScopeIdentifier,
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
@ -97,36 +94,42 @@ private function resolveConnectionType(ProviderConnection $connection): ?Provide
private function resolveDedicatedIdentity(
ProviderConnection $connection,
string $tenantContext,
string $targetScopeIdentifier,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
array $providerContextDetails = [],
): ProviderIdentityResolution {
try {
$credentials = $this->credentials->getClientCredentials($connection);
} catch (InvalidArgumentException|RuntimeException $exception) {
return ProviderIdentityResolution::blocked(
connectionType: ProviderConnectionType::Dedicated,
tenantContext: $tenantContext,
credentialSource: $this->credentialSource($connection),
reasonCode: $exception instanceof InvalidArgumentException
? ProviderReasonCodes::DedicatedCredentialInvalid
: ProviderReasonCodes::DedicatedCredentialMissing,
message: $exception->getMessage(),
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}
if (! $targetScope instanceof ProviderConnectionTargetScopeDescriptor) {
$targetScope = ProviderConnectionTargetScopeDescriptor::fromInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: $targetScopeIdentifier !== '' ? $targetScopeIdentifier : 'organizations',
);
}
return ProviderIdentityResolution::resolved(
connectionType: ProviderConnectionType::Dedicated,
tenantContext: $tenantContext,
targetScope: $targetScope,
effectiveClientId: $credentials['client_id'],
credentialSource: $this->credentialSource($connection),
clientSecret: $credentials['client_secret'],
authorityTenant: $tenantContext,
authorityTenant: $targetScope->scopeIdentifier,
redirectUri: trim((string) route('admin.consent.callback')),
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
providerContextDetails: $providerContextDetails,
);
}

View File

@ -12,6 +12,9 @@
use App\Support\Operations\OperationRunCapabilityResolver;
use App\Support\Providers\ProviderNextStepsRegistry;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
use App\Support\Verification\BlockedVerificationReportFactory;
use App\Support\Verification\StaleQueuedVerificationReportFactory;
use App\Support\Verification\VerificationReportWriter;
@ -28,6 +31,7 @@ public function __construct(
private readonly ProviderConnectionResolver $resolver,
private readonly ProviderNextStepsRegistry $nextStepsRegistry,
private readonly OperationRunCapabilityResolver $capabilityResolver,
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
) {}
/**
@ -137,9 +141,9 @@ public function start(
'module' => $definition['module'],
'provider_binding' => $this->bindingContext($binding),
'provider_connection_id' => (int) $lockedConnection->getKey(),
'target_scope' => [
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
],
'connection_type' => $lockedConnection->connection_type?->value ?? $lockedConnection->connection_type,
'target_scope' => $this->targetScopeContextForConnection($lockedConnection),
'provider_context' => $this->providerContextForConnection($lockedConnection),
]);
$run = $this->runs->ensureRunWithIdentity(
@ -185,9 +189,9 @@ private function startBlocked(
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
'provider' => $provider,
'module' => $module,
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
'target_scope' => $connection instanceof ProviderConnection
? $this->targetScopeContextForConnection($connection)
: $this->targetScopeContextForTenant($tenant, $provider),
]);
$identityInputs = [
@ -202,6 +206,8 @@ private function startBlocked(
if ($connection instanceof ProviderConnection) {
$context['provider_connection_id'] = (int) $connection->getKey();
$context['connection_type'] = $connection->connection_type?->value ?? $connection->connection_type;
$context['provider_context'] = $this->providerContextForConnection($connection);
$identityInputs['provider_connection_id'] = (int) $connection->getKey();
}
@ -287,6 +293,71 @@ private function bindingContext(array $binding): array
];
}
/**
* @return array{
* provider: string,
* scope_kind: string,
* scope_identifier: string,
* scope_display_name: string,
* shared_label: string,
* shared_help_text: string
* }
*/
private function targetScopeContextForConnection(ProviderConnection $connection): array
{
try {
return $this->targetScopeNormalizer->descriptorForConnection($connection)->toArray();
} catch (InvalidArgumentException) {
$identifier = trim((string) $connection->entra_tenant_id);
$fallbackIdentifier = $connection->tenant instanceof ManagedEnvironment
? trim((string) $connection->tenant->graphTenantId())
: '';
return ProviderConnectionTargetScopeDescriptor::fromInput(
provider: (string) $connection->provider,
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: $identifier !== '' ? $identifier : ($fallbackIdentifier !== '' ? $fallbackIdentifier : (string) $connection->getKey()),
scopeDisplayName: (string) ($connection->tenant?->name ?? $connection->display_name ?? $identifier),
)->toArray();
}
}
/**
* @return array{
* provider: string,
* scope_kind: string,
* scope_identifier: string,
* scope_display_name: string,
* shared_label: string,
* shared_help_text: string
* }
*/
private function targetScopeContextForTenant(ManagedEnvironment $tenant, string $provider): array
{
$identifier = trim($tenant->providerTenantContext());
return ProviderConnectionTargetScopeDescriptor::fromInput(
provider: $provider !== '' ? $provider : 'unknown',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: $identifier,
scopeDisplayName: (string) ($tenant->name ?? $identifier),
)->toArray();
}
/**
* @return array{provider: string, details: list<array<string, string>>}
*/
private function providerContextForConnection(ProviderConnection $connection): array
{
return [
'provider' => (string) $connection->provider,
'details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection),
),
];
}
/**
* @param array<string, mixed> $extraContext
*/

View File

@ -120,9 +120,9 @@ public function providerConnectionCheckUsingConnection(
'credential_source' => $identity->credentialSource,
'effective_client_id' => $identity->effectiveClientId,
'target_scope' => $identity->targetScope?->toArray(),
'provider_identity_context' => array_map(
'provider_context' => array_map(
static fn ($detail): array => $detail->toArray(),
$identity->contextualIdentityDetails,
$identity->providerContextDetails,
),
],
]),

View File

@ -20,6 +20,7 @@ public function __construct(
public readonly string $verificationState,
public readonly string $readinessSummary,
public readonly array $contextualIdentityDetails = [],
public readonly bool $isEnabled = true,
) {}
public static function forConnection(ProviderConnection $connection): self
@ -41,6 +42,7 @@ public static function forConnection(ProviderConnection $connection): self
verificationState: $verificationState,
),
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
isEnabled: (bool) $connection->is_enabled,
);
}
@ -67,7 +69,10 @@ public function contextualIdentityLine(): ?string
* consent_state: string,
* verification_state: string,
* readiness_summary: string,
* contextual_identity_details: list<array<string, string>>
* target_scope_summary: string,
* provider_context: array{provider: string, details: list<array<string, string>>},
* contextual_identity_line: ?string,
* is_enabled: bool
* }
*/
public function toArray(): array
@ -78,7 +83,21 @@ public function toArray(): array
'consent_state' => $this->consentState,
'verification_state' => $this->verificationState,
'readiness_summary' => $this->readinessSummary,
'contextual_identity_details' => array_map(
'target_scope_summary' => $this->targetScopeSummary(),
'provider_context' => $this->providerContext(),
'contextual_identity_line' => $this->contextualIdentityLine(),
'is_enabled' => $this->isEnabled,
];
}
/**
* @return array{provider: string, details: list<array<string, string>>}
*/
public function providerContext(): array
{
return [
'provider' => $this->provider,
'details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$this->contextualIdentityDetails,
),

View File

@ -144,13 +144,31 @@ public function auditMetadataForConnection(ProviderConnection $connection, array
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'target_scope' => $summary->targetScope->toArray(),
'provider_identity_context' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$summary->contextualIdentityDetails,
),
'provider_context' => [
'provider' => (string) $connection->provider,
'details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$summary->contextualIdentityDetails,
),
],
], $extra);
}
/**
* @param list<ProviderIdentityContextMetadata> $details
* @return array{provider: string, details: list<array<string, string>>}
*/
public function providerContext(string $provider, array $details): array
{
return [
'provider' => $provider,
'details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$details,
),
];
}
/**
* @param list<string> $fields
* @return list<string>

View File

@ -53,12 +53,17 @@ public static function identity(OperationRun $run): array
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$identity['entra_tenant_id'] = trim($entraTenantId);
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
if ($targetScopeIdentifier !== null) {
$identity['target_scope'] = self::targetScopeIdentity($targetScope);
} else {
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$identity['entra_tenant_id'] = trim($entraTenantId);
}
}
$connectionType = $targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null);
$connectionType = $context['connection_type'] ?? ($targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null));
if (is_string($connectionType) && trim($connectionType) !== '') {
$identity['connection_type'] = trim($connectionType);
}
@ -124,15 +129,23 @@ private static function evidence(OperationRun $run, array $context): array
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
if ($targetScopeIdentifier !== null) {
$evidence[] = [
'kind' => 'entra_tenant_id',
'value' => trim($entraTenantId),
'kind' => 'target_scope_identifier',
'value' => $targetScopeIdentifier,
];
} else {
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$evidence[] = [
'kind' => 'entra_tenant_id',
'value' => trim($entraTenantId),
];
}
}
$connectionType = $targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null);
$connectionType = $context['connection_type'] ?? ($targetScope['connection_type'] ?? ($context['identity']['connection_type'] ?? null));
if (is_string($connectionType) && trim($connectionType) !== '') {
$evidence[] = [
'kind' => 'connection_type',
@ -163,4 +176,35 @@ private static function evidence(OperationRun $run, array $context): array
return $evidence;
}
/**
* @param array<string, mixed> $targetScope
*/
private static function targetScopeIdentifier(array $targetScope): ?string
{
$scopeIdentifier = $targetScope['scope_identifier'] ?? null;
return is_string($scopeIdentifier) && trim($scopeIdentifier) !== ''
? trim($scopeIdentifier)
: null;
}
/**
* @param array<string, mixed> $targetScope
* @return array<string, string>
*/
private static function targetScopeIdentity(array $targetScope): array
{
$identity = [];
foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) {
$value = $targetScope[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
$identity[$key] = trim($value);
}
}
return $identity;
}
}

View File

@ -45,9 +45,14 @@ public static function identity(OperationRun $run): array
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$identity['entra_tenant_id'] = trim($entraTenantId);
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
if ($targetScopeIdentifier !== null) {
$identity['target_scope'] = self::targetScopeIdentity($targetScope);
} else {
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$identity['entra_tenant_id'] = trim($entraTenantId);
}
}
return $identity;
@ -72,12 +77,20 @@ private static function evidence(OperationRun $run, array $context): array
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$targetScopeIdentifier = self::targetScopeIdentifier($targetScope);
if ($targetScopeIdentifier !== null) {
$evidence[] = [
'kind' => 'entra_tenant_id',
'value' => trim($entraTenantId),
'kind' => 'target_scope_identifier',
'value' => $targetScopeIdentifier,
];
} else {
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
$evidence[] = [
'kind' => 'entra_tenant_id',
'value' => trim($entraTenantId),
];
}
}
$evidence[] = [
@ -87,4 +100,35 @@ private static function evidence(OperationRun $run, array $context): array
return $evidence;
}
/**
* @param array<string, mixed> $targetScope
*/
private static function targetScopeIdentifier(array $targetScope): ?string
{
$scopeIdentifier = $targetScope['scope_identifier'] ?? null;
return is_string($scopeIdentifier) && trim($scopeIdentifier) !== ''
? trim($scopeIdentifier)
: null;
}
/**
* @param array<string, mixed> $targetScope
* @return array<string, string>
*/
private static function targetScopeIdentity(array $targetScope): array
{
$identity = [];
foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) {
$value = $targetScope[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
$identity[$key] = trim($value);
}
}
return $identity;
}
}

View File

@ -13,6 +13,7 @@ final class VerificationReportSanitizer
*/
private const ALLOWED_EVIDENCE_KINDS = [
'provider_connection_id',
'target_scope_identifier',
'entra_tenant_id',
'connection_type',
'credential_source',
@ -108,7 +109,7 @@ public static function sanitizeReport(array $report): array
/**
* @param array<string, mixed> $identity
* @return array<string, int|string>
* @return array<string, mixed>
*/
private static function sanitizeIdentity(array $identity): array
{
@ -123,6 +124,16 @@ private static function sanitizeIdentity(array $identity): array
continue;
}
if ($key === 'target_scope' && is_array($value)) {
$targetScope = self::sanitizeIdentityTargetScope($value);
if ($targetScope !== []) {
$sanitized[$key] = $targetScope;
}
continue;
}
if (is_int($value)) {
$sanitized[$key] = $value;
@ -143,6 +154,31 @@ private static function sanitizeIdentity(array $identity): array
return $sanitized;
}
/**
* @param array<string, mixed> $targetScope
* @return array<string, string>
*/
private static function sanitizeIdentityTargetScope(array $targetScope): array
{
$sanitized = [];
foreach (['provider', 'scope_kind', 'scope_identifier', 'scope_display_name'] as $key) {
$value = $targetScope[$key] ?? null;
if (! is_string($value)) {
continue;
}
$value = self::sanitizeValueString($value);
if ($value !== null) {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
/**
* @param array<string, mixed> $summary
* @return array{overall: string, counts: array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}}|null

View File

@ -40,18 +40,19 @@
'target scope',
'credential source',
'effective client identity',
'provider context',
],
'retained_provider_semantics' => [
'entra_tenant_id',
'provider_context.microsoft_tenant_id',
'platform_config',
'graph.tenant_id',
'admin.consent.callback',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
],
'provider.connection_resolution' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core provider connection selection and validation path that keeps current Microsoft connection details as bounded exception metadata.',
'description' => 'Platform-core provider connection selection and validation path that publishes neutral target-scope truth with provider-specific profile detail kept as bounded context metadata.',
'implementation_paths' => [
'app/Services/Providers/ProviderConnectionResolver.php',
'app/Services/Providers/ProviderConnectionResolution.php',
@ -59,16 +60,16 @@
'neutral_terms' => [
'provider',
'provider connection',
'tenant scope',
'target scope',
'default binding',
'unsupported combination',
],
'retained_provider_semantics' => [
'microsoft',
'entra_tenant_id',
'provider_context.microsoft_tenant_id',
'consent_status',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
],
'provider.operation_registry' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
@ -94,7 +95,7 @@
],
'provider.operation_start_gate' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core operation start orchestration that consumes explicit provider bindings and records current Microsoft target-scope exceptions.',
'description' => 'Platform-core operation start orchestration that consumes explicit provider bindings and records neutral target-scope context with provider-specific follow-up detail nested separately.',
'implementation_paths' => [
'app/Services/Providers/ProviderOperationStartGate.php',
],
@ -107,9 +108,9 @@
],
'retained_provider_semantics' => [
'microsoft',
'target_scope.entra_tenant_id',
'provider_context.microsoft_tenant_id',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
],
],
];

View File

@ -14,6 +14,7 @@
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null;
$lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null;
$lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
$targetScopeSummary = is_string($state['target_scope_summary'] ?? null) ? (string) $state['target_scope_summary'] : null;
$isMissing = $connectionState === 'missing';
$lifecycleSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $isEnabled ?? $lifecycle);
@ -26,9 +27,9 @@
<div>
<div class="text-sm font-semibold text-gray-800">Provider connection</div>
@if ($isMissing)
<div class="mt-1 text-sm text-amber-700">Needs action: no Microsoft provider connection is configured.</div>
<div class="mt-1 text-sm text-amber-700">Needs action: no provider connection is configured.</div>
@elseif ($needsDefaultConnection)
<div class="mt-1 text-sm text-amber-700">Needs action: set a default Microsoft provider connection.</div>
<div class="mt-1 text-sm text-amber-700">Needs action: set a default provider connection.</div>
@else
<div class="mt-1 text-sm text-gray-700">{{ $displayName ?? 'Unnamed connection' }}</div>
@endif
@ -51,6 +52,10 @@
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
<dd>{{ $provider ?? 'n/a' }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Target scope</dt>
<dd>{{ $targetScopeSummary ?? 'n/a' }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Lifecycle</dt>
<dd>

View File

@ -3,15 +3,15 @@
wire:poll.{{ $pollingInterval }}
@endif
data-testid="tenant-dashboard-context-chips"
class="grid gap-3 md:grid-cols-2 lg:grid-cols-[minmax(16rem,1fr)_auto_auto] lg:items-center"
class="flex w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-start md:flex-nowrap"
>
<div data-testid="tenant-dashboard-context-chip-workspace" class="inline-flex min-w-0 w-full items-center gap-2 rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200">
<div data-testid="tenant-dashboard-context-chip-workspace" class="inline-flex min-w-0 w-full max-w-full items-center gap-2 rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200 sm:w-auto sm:max-w-[20rem] lg:max-w-[24rem]">
<x-filament::icon icon="heroicon-o-building-office" class="h-5 w-5 text-gray-400 dark:text-gray-500" />
<span class="truncate" title="{{ __('localization.dashboard.overview.context_workspace_chip', ['workspace' => $context['workspace']]) }}">{{ __('localization.dashboard.overview.context_workspace_chip', ['workspace' => $context['workspace']]) }}</span>
</div>
@if (filled($context['provider'] ?? null))
<div data-testid="tenant-dashboard-context-chip-provider" data-provider-key="{{ $context['providerKey'] ?? '' }}" class="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200 lg:justify-self-start">
<div data-testid="tenant-dashboard-context-chip-provider" data-provider-key="{{ $context['providerKey'] ?? '' }}" class="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200">
@if (($context['providerKey'] ?? null) === 'microsoft')
<svg data-testid="tenant-dashboard-context-chip-provider-microsoft-logo" viewBox="0 0 16 16" aria-hidden="true" class="h-4 w-4 shrink-0">
<rect x="1" y="1" width="6" height="6" fill="#f25022" />
@ -28,7 +28,7 @@ class="grid gap-3 md:grid-cols-2 lg:grid-cols-[minmax(16rem,1fr)_auto_auto] lg:i
@endif
@if (filled($context['latestActivity'] ?? null))
<div data-testid="tenant-dashboard-context-chip-latest-activity" class="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200 lg:justify-self-start">
<div data-testid="tenant-dashboard-context-chip-latest-activity" class="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-gray-200 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 dark:text-gray-200">
<x-filament::icon data-testid="tenant-dashboard-context-chip-latest-activity-icon" icon="heroicon-o-clock" class="h-5 w-5 shrink-0 text-gray-400 dark:text-gray-500" />
<span>{{ __('localization.dashboard.overview.context_latest_activity_chip', ['time' => $context['latestActivity']]) }}</span>
</div>

View File

@ -72,6 +72,8 @@
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity\"]') !== null", true)
->assertScript("document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity-icon\"]') !== null", true)
->assertScript("(() => { const chips = document.querySelector('[data-testid=\"tenant-dashboard-context-chips\"]'); const firstKpi = document.querySelector('[data-testid=\"tenant-dashboard-kpi\"]'); if (! chips || ! firstKpi) return false; return chips.getBoundingClientRect().top < firstKpi.getBoundingClientRect().top; })()", true)
->assertScript("(() => { const subtitle = Array.from(document.querySelectorAll('p')).find((node) => node.textContent?.includes('Tenant governance overview')); const chips = document.querySelector('[data-testid=\"tenant-dashboard-context-chips\"]'); if (! subtitle || ! chips) return false; return chips.getBoundingClientRect().top - subtitle.getBoundingClientRect().bottom <= 40; })()", true)
->assertScript("(() => { const workspace = document.querySelector('[data-testid=\"tenant-dashboard-context-chip-workspace\"]'); const provider = document.querySelector('[data-testid=\"tenant-dashboard-context-chip-provider\"]'); const activity = document.querySelector('[data-testid=\"tenant-dashboard-context-chip-latest-activity\"]'); if (! workspace || ! provider || ! activity) return false; const tops = [workspace, provider, activity].map((element) => Math.round(element.getBoundingClientRect().top)); return Math.max(...tops) - Math.min(...tops) <= 2; })()", true)
->assertSee('Recommended next actions')
->assertSee('Operations needing attention')
->assertSee('Operations requiring attention')

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\TenantResource;
use App\Models\ManagedEnvironment;
use App\Models\ProviderConnection;
use App\Models\TenantOnboardingSession;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->browser()->timeout(20_000);
it('smokes provider-connection detail and managed-environment related provider summary continuity', function (): void {
[$user, $tenant] = createUserWithTenant(
role: 'owner',
workspaceRole: 'manager',
ensureDefaultMicrosoftProviderConnection: false,
);
$tenant->forceFill([
'name' => 'Spec 281 Browser Environment',
'managed_environment_id' => '88888888-8888-8888-8888-888888888888',
'status' => ManagedEnvironment::STATUS_ONBOARDING,
])->save();
$connection = ProviderConnection::factory()->consentGranted()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'display_name' => 'Spec 281 Browser Connection',
'entra_tenant_id' => '88888888-8888-8888-8888-888888888888',
'is_default' => true,
'verification_status' => 'healthy',
]);
$draft = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'entra_tenant_id' => '88888888-8888-8888-8888-888888888888',
'current_step' => 'connection',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
],
]);
visit(ProviderConnectionResource::getUrl('view', [
'record' => $connection,
'managed_environment_id' => $tenant->external_id,
], panel: 'admin'))
->waitForText('Spec 281 Browser Connection')
->assertSee('Target scope')
->assertSee('Spec 281 Browser Environment')
->assertSee('Provider context')
->assertSee('Microsoft tenant ID')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
->waitForText('Provider connection')
->assertSee('Ready - Spec 281 Browser Environment')
->assertSee('Spec 281 Browser Environment')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
->waitForText('Provider connection')
->assertSee('Spec 281 Browser Connection')
->assertSee('Target scope')
->assertSee('Spec 281 Browser Environment')
->assertSee('Open Provider Connections')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -102,7 +102,7 @@
'provider_connection_id',
'provider',
'target_scope',
'provider_identity_context',
'provider_context',
'connection_type',
])
->and($metadata)->not->toHaveKey('entra_tenant_id')
@ -112,7 +112,10 @@
'scope_identifier' => '88888888-8888-8888-8888-888888888888',
'shared_label' => 'Target scope',
])
->and($metadata['provider_identity_context'][0] ?? [])->toMatchArray([
->and($metadata['provider_context'] ?? [])->toMatchArray([
'provider' => 'microsoft',
])
->and($metadata['provider_context']['details'][0] ?? [])->toMatchArray([
'provider' => 'microsoft',
'detail_key' => 'microsoft_tenant_id',
'detail_label' => 'Microsoft tenant ID',

View File

@ -98,9 +98,10 @@ function mockTenantDashboardSummaryPermissions(array $overview = []): void
expect(substr_count($content, 'data-testid="tenant-dashboard-kpi"'))->toBe(4)
->and($content)->toContain('data-testid="tenant-dashboard-posture-pill"')
->and($content)->toContain('data-testid="tenant-dashboard-context-chips"')
->and($content)->toContain('lg:grid-cols-[minmax(16rem,1fr)_auto_auto] lg:items-center')
->and($content)->toContain('class="flex w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-start md:flex-nowrap"')
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-workspace"')
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-workspace" class="inline-flex min-w-0 w-full items-center')
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-workspace" class="inline-flex min-w-0 w-full max-w-full items-center')
->and($content)->toContain('sm:w-auto sm:max-w-[20rem] lg:max-w-[24rem]')
->and($content)->toContain('Workspace: '.$tenant->workspace->name)
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-provider"')
->and($content)->toContain('data-testid="tenant-dashboard-context-chip-provider-microsoft-logo"')

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Models\ProviderConnection;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('keeps provider connection list and detail surfaces centered on target scope', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$connection = ProviderConnection::factory()->consentGranted()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'display_name' => 'Spec 281 visible connection',
'entra_tenant_id' => '66666666-6666-6666-6666-666666666666',
'consent_status' => 'granted',
'verification_status' => 'healthy',
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)->test(ListProviderConnections::class);
$table = $component->instance()->getTable();
$visibleColumnNames = collect($table->getVisibleColumns())
->map(fn ($column): string => $column->getName())
->values()
->all();
$globalSearchProperty = new ReflectionProperty(ProviderConnectionResource::class, 'isGloballySearchable');
$globalSearchProperty->setAccessible(true);
expect($globalSearchProperty->getValue())->toBeFalse()
->and(array_keys(ProviderConnectionResource::getPages()))->toContain('view', 'edit')
->and($visibleColumnNames)->toContain('provider', 'target_scope', 'consent_status', 'verification_status')
->and($visibleColumnNames)->not->toContain('entra_tenant_id');
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('view', [
'record' => $connection,
'managed_environment_id' => $tenant->external_id,
], panel: 'admin'))
->assertOk()
->assertSee('Target scope')
->assertSee('Provider context')
->assertSee('Microsoft tenant ID')
->assertDontSee('Entra tenant ID');
});

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
it('keeps Microsoft-shaped provider scope out of shared provider platform-core contracts', function (): void {
$root = base_path();
$forbiddenByPath = [
'app/Services/Providers/ProviderIdentityResolution.php' => [
'tenantContext',
'target_scope.entra_tenant_id',
],
'app/Services/Providers/ProviderIdentityResolver.php' => [
'tenantContext',
],
'app/Services/Providers/PlatformProviderIdentityResolver.php' => [
'tenantContext',
],
'app/Services/Providers/ProviderOperationStartGate.php' => [
"'entra_tenant_id' =>",
'target_scope.entra_tenant_id',
],
'config/provider_boundaries.php' => [
'target_scope.entra_tenant_id',
],
];
foreach ($forbiddenByPath as $relativePath => $fragments) {
$contents = (string) file_get_contents($root.'/'.$relativePath);
foreach ($fragments as $fragment) {
expect($contents)
->not->toContain($fragment, sprintf('%s still exposes [%s] as shared provider-scope truth.', $relativePath, $fragment));
}
}
});

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\ProviderConnection;
use App\Models\TenantOnboardingSession;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('reuses the provider connection surface summary in onboarding readiness', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$connection = ProviderConnection::factory()->consentGranted()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'display_name' => 'Spec 281 onboarding connection',
'entra_tenant_id' => '77777777-7777-7777-7777-777777777777',
'is_default' => true,
'verification_status' => 'healthy',
]);
$draft = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'entra_tenant_id' => '77777777-7777-7777-7777-777777777777',
'current_step' => 'connection',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$component = Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $draft->getKey()]);
$method = new ReflectionMethod($component->instance(), 'readinessProviderSummary');
$method->setAccessible(true);
$summary = $method->invoke($component->instance(), $connection->fresh(['tenant']));
expect($summary['provider'])->toBe('microsoft')
->and($summary['target_scope'] ?? [])->toMatchArray([
'scope_identifier' => '77777777-7777-7777-7777-777777777777',
'shared_label' => 'Target scope',
])
->and($summary['target_scope_summary'] ?? null)->toBe($tenant->name.' (77777777-7777-7777-7777-777777777777)')
->and($summary['provider_context'] ?? [])->toMatchArray([
'provider' => 'microsoft',
])
->and($summary['target_scope'])->not->toHaveKey('entra_tenant_id')
->and($summary)->not->toHaveKey('contextual_identity_details');
});

View File

@ -109,9 +109,15 @@ public function request(string $method, string $path, array $options = []): Grap
'provider' => 'microsoft',
'module' => 'compliance',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
'entra_tenant_name' => 'Contoso',
],
'connection_type' => 'platform',
]);
expect($run->context['provider_context'] ?? [])->toMatchArray([
'provider' => 'microsoft',
]);
expect($run->context['target_scope'] ?? [])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => $connection->entra_tenant_id,
'scope_display_name' => 'Contoso',
])->not->toHaveKey('entra_tenant_id');
});

View File

@ -105,12 +105,17 @@ public function request(string $method, string $path, array $options = []): Grap
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded');
expect($run->context)->toMatchArray([
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
'entra_tenant_name' => 'Contoso',
'connection_type' => 'dedicated',
],
'connection_type' => 'dedicated',
]);
expect($run->context['provider_context'] ?? [])->toMatchArray([
'provider' => 'microsoft',
]);
expect($run->context['target_scope'] ?? [])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => $connection->entra_tenant_id,
'scope_display_name' => 'Contoso',
])->not->toHaveKey('entra_tenant_id');
expect($connection->metadata)->toMatchArray([
'entra_tenant_name' => 'Contoso',

View File

@ -52,10 +52,15 @@
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
]);
expect($opRun?->context['provider_context'] ?? [])->toMatchArray([
'provider' => 'microsoft',
]);
expect($opRun?->context['target_scope'] ?? [])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => $connection->entra_tenant_id,
])->not->toHaveKey('entra_tenant_id');
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();

View File

@ -52,10 +52,15 @@
'provider' => 'microsoft',
'module' => 'inventory',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
]);
expect($opRun?->context['provider_context'] ?? [])->toMatchArray([
'provider' => 'microsoft',
]);
expect($opRun?->context['target_scope'] ?? [])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => $connection->entra_tenant_id,
])->not->toHaveKey('entra_tenant_id');
expect(OperationRun::query()
->where('managed_environment_id', $tenant->getKey())
@ -105,10 +110,15 @@
'provider' => 'microsoft',
'module' => 'inventory',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
]);
expect($opRun?->context['provider_context'] ?? [])->toMatchArray([
'provider' => 'microsoft',
]);
expect($opRun?->context['target_scope'] ?? [])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => $connection->entra_tenant_id,
])->not->toHaveKey('entra_tenant_id');
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
});
@ -153,10 +163,15 @@
'provider' => 'microsoft',
'module' => 'compliance',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $connection->entra_tenant_id,
],
]);
expect($opRun?->context['provider_context'] ?? [])->toMatchArray([
'provider' => 'microsoft',
]);
expect($opRun?->context['target_scope'] ?? [])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => $connection->entra_tenant_id,
])->not->toHaveKey('entra_tenant_id');
expect(OperationRun::query()
->where('managed_environment_id', $tenant->getKey())

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Services\Providers\ProviderConnectionResolver;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('resolves provider connections and summaries through one neutral target-scope contract', function (): void {
config()->set('graph.client_id', 'platform-client-id');
config()->set('graph.client_secret', 'platform-client-secret');
[, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$connection = ProviderConnection::factory()
->platform()
->consentGranted()
->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'display_name' => 'Spec 281 provider connection',
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'is_default' => true,
'is_enabled' => true,
'verification_status' => 'healthy',
]);
$resolution = app(ProviderConnectionResolver::class)
->validateConnection($tenant, 'microsoft', $connection->fresh(['tenant']));
$summary = ProviderConnectionSurfaceSummary::forConnection($connection->fresh(['tenant']));
$summaryPayload = $summary->toArray();
expect($resolution->resolved)->toBeTrue()
->and($resolution->targetScope?->toArray())->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => '11111111-1111-1111-1111-111111111111',
'shared_label' => 'Target scope',
])
->and($resolution->targetScope?->toArray())->not->toHaveKey('entra_tenant_id')
->and($summaryPayload['provider'])->toBe('microsoft')
->and($summaryPayload['target_scope'] ?? [])->toMatchArray([
'scope_identifier' => '11111111-1111-1111-1111-111111111111',
])
->and($summaryPayload['provider_context'] ?? [])->toMatchArray([
'provider' => 'microsoft',
])
->and($summaryPayload['target_scope'])->not->toHaveKey('entra_tenant_id')
->and($summaryPayload['provider_context']['details'][0] ?? [])->toMatchArray([
'detail_key' => 'microsoft_tenant_id',
'detail_value' => '11111111-1111-1111-1111-111111111111',
]);
});

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Services\Providers\ProviderIdentityResolver;
use App\Support\Providers\ProviderConnectionType;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('exposes effective client identity and provider context without shared tenantContext naming', function (): void {
config()->set('graph.client_id', 'platform-client-id');
config()->set('graph.client_secret', 'platform-client-secret');
config()->set('graph.managed_environment_id', 'platform-home-tenant-id');
[, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$connection = ProviderConnection::factory()->platform()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => '22222222-2222-2222-2222-222222222222',
]);
$resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant']));
$providerContextDetails = collect($resolution->providerContext()['details']);
expect($resolution->resolved)->toBeTrue()
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
->and(property_exists($resolution, 'tenantContext'))->toBeFalse()
->and($resolution->targetScopeIdentifier())->toBe('22222222-2222-2222-2222-222222222222')
->and($resolution->effectiveClientIdentity())->toBe([
'client_id' => 'platform-client-id',
'credential_source' => 'platform_config',
])
->and($providerContextDetails->contains(
fn (array $detail): bool => ($detail['detail_key'] ?? null) === 'microsoft_tenant_id'
&& ($detail['detail_value'] ?? null) === '22222222-2222-2222-2222-222222222222',
))->toBeTrue();
});
it('keeps dedicated runtime secrets out of target scope and provider context', function (): void {
$connection = ProviderConnection::factory()->dedicated()->create([
'entra_tenant_id' => '33333333-3333-3333-3333-333333333333',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'dedicated-client-id',
'client_secret' => 'dedicated-client-secret',
],
]);
$resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant', 'credential']));
$providerContextDetails = collect($resolution->providerContext()['details']);
expect($resolution->resolved)->toBeTrue()
->and($resolution->targetScope?->toArray())->not->toHaveKey('client_secret')
->and($providerContextDetails->contains(
fn (array $detail): bool => str_contains((string) ($detail['detail_value'] ?? ''), 'dedicated-client-secret'),
))->toBeFalse()
->and($resolution->effectiveClientId)->toBe('dedicated-client-id');
});

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\ManagedEnvironment;
use App\Services\Providers\ProviderOperationStartGate;
use App\Support\OperationRunOutcome;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('stores neutral target-scope and nested provider context when provider operations start', function (): void {
$tenant = ManagedEnvironment::factory()->create([
'name' => 'Spec 281 Environment',
'managed_environment_id' => '44444444-4444-4444-4444-444444444444',
]);
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'workspace_id' => (int) $tenant->workspace_id,
'managed_environment_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => '44444444-4444-4444-4444-444444444444',
'consent_status' => 'granted',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
]);
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: fn (OperationRun $run): null => null,
);
$context = $result->run->fresh()->context;
expect($result->status)->toBe('started')
->and($context['target_scope'] ?? [])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => '44444444-4444-4444-4444-444444444444',
'scope_display_name' => 'Spec 281 Environment',
])
->and($context['target_scope'] ?? [])->not->toHaveKey('entra_tenant_id')
->and($context['provider_context']['details'][0] ?? [])->toMatchArray([
'detail_key' => 'microsoft_tenant_id',
'detail_value' => '44444444-4444-4444-4444-444444444444',
]);
});
it('stores neutral target-scope context when provider starts are blocked before a connection is resolved', function (): void {
$tenant = ManagedEnvironment::factory()->create([
'name' => 'Blocked Spec 281 Environment',
'managed_environment_id' => '55555555-5555-5555-5555-555555555555',
]);
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: null,
operationType: 'provider.connection.check',
dispatcher: fn (): null => null,
);
$context = $result->run->fresh()->context;
$report = $context['verification_report'] ?? [];
$report = is_array($report) ? $report : [];
$identity = $report['identity'] ?? [];
$identity = is_array($identity) ? $identity : [];
$evidence = $report['checks'][0]['evidence'] ?? [];
$evidence = is_array($evidence) ? $evidence : [];
expect($result->status)->toBe('blocked')
->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing)
->and($context['target_scope'] ?? [])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => '55555555-5555-5555-5555-555555555555',
])
->and($context['target_scope'] ?? [])->not->toHaveKey('entra_tenant_id')
->and($identity['target_scope'] ?? [])->toMatchArray([
'scope_identifier' => '55555555-5555-5555-5555-555555555555',
])
->and($identity)->not->toHaveKey('entra_tenant_id')
->and(collect($evidence)->contains(
fn (array $pointer): bool => ($pointer['kind'] ?? null) === 'target_scope_identifier'
&& ($pointer['value'] ?? null) === '55555555-5555-5555-5555-555555555555',
))->toBeTrue();
});

View File

@ -13,7 +13,8 @@
expect($resolution->resolved)->toBeTrue()
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
->and($resolution->tenantContext)->toBe('customer-tenant-id')
->and(property_exists($resolution, 'tenantContext'))->toBeFalse()
->and($resolution->targetScopeIdentifier())->toBe('customer-tenant-id')
->and($resolution->effectiveClientId)->toBe('platform-client-id')
->and($resolution->credentialSource)->toBe('platform_config')
->and($resolution->clientSecret)->toBe('platform-client-secret')

View File

@ -29,9 +29,9 @@
expect($identity->coversPath('app/Services/Providers/ProviderIdentityResolution.php'))->toBeTrue()
->and($identity->neutralTerms)->toContain('target scope')
->and($identity->retainedProviderSemantics)->toContain('entra_tenant_id')
->and($identity->retainedProviderSemantics)->toContain('provider_context.microsoft_tenant_id')
->and($identity->retainedProviderSemantics)->not->toContain('Microsoft Graph option keys')
->and($identity->followUpAction)->toBe(ProviderBoundarySeam::FOLLOW_UP_SPEC);
->and($identity->followUpAction)->toBe(ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE);
$registry = $catalog->get('provider.operation_registry');
@ -48,7 +48,7 @@
->and($seam->description)->not->toBeEmpty()
->and($seam->implementationPaths)->not->toBeEmpty()
->and($seam->neutralTerms)->not->toBeEmpty()
->and($seam->retainedProviderSemantics)->toContain('target_scope.entra_tenant_id')
->and($seam->retainedProviderSemantics)->toContain('provider_context.microsoft_tenant_id')
->and($seam->followUpAction)->toBeIn([
ProviderBoundarySeam::FOLLOW_UP_NONE,
ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,

View File

@ -32,11 +32,12 @@
expect($resolution->resolved)->toBeTrue()
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
->and($resolution->tenantContext)->toBe('22222222-2222-2222-2222-222222222222')
->and(property_exists($resolution, 'tenantContext'))->toBeFalse()
->and($resolution->targetScopeIdentifier())->toBe('22222222-2222-2222-2222-222222222222')
->and($resolution->targetScope)->not->toBeNull()
->and($resolution->targetScope?->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT)
->and($resolution->targetScope?->scopeIdentifier)->toBe('22222222-2222-2222-2222-222222222222')
->and(collect($resolution->contextualIdentityDetails)->pluck('detailKey')->all())
->and(collect($resolution->providerContextDetails)->pluck('detailKey')->all())
->toContain('microsoft_tenant_id', 'authority_tenant', 'redirect_uri');
});

View File

@ -88,7 +88,8 @@
$resolution = app(ProviderIdentityResolver::class)->resolve($connection);
expect($resolution->resolved)->toBeTrue()
->and($resolution->tenantContext)->toBe('dedicated-target-tenant-id')
->and(property_exists($resolution, 'tenantContext'))->toBeFalse()
->and($resolution->targetScopeIdentifier())->toBe('dedicated-target-tenant-id')
->and($resolution->effectiveClientId)->toBe('dedicated-client-id')
->and(method_exists($resolution, 'graphOptions'))->toBeFalse();
});

View File

@ -53,10 +53,15 @@
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => 'entra-tenant-id',
],
]);
expect($run->context['provider_context'] ?? [])->toMatchArray([
'provider' => 'microsoft',
]);
expect($run->context['target_scope'] ?? [])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => 'entra-tenant-id',
])->not->toHaveKey('entra_tenant_id');
expect($run->context['provider_binding']['provider'] ?? null)->toBe('microsoft')
->and($run->context['provider_binding']['binding_status'] ?? null)->toBe('active');
});
@ -197,10 +202,12 @@
expect($run->context)->toMatchArray([
'provider_connection_id' => (int) $connection->getKey(),
'required_capability' => Capabilities::TENANT_MANAGE,
'target_scope' => [
'entra_tenant_id' => 'restore-entra-tenant-id',
],
]);
expect($run->context['target_scope'] ?? [])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => 'restore-entra-tenant-id',
])->not->toHaveKey('entra_tenant_id');
});
it('starts directory group sync with explicit provider connection binding and sync capability metadata', function (): void {
@ -239,10 +246,12 @@
expect($run->context)->toMatchArray([
'provider_connection_id' => (int) $connection->getKey(),
'required_capability' => Capabilities::TENANT_SYNC,
'target_scope' => [
'entra_tenant_id' => 'directory-entra-tenant-id',
],
]);
expect($run->context['target_scope'] ?? [])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => 'directory-entra-tenant-id',
])->not->toHaveKey('entra_tenant_id');
});
it('treats onboarding bootstrap provider starts as one protected scope', function (): void {

View File

@ -0,0 +1,68 @@
# Specification Quality Checklist: Provider Connection Scope & Microsoft Profile Extraction
**Purpose**: Validate package completeness, boundedness, and readiness before implementation
**Created**: 2026-05-07
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] The package stays on reserved slot `281` and does not silently absorb Spec `280` or Specs `282`-`287`.
- [x] The stale candidate wording about `provider_connections.tenant_id` is explicitly corrected to current repo truth.
- [x] The package explicitly documents the second candidate deviation: the raw `provider_key` / `external_account_id` / `provider_metadata` / run-context proposal is narrowed to existing repo truth through `target_scope`, `effective_client_identity`, nested `provider_context`, and existing provider-owned metadata.
- [x] The package stays focused on the verified provider-boundary hotspot instead of reading like a speculative provider-platform rewrite.
- [x] No new provider-profile table, registry, capability engine, or artifact taxonomy is pulled into scope.
- [x] `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, and the contract artifact all describe the same bounded slice.
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain in `spec.md`, `plan.md`, `research.md`, `data-model.md`, or `quickstart.md`.
- [x] Requirements remain testable and bounded to the current provider-connection, target-scope, identity-resolution, onboarding, and operation-start seams.
- [x] Shared `target_scope` fields are explicit and neutral across the package.
- [x] Provider-specific Microsoft detail is explicitly nested under provider-owned profile or context disclosure instead of shared contract truth.
- [x] Scope boundaries, assumptions, risks, and deferred adjacent candidates remain explicit.
## Repo Truth Anchoring
- [x] The package reflects that `ProviderConnection` already belongs to `ManagedEnvironment` via `managed_environment_id`.
- [x] The package reflects that current platform-core seams still leak Microsoft semantics through `tenantContext` and `target_scope.entra_tenant_id`.
- [x] The package reflects that `config/provider_boundaries.php` already classifies provider identity, connection resolution, and operation-start seams as platform-core follow-up hotspots.
- [x] The package reflects that `ProviderConnectionResource` exists with `Create`, `View`, and `Edit` pages and remains non-globally-searchable.
- [x] The package reflects that `ManagedTenantOnboardingWizard` and managed-environment related-context seams already reuse provider summaries and therefore need one summary contract.
## Feature Readiness
- [x] Filament v5 and Livewire v4 expectations remain explicit across the package.
- [x] Provider registration location remains explicit as `apps/platform/bootstrap/providers.php`.
- [x] `ProviderConnectionResource` global-search status and touched searchable-surface notes remain explicit.
- [x] Destructive action confirmation and authorization expectations remain explicit for touched provider-connection mutations.
- [x] The unchanged asset strategy and deployment note remain explicit.
- [x] The test strategy and minimal proving commands are explicit and aligned across artifacts.
- [x] The Candidate Selection Gate still explains why `281` is chosen now and why `282`-`287` are deferred.
- [x] The Completed-Spec Guardrail still keeps `279` and `280` separate from this package.
## Artifact Alignment
- [x] `research.md` records the same bounded extraction decisions reflected in `plan.md`.
- [x] `data-model.md` models the same neutral `target_scope`, provider-context, effective-client-identity, onboarding, and run-context contracts reflected in the plan and contract file.
- [x] `quickstart.md` uses the same bounded reviewer flow and proof commands as `plan.md`.
- [x] `contracts/provider-connection-scope.logical.openapi.yaml` models the same shared summary, identity-resolution, provider-profile, onboarding-readiness, and operation-start contracts described in the plan.
- [x] Canonical proof commands match across `spec.md`, `plan.md`, and `quickstart.md`.
## Test Governance
- [x] Planned proof stays bounded to focused feature coverage, one browser smoke, and the existing guard concept for Microsoft-shaped shared-contract leaks.
- [x] No new heavy-governance family or broad browser matrix is introduced.
- [x] Workspace, managed-environment, provider-connection, and optional credential fixture cost is acknowledged instead of hidden.
- [x] Reviewer handoff includes exact minimal validation commands and concrete stop questions.
## Notes
- Reviewed against `.specify/memory/constitution.md`, `specs/279-workspace-managed-environment-core/spec.md`, `specs/280-workspace-tenancy-environment-routing/spec.md`, `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolution.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, `apps/platform/app/Services/Providers/CredentialManager.php`, `apps/platform/app/Services/Providers/AdminConsentUrlFactory.php`, `apps/platform/app/Services/Providers/ProviderGateway.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php`, `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`, and `apps/platform/config/provider_boundaries.php` on 2026-05-07.
- No application implementation, test execution, or runtime validation was performed while preparing this package.
## Review Outcome
- **Outcome class**: `implementation-ready`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Reason**: The package turns the ready spec into an implementation-ready plan set that neutralizes shared provider-connection and target-scope contracts, confines Microsoft profile detail to provider-owned seams, and keeps all adjacent routing, taxonomy, RBAC, copy, and quality-gate work deferred.

View File

@ -0,0 +1,438 @@
openapi: 3.0.3
info:
title: TenantPilot Admin - Provider Connection Scope & Profile Contract (Conceptual)
version: 0.1.0
description: |
Conceptual shared-contract artifact for Spec 281.
This package keeps the existing provider-connection persistence model and
operator surfaces, but makes the shared provider-connection target-scope,
identity-resolution, onboarding-summary, and operation-start context shape
implementable without guessing at field ownership.
These paths model logical surfaces and shared contracts, not a promise of
new public route families. Public route ownership remains on the existing
Filament resource and pages, with adjacent routing work deferred to Spec 280.
servers:
- url: /logical/provider-connections
paths:
/connections/{connection}/surface-summary:
get:
summary: Resolve the shared provider-connection summary contract
parameters:
- $ref: '#/components/parameters/ConnectionIdentifier'
responses:
'200':
description: Shared provider-connection summary resolved
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderConnectionSurfaceSummaryView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
x-contract-rules:
- Shared `target_scope` must use `ProviderTargetScope`.
- Provider-specific Microsoft detail remains nested under provider context or profile metadata.
/connections/{connection}/identity-resolution:
get:
summary: Resolve the shared provider identity-result contract
parameters:
- $ref: '#/components/parameters/ConnectionIdentifier'
responses:
'200':
description: Provider identity resolved or blocked with a neutral shared contract
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderIdentityResolutionView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
x-contract-rules:
- The shared contract centers on `target_scope`, effective client identity, credential source, and blocked reason.
- Provider-specific authority or redirect detail stays nested in `provider_context`.
/connections/{connection}/provider-profile:
get:
summary: Resolve provider-owned profile and contextual disclosure
parameters:
- $ref: '#/components/parameters/ConnectionIdentifier'
responses:
'200':
description: Provider-owned profile disclosure resolved
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderProfileDisclosureView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
x-provider-owned: true
/onboarding/provider-connections/{connection}/readiness:
get:
summary: Resolve the onboarding readiness summary for an existing provider connection
parameters:
- $ref: '#/components/parameters/ConnectionIdentifier'
responses:
'200':
description: Onboarding readiness payload resolved
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingProviderConnectionReadinessView'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
x-contract-rules:
- Onboarding must reuse the same `ProviderConnectionSurfaceSummaryView` contract as the provider-connections resource.
/provider-operations/{operationType}/start:
post:
summary: Start or block a provider operation using neutral shared target-scope context
parameters:
- $ref: '#/components/parameters/OperationType'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderOperationStartRequest'
responses:
'200':
description: Existing active run reused or scope marked busy
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderOperationStartResult'
'202':
description: New operation run queued
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderOperationStartResult'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'422':
description: Operation start blocked with a recorded run and neutral shared target-scope context
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderOperationStartResult'
x-contract-rules:
- Shared run context must use `ProviderOperationRunContext.target_scope` instead of `target_scope.entra_tenant_id`.
- Provider-specific detail needed for follow-up belongs in `provider_context.details`.
components:
parameters:
ConnectionIdentifier:
name: connection
in: path
required: true
schema:
type: integer
OperationType:
name: operationType
in: path
required: true
schema:
type: string
responses:
Forbidden:
description: Actor is in scope but lacks the required provider capability.
NotFound:
description: Provider connection or managed-environment scope is not visible to the actor.
schemas:
ProviderTargetScope:
type: object
required:
- provider
- scope_kind
- scope_identifier
- scope_display_name
- shared_label
- shared_help_text
properties:
provider:
type: string
scope_kind:
type: string
enum: [tenant]
scope_identifier:
type: string
scope_display_name:
type: string
shared_label:
type: string
shared_help_text:
type: string
ProviderContextDetail:
type: object
description: Extensible provider-owned detail item used for profile, consent, required-permissions, domain, portal, audit, or troubleshooting disclosure.
required:
- detail_key
- detail_label
- detail_value
- visibility
properties:
detail_key:
type: string
description: Stable provider-owned detail key such as microsoft_tenant_id, authority_tenant, redirect_uri, admin_consent_url, required_permissions_url, portal_domain, or portal_link.
detail_label:
type: string
detail_value:
type: string
visibility:
type: string
enum: [contextual_only, audit_only, troubleshooting_only]
ProviderContext:
type: object
description: Provider-owned nested context wrapper reused by UI summaries, audit metadata, and provider-operation follow-up surfaces.
required:
- provider
- details
properties:
provider:
type: string
details:
type: array
items:
$ref: '#/components/schemas/ProviderContextDetail'
EffectiveClientIdentity:
type: object
required:
- credential_source
properties:
client_id:
type: string
nullable: true
credential_source:
type: string
BlockedReason:
type: object
required:
- reason_code
properties:
reason_code:
type: string
message:
type: string
nullable: true
ProviderConnectionSurfaceSummaryView:
type: object
required:
- provider
- target_scope
- consent_state
- verification_state
- readiness_summary
- target_scope_summary
- provider_context
- is_enabled
properties:
provider:
type: string
target_scope:
$ref: '#/components/schemas/ProviderTargetScope'
consent_state:
type: string
verification_state:
type: string
readiness_summary:
type: string
target_scope_summary:
type: string
provider_context:
$ref: '#/components/schemas/ProviderContext'
contextual_identity_line:
type: string
nullable: true
is_enabled:
type: boolean
ProviderIdentityResolutionView:
type: object
required:
- resolved
- connection_type
- effective_client_identity
- provider_context
properties:
resolved:
type: boolean
connection_type:
type: string
target_scope:
allOf:
- $ref: '#/components/schemas/ProviderTargetScope'
nullable: true
effective_client_identity:
$ref: '#/components/schemas/EffectiveClientIdentity'
blocked_reason:
allOf:
- $ref: '#/components/schemas/BlockedReason'
nullable: true
provider_context:
$ref: '#/components/schemas/ProviderContext'
ProviderProfileDisclosureView:
type: object
required:
- provider
- target_scope
- provider_context
properties:
provider:
type: string
target_scope:
$ref: '#/components/schemas/ProviderTargetScope'
provider_context:
$ref: '#/components/schemas/ProviderContext'
PermissionOverview:
type: object
required:
- overall
- counts
- freshness
- missing_permissions
properties:
overall:
type: string
nullable: true
counts:
type: object
additionalProperties:
type: integer
freshness:
type: object
required: [last_refreshed_at, is_stale]
properties:
last_refreshed_at:
type: string
nullable: true
is_stale:
type: boolean
missing_permissions:
type: object
required: [application, delegated]
properties:
application:
type: array
items:
type: string
delegated:
type: array
items:
type: string
required_permissions_url:
type: string
nullable: true
OnboardingProviderConnectionReadinessView:
type: object
required:
- provider_connection_id
- provider_summary
- permission_overview
properties:
provider_connection_id:
type: integer
provider_summary:
$ref: '#/components/schemas/ProviderConnectionSurfaceSummaryView'
permission_overview:
$ref: '#/components/schemas/PermissionOverview'
ProviderBindingContext:
type: object
required:
- provider
- binding_status
- handler_notes
- exception_notes
properties:
provider:
type: string
binding_status:
type: string
handler_notes:
type: string
exception_notes:
type: string
ProviderOperationRunContext:
type: object
required:
- provider
- module
- provider_binding
- target_scope
properties:
execution_authority_mode:
type: string
nullable: true
required_capability:
type: string
nullable: true
provider:
type: string
module:
type: string
provider_binding:
$ref: '#/components/schemas/ProviderBindingContext'
provider_connection_id:
type: integer
nullable: true
target_scope:
$ref: '#/components/schemas/ProviderTargetScope'
provider_context:
allOf:
- $ref: '#/components/schemas/ProviderContext'
nullable: true
OperationRunReference:
type: object
required:
- id
- type
- status
- context
properties:
id:
type: integer
type:
type: string
status:
type: string
outcome:
type: string
nullable: true
context:
$ref: '#/components/schemas/ProviderOperationRunContext'
ProviderOperationStartRequest:
type: object
required:
- managed_environment_id
properties:
managed_environment_id:
type: integer
provider_connection_id:
type: integer
nullable: true
execution_authority_mode:
type: string
nullable: true
extra_context:
type: object
additionalProperties: true
ProviderOperationStartResult:
type: object
required:
- result
- run
properties:
result:
type: string
enum: [started, deduped, scope_busy, blocked]
run:
$ref: '#/components/schemas/OperationRunReference'
blocked_reason:
allOf:
- $ref: '#/components/schemas/BlockedReason'
nullable: true

View File

@ -0,0 +1,232 @@
# Data Model: Provider Connection Scope & Microsoft Profile Extraction
**Date**: 2026-05-07
**Branch**: `281-provider-connection-scope`
## Overview
This slice introduces no new persistence. It keeps the existing provider-connection, credential, and run records intact and instead standardizes the derived runtime contracts that platform-core seams expose to UI, audit, and provider-operation flows.
## Persisted Truth Unchanged
- `ProviderConnection` remains the workspace-owned, managed-environment-scoped binding record.
- `ProviderCredential` remains the optional credential record attached to one `ProviderConnection`.
- `OperationRun` remains execution truth and keeps its current identity and lifecycle ownership.
- `config/provider_boundaries.php` remains the single source for provider-owned versus platform-core seam classification.
- No new table, registry, provider-profile entity, enum family, or taxonomy is introduced.
## Derived Runtime Contracts
### 1. Provider Connection Record
**Persistence**: existing database row
**Owner**: `ProviderConnection`
| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | int | yes | Existing record key |
| `workspace_id` | int | yes | Existing workspace boundary |
| `managed_environment_id` | int | yes | Existing managed-environment boundary; already the canonical scope anchor |
| `provider` | string | yes | Current provider key (`microsoft` today) |
| `display_name` | string | yes | Operator-visible connection label |
| `connection_type` | enum | yes | Existing `platform` or `dedicated` connection type |
| `is_default` | bool | yes | Existing default-binding flag |
| `is_enabled` | bool | yes | Existing enablement flag |
| `consent_status` | enum | yes | Existing consent state |
| `verification_status` | enum | yes | Existing verification state |
| `entra_tenant_id` | string | yes | Existing provider-owned persisted identifier; not the shared contract key after this slice |
| `metadata` | array | no | Existing legacy-identity and provider-owned metadata |
**Rules**:
- `managed_environment_id` remains the persisted scope anchor; the stale candidate move is not reopened.
- `entra_tenant_id` may remain a provider-owned stored value, but platform-core consumers must read the normalized `target_scope` contract instead of exposing this column name as shared truth.
- `metadata` remains derived/provider-owned detail and must not become a second canonical shared scope contract.
### 2. Shared Target Scope Descriptor
**Persistence**: derived
**Owner**: `ProviderConnectionTargetScopeDescriptor`
| Field | Type | Required | Notes |
|---|---|---|---|
| `provider` | string | yes | Shared provider key |
| `scope_kind` | string | yes | Current release supports `tenant` only |
| `scope_identifier` | string | yes | Neutral scope identifier used across platform-core seams |
| `scope_display_name` | string | yes | Operator-facing name for the scope |
| `shared_label` | string | yes | Current shared label, `Target scope` |
| `shared_help_text` | string | yes | Current shared help text |
**Rules**:
- This is the canonical shared `target_scope` object for connection resolution, identity resolution, audit metadata, surface summaries, onboarding readiness, and provider-operation start context.
- Shared `target_scope` payloads must not require `entra_tenant_id` as a top-level key.
- `scope_kind` remains the current `tenant` constant; this slice does not add new scope-state machinery.
### 3. Provider Context
**Persistence**: derived
**Owner**: `ProviderIdentityContextMetadata`
| Field | Type | Required | Notes |
|---|---|---|---|
| `provider` | string | yes | Provider key for the disclosed context |
| `details` | list<object> | yes | Ordered provider-owned detail items for profile, consent, audit, or troubleshooting disclosure |
**Nested `provider_context.details` item**
| Field | Type | Required | Notes |
|---|---|---|---|
| `detail_key` | string | yes | Stable provider-owned detail key such as `microsoft_tenant_id`, `admin_consent_url`, `required_permissions_url`, or `portal_domain` |
| `detail_label` | string | yes | Operator/support label |
| `detail_value` | string | yes | Current provider-owned value |
| `visibility` | string | yes | `contextual_only`, `audit_only`, or `troubleshooting_only` |
**Rules**:
- `provider_context` is the canonical nested provider-owned wrapper carried by shared identity, summary, onboarding, audit, and run-context contracts.
- Current Microsoft details include `microsoft_tenant_id`, `authority_tenant`, `redirect_uri`, consent links, required-permissions guidance, domains, and portal/profile links.
- The detail set is intentionally provider-owned and extensible; this slice does not freeze provider context to a three-key catalog.
- These values may appear in nested provider profile/context blocks or audit metadata, but they do not replace the shared `target_scope` descriptor.
### 4. Provider Identity Resolution Contract
**Persistence**: derived
**Owner**: `ProviderIdentityResolution`
| Field | Type | Required | Notes |
|---|---|---|---|
| `resolved` | bool | yes | Shared resolved/blocked status |
| `connection_type` | string | yes | Existing connection-type truth |
| `target_scope` | object | no | Canonical shared `target_scope` descriptor |
| `effective_client_identity.client_id` | string | no | Neutral shared client identity when resolved |
| `effective_client_identity.credential_source` | string | yes | Shared credential source (`platform_config`, dedicated source, legacy source) |
| `blocked_reason.reason_code` | string | no | Existing provider reason code when blocked |
| `blocked_reason.message` | string | no | Operator-facing blocked reason message |
| `provider_context` | object | yes | Nested provider-owned context wrapper with `provider` and ordered `details` |
**Rules**:
- The shared contract center is `target_scope`, effective client identity, credential source, and blocked reason.
- Legacy `tenantContext` is an implementation concern that should be absorbed into nested provider context or authority handling, not left as the primary shared contract field name.
- `clientSecret` remains runtime-only and is excluded from surface and audit contracts.
- Blocked results still return `target_scope` when it can be normalized, so surfaces keep one consistent summary even on failure.
### 5. Provider Connection Surface Summary
**Persistence**: derived
**Owner**: `ProviderConnectionSurfaceSummary`
| Field | Type | Required | Notes |
|---|---|---|---|
| `provider` | string | yes | Shared provider key |
| `target_scope` | object | yes | Canonical shared descriptor |
| `consent_state` | string | yes | Existing consent status value |
| `verification_state` | string | yes | Existing verification status value |
| `readiness_summary` | string | yes | Existing operator summary |
| `target_scope_summary` | string | yes | Shared rendered summary for UI surfaces |
| `provider_context` | object | yes | Nested provider-owned context wrapper with `provider` and ordered `details` |
| `contextual_identity_line` | string | no | Optional condensed display line derived from nested provider context |
| `is_enabled` | bool | yes | Existing enablement state for display logic |
**Rules**:
- `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and managed-environment related-context summaries must all reuse this contract.
- Default-visible content remains summary-first: target scope, readiness, consent, and verification.
- Provider-specific detail is secondary and derived from nested provider context detail only.
- Invalid target scope falls back to an explicit review-needed summary instead of leaking raw provider fields back into shared UI.
### 6. Onboarding Provider-Connection Readiness View
**Persistence**: derived
**Owner**: `ManagedTenantOnboardingWizard`
| Field | Type | Required | Notes |
|---|---|---|---|
| `provider_connection_id` | int | yes | Selected or referenced connection |
| `provider_summary` | object | yes | Reused `ProviderConnectionSurfaceSummary` payload |
| `permission_overview` | object | yes | Existing required-permissions overview including nested provider-owned `required_permissions_url` guidance |
**Rules**:
- Onboarding must reuse the same `provider_summary.target_scope` and `target_scope_summary` contract as the provider-connections resource.
- Supporting verification and permission links remain secondary and stay nested under `permission_overview.required_permissions_url` or equivalent provider-owned guidance fields.
- No onboarding-only target-scope wording or fallback structure is introduced.
### 7. Provider Operation Run Context
**Persistence**: derived run context in existing `OperationRun` rows
**Owner**: `ProviderOperationStartGate`
| Field | Type | Required | Notes |
|---|---|---|---|
| `execution_authority_mode` | string | yes | Existing execution-authority contract |
| `required_capability` | string | no | Existing capability contract |
| `provider` | string | yes | Shared provider key |
| `module` | string | yes | Existing provider-operation module |
| `provider_binding` | object | yes | Existing registry binding metadata |
| `provider_connection_id` | int | no | Existing binding identity when present |
| `target_scope` | object | yes | Canonical shared descriptor |
| `provider_context` | object | no | Nested provider-owned details when required for follow-up |
**Rules**:
- Started and blocked runs must use the same neutral shared `target_scope` schema.
- Shared run context must stop writing `target_scope.entra_tenant_id` as the primary contract.
- Provider-specific fields needed for follow-up or troubleshooting move to nested `provider_context` or equivalent provider-owned metadata.
- Current dedupe identity remains `provider_connection_id` plus existing identity inputs; this slice does not redefine run identity semantics.
### 8. Credential Scope Validation Invariant
**Persistence**: derived runtime validation only
**Owner**: `CredentialManager`
| Field | Type | Required | Notes |
|---|---|---|---|
| `provider_connection_id` | int | yes | Existing credential owner |
| `payload.client_id` | string | yes | Existing credential field |
| `payload.client_secret` | string | yes | Existing credential field |
| `payload.scope_assertion` | mixed | no | Existing payload assertion if present today |
| `normalized_target_scope_identifier` | string | yes | Derived from canonical shared descriptor |
**Rules**:
- No provider-credential schema change is introduced.
- If a payload carries a scope assertion, validation should compare it to the normalized target-scope identifier rather than leaking raw provider-column names into the platform-core error contract.
- Neutral mismatch wording belongs in the shared seam; provider-specific values remain nested metadata only.
### 9. Provider Boundary Review Record
**Persistence**: config-driven
**Owner**: `config/provider_boundaries.php`
| Field | Type | Required | Notes |
|---|---|---|---|
| `seam_key` | string | yes | Boundary seam identifier |
| `owner` | string | yes | `platform_core` or `provider_owned` |
| `neutral_terms` | list<string> | yes | Shared vocabulary allowed at the seam |
| `retained_provider_semantics` | list<string> | yes | Documented provider-specific exceptions |
| `follow_up_action` | string | yes | Existing review follow-up rule |
**Rules**:
- `provider.connection_resolution`, `provider.identity_resolution`, and `provider.operation_start_gate` remain platform-core seams and must carry only the documented provider-specific exceptions.
- `provider.gateway_runtime` remains provider-owned.
- `config/provider_boundaries.php` stays the single review record for this classification; the slice does not create a new taxonomy.
## Contract Flow
1. `ProviderConnection` is loaded inside its current workspace plus managed-environment scope.
2. `ProviderConnectionTargetScopeNormalizer` derives the canonical shared `target_scope` descriptor and the nested `provider_context` wrapper.
3. `ProviderConnectionResolver` validates enablement, consent, and supported binding using the normalized `target_scope` contract.
4. `ProviderIdentityResolver` emits one `ProviderIdentityResolution` result centered on target scope, effective client identity, and nested provider context.
5. `ProviderConnectionSurfaceSummary` renders the same summary contract for the provider-connections resource, onboarding, and related-context surfaces.
6. `ProviderOperationStartGate` records the same neutral `target_scope` contract into `OperationRun` context while nesting any provider-only detail under provider context.
7. Provider-owned consumers such as admin-consent URL shaping and Graph runtime mapping read the nested provider context they need without re-promoting those values into shared platform-core vocabulary.
## Deferred Boundaries
- No new provider implementation is introduced.
- No provider-profile table, registry, package engine, or artifact taxonomy is introduced.
- No routing work from Spec `280` is absorbed.
- No RBAC redesign, copy-neutralization, or cutover quality-gate work from Specs `282` through `287` is introduced.

View File

@ -0,0 +1,288 @@
# Implementation Plan: Provider Connection Scope & Microsoft Profile Extraction
**Branch**: `281-provider-connection-scope` | **Date**: 2026-05-07 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/281-provider-connection-scope/spec.md`
## Summary
Prepare the next reserved provider-boundary slice that keeps `ProviderConnection` as the existing managed-environment-scoped binding record but extracts one provider-neutral target-scope and effective-client-identity contract across the current platform-core seams. The narrow implementation path reuses `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionSurfaceSummary`, `ProviderConnectionResolver`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderOperationStartGate`, `CredentialManager`, `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and `config/provider_boundaries.php` while explicitly deferring Specs `282` through `287`.
This plan stays intentionally bounded. Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` stays non-globally-searchable, no `tenant_id` to `managed_environment_id` migration is reintroduced, no provider-profile table or registry appears, no routing cutover from Spec `280` is absorbed, no RBAC redesign or taxonomy work is introduced, and no compatibility alias such as shared `tenantContext` or shared `target_scope.entra_tenant_id` survives as platform-core truth.
## Inherited Baseline / Explicit Delta
### Inherited baseline
- Spec `279` already completed the core managed-environment cutover and is completed historical context only.
- Spec `280` already prepared the adjacent workspace-first routing shell and remains separate prepared context only.
- `apps/platform/app/Models/ProviderConnection.php` already anchors provider connections by `workspace_id` plus `managed_environment_id`; no `tenant_id` migration remains for this feature.
- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` already exists with `List`, `Create`, `View`, and `Edit` pages, remains `protected static bool $isGloballySearchable = false;`, and already groups mutating actions behind confirmation-protected Filament actions.
- `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php` and `apps/platform/config/provider_boundaries.php` already classify `provider.identity_resolution`, `provider.connection_resolution`, and `provider.operation_start_gate` as platform-core seams with retained Microsoft-specific exceptions that still need follow-through.
- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`, `ProviderConnectionTargetScopeNormalizer.php`, and `ProviderConnectionSurfaceSummary.php` already expose neutral field names such as `scope_kind`, `scope_identifier`, and `scope_display_name`, but they still derive those values directly from `ProviderConnection::entra_tenant_id` and still emit Microsoft contextual detail.
- `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `ProviderIdentityResolution.php`, and `PlatformProviderIdentityResolver.php` still use `tenantContext` as the shared identity field name even though the same path already exposes `credentialSource`, `effectiveClientId`, `targetScope`, and contextual detail lists.
- `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` still writes `target_scope.entra_tenant_id` for both started and blocked run context.
- `apps/platform/app/Services/Providers/CredentialManager.php` still validates credential payload scope against `entra_tenant_id` and still reports a Microsoft-shaped mismatch message from a platform-core seam.
- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `apps/platform/app/Filament/Resources/TenantResource.php` already reuse provider-connection summary and related-context seams, so they are the existing consumers that must converge on one contract rather than new surfaces that need to be invented.
### Explicit delta in this plan
- Keep `ProviderConnection`, `ProviderCredential`, `OperationRun`, and current route ownership intact; the slice is contract extraction only.
- Make `ProviderConnectionTargetScopeDescriptor` plus `ProviderConnectionTargetScopeNormalizer` the canonical shared `target_scope` contract across connection resolution, identity resolution, audit metadata, provider-operation start context, resource summaries, onboarding readiness, and related-context summaries.
- Reshape `ProviderIdentityResolution` around neutral effective-client-identity and target-scope language while confining Microsoft-specific tenant, authority, redirect, and consent semantics to nested provider-owned profile or context detail.
- Update `ProviderOperationStartGate` and the associated audit metadata path so shared `OperationRun` context no longer depends on `target_scope.entra_tenant_id` as the primary contract.
- Keep `ProviderConnectionSurfaceSummary` as the one summary adapter reused by `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and managed-environment related-context provider summaries.
- Keep provider-owned Microsoft behavior such as admin-consent URL shaping, Graph runtime option mapping, and Microsoft profile disclosure explicit and secondary instead of turning them into platform-core vocabulary.
- Keep all provider registration, asset handling, routing, RBAC, taxonomy, capability-registry, and broader copy-neutralization work deferred to adjacent specs.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12.52
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing provider-boundary config and target-scope helper seams
**Storage**: PostgreSQL, no new persistence or schema change in this slice
**Testing**: Pest feature tests, one Pest browser smoke, and focused guard coverage for Microsoft-shaped shared-contract regressions
**Validation Lanes**: fast-feedback, confidence, browser
**Target Platform**: Laravel monolith in `apps/platform`
**Project Type**: web application
**Performance Goals**: preserve current provider-connection resource, onboarding readiness, and provider-operation start responsiveness while changing only contract shaping and shared summaries; no new remote inline work or new asset load path
**Constraints**: no new table or persisted provider-profile truth, no registry or capability engine, no reintroduction of the stale `tenant_id` migration candidate, no routing cutover absorption from Spec `280`, provider registration stays in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` stays non-globally-searchable, destructive actions stay confirmation-protected, asset strategy remains unchanged, and preparation work must stay spec-only
**Scale/Scope**: one provider-neutral target-scope and identity contract across the existing provider resolution, summary, onboarding, and operation-start seams for the single current Microsoft provider implementation
## Likely Affected Repo Surfaces
- `apps/platform/app/Models/ProviderConnection.php`
- `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`
- `apps/platform/app/Services/Providers/ProviderConnectionResolution.php`
- `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`
- `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`
- `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`
- `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`
- `apps/platform/app/Services/Providers/CredentialManager.php`
- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`
- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php`
- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`
- `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php`
- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- `apps/platform/app/Filament/Resources/TenantResource.php`
- `apps/platform/app/Support/Providers/RequiredPermissionsLinks.php`
- `apps/platform/app/Services/Providers/AdminConsentUrlFactory.php`
- `apps/platform/app/Services/Providers/ProviderGateway.php`
- `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`
- `apps/platform/config/provider_boundaries.php`
- feature, browser, and guard coverage under `apps/platform/tests/Feature` and `apps/platform/tests/Browser`
## Filament v5 / Provider Resource Notes
- **Livewire v4.0+ compliance**: all touched Filament work remains on Filament v5 with Livewire v4; the slice changes summary and contract shaping only.
- **Provider registration location**: provider registration stays in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`.
- **Global search rule**: `ProviderConnectionResource` already remains `isGloballySearchable = false` and still has `View` plus `Edit` pages. No new searchable resource is introduced by this slice. If a touched searchable consumer such as `TenantResource` continues to surface provider summaries, it must keep its existing valid view destination unchanged.
- **Destructive actions**: the touched provider-connection mutations already use `Actions\Action::make(...)->action(...)` with `->requiresConfirmation()` and server-side capability checks. The relevant confirmation-protected actions currently include `set_default`, `enable_dedicated_override`, `rotate_dedicated_credential`, `delete_dedicated_credential`, `revert_to_platform`, `enable_connection`, and `disable_connection`; this slice preserves that behavior.
- **Asset strategy**: no new panel or shared asset registration is planned. Deployment guidance remains unchanged: `cd apps/platform && php artisan filament:assets` is only needed when registered assets change, and this slice adds none.
## Neutral Target-Scope & Identity Contract Fit
- Treat `ProviderConnectionTargetScopeDescriptor` as the canonical shared `target_scope` object with `provider`, `scope_kind`, `scope_identifier`, `scope_display_name`, `shared_label`, and `shared_help_text`.
- Treat `ProviderIdentityResolution` as the canonical shared identity-result object for `resolved`, `connection_type`, `effective client identity`, blocked reason, target scope, and provider-context details.
- Treat `ProviderConnectionSurfaceSummary` as the only shared summary adapter for provider-connection list/detail surfaces, onboarding readiness, and managed-environment related context.
- Treat `ProviderOperationStartGate` as the only platform-core seam allowed to shape `OperationRun` context for provider-start flows.
- Treat `ProviderIdentityContextMetadata` as provider-owned disclosure metadata. Microsoft-specific items such as `microsoft_tenant_id`, `authority_tenant`, and `redirect_uri` stay nested there or in provider-owned profile blocks instead of becoming new shared top-level contract keys.
- Treat `AdminConsentUrlFactory` and `ProviderGateway` as downstream provider-owned consumers that must adapt to the neutral shared identity result if field names change.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: mixed native Filament resource plus existing custom onboarding wizard
- **Shared-family relevance**: provider-connections resource family, managed-environment related context, onboarding readiness/provider summary, shared provider-operation feedback
- **State layers in scope**: page, detail, modal, Livewire state, related-context summaries
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: decision-first shared target-scope summary, diagnostics-second readiness and blocked-reason detail, provider-raw/profile detail third and explicitly nested
- **Raw/support gating plan**: capability-gated or contextual-only provider profile detail using existing provider-context metadata visibility
- **One-primary-action / duplicate-truth control**: `ProviderConnectionSurfaceSummary` stays the one shared summary contract so provider-connections, onboarding, and related context do not invent parallel identity stories
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory until shared seams stop exposing Microsoft field names as primary contract truth
- **Special surface test profiles**: standard-native-filament, workflow-hub, global-context-shell
- **Required tests or manual smoke**: functional-core, state-contract, manual-smoke
- **Exception path and spread control**: none; the slice removes Microsoft-shaped spread from platform-core seams instead of adding a new exception
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: provider connection resolution, identity resolution, target-scope normalization, provider-connection surface summaries, onboarding readiness summaries, provider-operation start context, provider boundary catalog, and provider-owned consent/runtime consumers
- **Shared abstractions reused**: `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionSurfaceSummary`, `ProviderConnectionResolution`, `ProviderIdentityResolution`, `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, and the existing `ProviderConnectionResource` surface contract
- **New abstraction introduced? why?**: none planned; the existing target-scope and identity seams are sufficient once their field ownership and vocabulary are corrected
- **Why the existing abstraction was sufficient or insufficient**: the repo already has the right seams and summary objects. What is insufficient is the Microsoft-shaped payload and naming that still flows through them.
- **Bounded deviation / spread control**: Microsoft-specific consent URL, authority, redirect, and profile identifiers may remain in provider-owned nested metadata and provider-runtime seams only; they must not remain the primary shared contract fields
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, start and blocked context only
- **Central contract reused**: `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, and the existing `OperationRunService` lifecycle path
- **Delegated UX behaviors**: queued or blocked start-state messaging, dedupe-or-scope-busy outcomes, run creation identity, and start-result presentation remain on the shared provider-operation path
- **Surface-owned behavior kept local**: `ProviderConnectionResource` and `ManagedTenantOnboardingWizard` continue to own only connection selection, initiation inputs, and follow-up link placement
- **Queued DB-notification policy**: `N/A` - unchanged shared policy
- **Terminal notification path**: existing central lifecycle mechanism
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes
- **Provider-owned seams**: Microsoft admin-consent URL shaping, Microsoft portal or profile disclosure, Graph runtime option mapping, and provider-context detail visibility
- **Platform-core seams**: `provider.connection_resolution`, `provider.identity_resolution`, `provider.operation_start_gate`, target-scope descriptor/normalizer, surface-summary reuse, and shared run or audit context
- **Neutral platform terms / contracts preserved**: `provider connection`, `target scope`, `scope kind`, `scope identifier`, `scope display name`, `effective client identity`, `credential source`, `provider profile`, `provider context`, `workspace`, and `managed environment`
- **Retained provider-specific semantics and why**: Microsoft tenant identifiers, authority tenant, redirect URI, required consent flow, and Graph runtime details remain necessary for the current provider implementation. They stay nested under provider-owned metadata or provider-runtime seams rather than becoming platform-core contract keys.
- **Bounded extraction or follow-up path**: Specs `282` through `287` remain the follow-up path for artifact retargeting, capability registry, taxonomy, RBAC, copy neutralization, and cutover quality gates
## Constitution Check
*GATE: Must pass before implementation begins and again after the design artifacts are complete.*
- Inventory-first / snapshot truth: PASS. The slice changes shared provider-boundary contracts only; inventory and snapshot truth are unchanged.
- Read/write separation: PASS. No new write workflow is introduced; existing provider operations keep their current confirmation, audit, and run-observability path.
- Graph contract path: PASS. No Graph endpoint or contract-registry work is added; provider-owned Graph option shaping remains behind the current provider runtime seam.
- Deterministic capabilities: PASS. Capability requirements remain the current registry-backed provider capabilities.
- RBAC-UX plane separation: PASS. `/admin` versus `/system` remains unchanged.
- Workspace isolation: PASS. `ProviderConnection` remains workspace-owned and environment-scoped; no access-boundary change is planned.
- Managed-environment isolation: PASS. Provider-connection resolution and onboarding continue to require the current managed-environment boundary.
- Destructive action discipline: PASS by preservation. Existing confirmation-protected provider-connection mutations remain confirmation-protected and server-authorized.
- Global search safety: PASS. `ProviderConnectionResource` already remains non-globally-searchable and keeps valid view/edit pages.
- OperationRun / Ops-UX: PASS. The slice reuses the shared provider-operation start path and changes only the context contract it records.
- Data minimization: PASS. No new persistence or provider-profile table is introduced; provider-specific detail remains nested metadata.
- Test governance: PASS. Proof remains bounded to focused feature coverage, one browser smoke, and one leak-guard family.
- Proportionality / no premature abstraction: PASS. The plan reuses the current seams instead of introducing a new provider framework, registry, or profile entity.
- Persisted truth / behavioral state: PASS. No new table, enum family, or taxonomy is introduced.
- UI semantics / shared pattern first / Filament-native UI: PASS. Existing resource and wizard surfaces remain the first path and keep one shared summary adapter.
- Provider boundary: PASS with implementation condition. Platform-core seams must stop treating `tenantContext` and `target_scope.entra_tenant_id` as shared truth; Microsoft detail remains explicit and nested.
**Gate evaluation**: PASS.
**Post-design re-check**: PASS while `research.md`, `data-model.md`, `quickstart.md`, `contracts/provider-connection-scope.logical.openapi.yaml`, and `checklists/requirements.md` stay aligned on the same neutral `target_scope`, effective-client-identity, provider-profile disclosure, and proving-command contract.
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature, Browser
- **Affected validation lanes**: fast-feedback, confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: the change is a shared-contract extraction across resolvers, start-gate context, and two operator-facing summary consumers. Focused feature coverage proves the contract and guard behavior, and one browser smoke proves the resource plus onboarding surfaces still tell the same connection story in the live shell.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`
- **Fixture / helper / factory / seed / context cost risks**: moderate because proof needs workspace, managed environment, provider connection, optional provider credential, and operation-run fixtures without broadening shared defaults
- **Expensive defaults or shared helper growth introduced?**: no; any new provider fixture helper should remain opt-in and feature-local
- **Heavy-family additions, promotions, or visibility changes**: none beyond one slice-specific browser smoke already justified in the spec
- **Surface-class relief / special coverage rule**: standard-native-filament relief for the resource and one workflow-hub browser proof for onboarding continuity
- **Closing validation and reviewer handoff**: rerun the exact commands above, verify that shared contract outputs now use neutral `target_scope` keys, verify that `ProviderIdentityResolution` no longer uses a shared `tenantContext` field name for platform-core truth, verify that `ProviderOperationStartGate` stopped writing shared `target_scope.entra_tenant_id`, verify that Microsoft detail appears only inside provider-owned context or profile blocks, verify that `ProviderConnectionResource` stays non-globally-searchable with View/Edit pages intact, confirm the existing destructive actions still require confirmation plus server authorization, and confirm provider registration plus asset strategy remain unchanged
- **Budget / baseline / trend follow-up**: contained feature-local increase only
- **Review-stop questions**: did a new provider-profile table or registry appear, did shared seams keep `tenantContext` or `target_scope.entra_tenant_id` as primary truth, did provider-specific profile detail escape nested metadata, did onboarding and provider-connections diverge into separate summary contracts, did the slice absorb deferred Specs `282` through `287`
- **Escalation path**: `reject-or-split` if implementation introduces new persistence, shared compatibility aliases, routing cutover work, RBAC redesign, or provider-framework machinery
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: the adjacent follow-up work is already reserved as Specs `282` through `287`; 281 only needs the bounded contract extraction itself
## Review Checklist Status
- **Review checklist artifact**: `checklists/requirements.md`
- **Review outcome class**: `implementation-ready`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Escalation rule**: if implementation preserves shared Microsoft-shaped contract fields, adds new provider persistence, or absorbs routing, registry, taxonomy, RBAC, or copy-neutralization work, flip the workflow outcome to `split` or `reject-or-split`
## Rollout Considerations
- Land target-scope extraction, identity-result reshaping, surface-summary convergence, and provider-operation context updates as one bounded implementation slice so platform-core truth changes atomically.
- Retarget provider-owned consumers such as admin-consent URL shaping only after the shared identity result is finalized, not as a separate second contract.
- Keep managed-environment related-context summaries and onboarding readiness on the same shared summary adapter before polishing any additional copy.
- Keep Spec `280` route work separate; `281` should consume the existing surfaces and not redefine their route ownership.
## Risk Controls
- Reject any implementation that reintroduces the stale `tenant_id` to `managed_environment_id` candidate work on `ProviderConnection`.
- Reject any implementation that adds a provider-profile table, registry, capability engine, package abstraction, or artifact taxonomy.
- Reject any implementation that leaves shared `tenantContext` or shared `target_scope.entra_tenant_id` in platform-core contracts as compatibility aliases.
- Reject any implementation that duplicates summary logic outside `ProviderConnectionSurfaceSummary` for provider-connections, onboarding, or related context.
- Reject any implementation that moves provider registration out of `apps/platform/bootstrap/providers.php` or adds new Filament asset registration for this slice.
## Research & Design Outputs
- `research.md` records the bounded extraction decisions for persisted truth, shared target-scope ownership, shared identity-result ownership, provider-owned Microsoft profile disclosure, start-gate context shape, and proof strategy.
- `data-model.md` captures the unchanged persisted truth plus the derived `target_scope`, effective-client-identity, provider-context, surface-summary, onboarding-readiness, and run-context contracts.
- `quickstart.md` gives reviewers the bounded package review flow and the exact proving commands.
- `contracts/provider-connection-scope.logical.openapi.yaml` captures the conceptual summary, provider-profile, onboarding-readiness, and operation-start contracts with the neutral shared `target_scope` schema.
- `checklists/requirements.md` records package readiness, boundedness, and outcome state.
## Project Structure
### Documentation (this feature)
```text
specs/281-provider-connection-scope/
├── checklists/
│ └── requirements.md
├── contracts/
│ └── provider-connection-scope.logical.openapi.yaml
├── data-model.md
├── plan.md
├── quickstart.md
├── research.md
├── spec.md
└── tasks.md
```
### Source Code (expected implementation surfaces)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ └── Workspaces/
│ │ │ └── ManagedTenantOnboardingWizard.php
│ │ └── Resources/
│ │ ├── ProviderConnectionResource.php
│ │ ├── TenantResource.php
│ │ └── ProviderConnectionResource/
│ │ └── Pages/
│ ├── Models/
│ │ └── ProviderConnection.php
│ ├── Services/
│ │ └── Providers/
│ │ ├── AdminConsentUrlFactory.php
│ │ ├── CredentialManager.php
│ │ ├── PlatformProviderIdentityResolver.php
│ │ ├── ProviderConnectionResolution.php
│ │ ├── ProviderConnectionResolver.php
│ │ ├── ProviderGateway.php
│ │ ├── ProviderIdentityResolution.php
│ │ ├── ProviderIdentityResolver.php
│ │ └── ProviderOperationStartGate.php
│ └── Support/
│ └── Providers/
│ ├── Boundary/
│ │ └── ProviderBoundaryCatalog.php
│ └── TargetScope/
│ ├── ProviderConnectionSurfaceSummary.php
│ ├── ProviderConnectionTargetScopeDescriptor.php
│ ├── ProviderConnectionTargetScopeNormalizer.php
│ └── ProviderIdentityContextMetadata.php
├── bootstrap/
│ └── providers.php
└── config/
└── provider_boundaries.php
```
**Structure decision**: keep the documentation package self-contained under `specs/281-provider-connection-scope/`; later implementation should update the existing provider resolution, summary, onboarding, and operation-start seams in place instead of introducing a parallel provider-contract subsystem.
## Complexity Tracking
No constitution violation or bloat exception is introduced by the plan. The slice removes Microsoft-shaped leakage from existing platform-core seams and adds no new persistence, abstraction family, taxonomy, or framework.
## Proportionality Review
- **Current operator problem**: the platform-core provider boundary still forces operators, audit consumers, and operation-run context to rely on Microsoft-shaped identity and scope fields even though provider connections are already modeled as managed-environment-scoped records.
- **Existing structure is insufficient because**: the current repo already has target-scope and summary helpers, but the remaining shared field names and run-context keys still encode Microsoft semantics as generic truth.
- **Narrowest correct implementation**: reuse the existing descriptor, normalizer, identity result, resource summary, onboarding readiness, and start-gate seams while replacing the Microsoft-shaped shared contract fields with neutral `target_scope` and effective-client-identity outputs.
- **Ownership cost created**: focused contract, summary, and proof updates across the listed provider seams and their tests.
- **Alternative intentionally rejected**: a new provider-profile table, provider registry, capability engine, or broader multi-provider identity framework, because none are required by current-release truth.
- **Release truth**: current-release truth; this is a bounded extraction of an already-verified hotspot, not future-provider platform preparation.

View File

@ -0,0 +1,41 @@
# Quickstart: Provider Connection Scope & Microsoft Profile Extraction
## Reviewer Flow
1. Read [spec.md](./spec.md), [plan.md](./plan.md), [research.md](./research.md), and [data-model.md](./data-model.md) together.
2. Confirm the package stays on reserved slot `281` only and treats Spec `279` as completed context and Spec `280` as adjacent prepared routing work only.
3. Confirm the verified current repo truth: `ProviderConnection` already uses `managed_environment_id`, `ProviderConnectionResource` is already non-globally-searchable with `Create`, `View`, and `Edit` pages, and `config/provider_boundaries.php` already classifies `provider.connection_resolution`, `provider.identity_resolution`, and `provider.operation_start_gate` as platform-core seams.
4. Confirm the package does not introduce a provider-profile table, registry, capability engine, or any other speculative multi-provider framework.
5. Confirm the canonical shared target-scope contract is explicit and unchanged across artifacts: `provider`, `scope_kind`, `scope_identifier`, `scope_display_name`, `shared_label`, and `shared_help_text`.
6. Confirm the shared identity-result contract is explicit and neutral: target scope, effective client identity, credential source, blocked reason, and nested provider-context details; shared `tenantContext` is not treated as the long-term platform-core field name.
7. Confirm the planned `OperationRun` context rewrite is explicit: shared `target_scope` becomes the neutral descriptor shape and provider-specific Microsoft detail moves to nested provider context or profile metadata rather than staying at `target_scope.entra_tenant_id`.
8. Confirm `ProviderConnectionSurfaceSummary` remains the single summary contract for `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and managed-environment related-context provider summaries.
9. Confirm Filament guardrails remain explicit: Filament v5 stays on Livewire v4, provider registration stays in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` stays non-globally-searchable, destructive provider-connection mutations stay confirmation-protected and server-authorized, and asset strategy remains unchanged.
10. Confirm no application implementation, test execution, or non-spec artifact modification is included in this prep package.
## Planned Validation Commands
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php)
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php)
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)
```
## Review Questions
- Does the package stay bounded to contract extraction across the current provider seams rather than drifting into a provider framework or profile table?
- Does the package explicitly avoid reintroducing the stale `provider_connections.tenant_id` move?
- Does the shared `target_scope` schema stay neutral everywhere in the package instead of carrying `entra_tenant_id` as shared truth?
- Does the identity-result contract make effective client identity and credential source explicit while keeping provider-specific Microsoft detail nested?
- Do provider-connections, onboarding, and related-context summaries all reuse one shared summary adapter?
- Does the start-gate contract clearly replace shared `target_scope.entra_tenant_id` with a neutral `target_scope` object plus nested provider context?
- Does `ProviderConnectionResource` stay non-globally-searchable while preserving `View` and `Edit` page destinations and the current confirmation-protected actions?
- Does the package keep Filament on Livewire v4, keep provider registration in `apps/platform/bootstrap/providers.php`, and avoid new asset or deployment steps?
- Do Specs `282` through `287` remain explicitly deferred rather than silently absorbed?
## Notes
- This prep package changes only planning artifacts under `specs/281-provider-connection-scope/`.
- No application implementation, tests, or runtime validation were executed while preparing the package.

View File

@ -0,0 +1,97 @@
# Research: Provider Connection Scope & Microsoft Profile Extraction
**Date**: 2026-05-07
**Branch**: `281-provider-connection-scope`
## Decision 1: Keep `281` as a contract-extraction slice, not a new provider framework
- **Decision**: limit the slice to provider-neutral target-scope and identity contract extraction across the existing `ProviderConnection`, resolver, summary, onboarding, and start-gate seams.
- **Rationale**: current repo truth already contains the right seam inventory: `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionSurfaceSummary`, `ProviderIdentityResolution`, and `ProviderOperationStartGate`. The problem is the Microsoft-shaped payload still flowing through them, not the absence of a framework.
- **Alternatives considered**:
- Introduce a provider registry or capability framework: rejected because the feature has only one current provider and the constitution explicitly prefers bounded extraction over speculative multi-provider machinery.
- Add a generic provider profile subsystem first: rejected because the current release does not need new persistence to describe provider profile detail.
## Decision 2: Treat `ProviderConnection` as the unchanged persisted source of truth
- **Decision**: `ProviderConnection` stays the only persisted binding record for this slice, anchored by `workspace_id` plus `managed_environment_id`. No `tenant_id` migration work and no provider-profile table are added.
- **Rationale**: `ProviderConnection` already belongs to `ManagedEnvironment` via `managed_environment_id`, and the raw candidate wording about replacing `tenant_id` is stale. The remaining defect is contract shaping, not relationship ownership.
- **Alternatives considered**:
- Re-open the old `tenant_id` to `managed_environment_id` candidate: rejected because repo truth already completed that move.
- Add a separate provider-profile entity: rejected because provider profile detail is still derived from the current connection and identity-resolution path.
- **Documented candidate deviation**: the raw candidate also proposed a new shared field family around `provider_key`, `external_account_id`, `provider_metadata`, and explicit run-context workspace/environment keys. In current repo truth, `provider`, `workspace_id`, and `managed_environment_id` already exist, so this package narrows the shared contract to `target_scope`, `effective_client_identity`, nested `provider_context`, and existing provider-owned metadata instead of inventing new top-level persisted fields.
## Decision 3: Make `ProviderConnectionTargetScopeDescriptor` the canonical shared `target_scope` contract
- **Decision**: standardize every shared target-scope output on `provider`, `scope_kind`, `scope_identifier`, `scope_display_name`, `shared_label`, and `shared_help_text` instead of any Microsoft-named top-level keys.
- **Rationale**: the descriptor already exists and already exposes the right field names. What is missing is consistent reuse across resolver, audit, UI summary, onboarding, and run-context seams.
- **Alternatives considered**:
- Keep `target_scope.entra_tenant_id` alongside the neutral fields: rejected because pre-production lean doctrine favors canonical replacement over shared compatibility aliases.
- Add a second DTO or presenter for neutral naming: rejected because the existing descriptor already covers the need.
## Decision 4: Reshape the shared identity result around effective client identity and nested provider context
- **Decision**: keep `ProviderIdentityResolution` as the single shared identity-result object, but move its planned contract center of gravity to `target_scope`, `effective_client_identity`, `blocked_reason`, and nested `provider_context` rather than shared `tenantContext` naming.
- **Rationale**: the current implementation already carries these concepts as `targetScope`, `effectiveClientId`, `credentialSource`, and contextual detail arrays. The shared leak is the `tenantContext` field name and its downstream use as the primary field in platform-core seams, so the package standardizes the planned artifact language on `target_scope`, `effective_client_identity`, and `provider_context`.
- **Alternatives considered**:
- Leave `tenantContext` as the canonical shared field and just add neutral labels in UI: rejected because the identity contract would remain Microsoft-shaped below the UI.
- Split dedicated and platform identities into new separate result types: rejected because one existing result object already supports both paths.
## Decision 5: Keep Microsoft-specific detail inside provider-owned profile or context metadata
- **Decision**: preserve Microsoft tenant ID, authority tenant, redirect URI, consent URL shaping, and Graph runtime options only inside provider-owned nested metadata or provider-owned consumer seams such as `ProviderIdentityContextMetadata`, `AdminConsentUrlFactory`, and `ProviderGateway`.
- **Rationale**: the boundary catalog already marks identity resolution and operation start as platform-core while runtime transport and consent URL shaping remain provider-owned. The narrowest fix is to push Microsoft semantics outward, not to pretend they disappear.
- **Alternatives considered**:
- Strip Microsoft detail from all outputs entirely: rejected because support, consent, and troubleshooting still need those fields.
- Keep Microsoft detail as top-level fields in shared summaries: rejected because that keeps the platform-core contract Microsoft-shaped.
## Decision 6: Reuse `ProviderConnectionSurfaceSummary` as the only summary adapter
- **Decision**: keep `ProviderConnectionSurfaceSummary` as the one summary contract reused by `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, and managed-environment related-context summaries.
- **Rationale**: the wizard already calls `ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary()`, and the resource already delegates summary rendering there. Reusing the same adapter is the narrowest way to remove duplicate truth.
- **Alternatives considered**:
- Let onboarding and provider-connections each compute their own neutral labels: rejected because it would create the second identity story this feature is meant to remove.
- Put raw `entra_tenant_id` back into onboarding for clarity: rejected because it would reintroduce a Microsoft-only summary path.
## Decision 7: Rewrite provider-operation start context to use neutral shared scope fields
- **Decision**: `ProviderOperationStartGate` should emit the neutral `target_scope` descriptor in started and blocked run context, with any Microsoft-specific detail nested under provider-owned provider-context metadata.
- **Rationale**: the start gate is one of the explicit boundary hotspots in `config/provider_boundaries.php`, and it still writes `target_scope.entra_tenant_id` directly today. Later artifact and taxonomy work would inherit that leak if it stays.
- **Alternatives considered**:
- Leave the start-gate context alone and only update UI summaries: rejected because `OperationRun` context is shared operational truth.
- Add neutral fields beside `entra_tenant_id`: rejected because the feature is pre-production and should replace the shared shape rather than dual-write it.
## Decision 8: Keep credential validation neutral at the platform-core seam
- **Decision**: `CredentialManager` stays the existing credential access seam, but its scope-validation rule and error wording should align to the normalized target-scope identifier rather than a Microsoft-shaped mismatch message.
- **Rationale**: `CredentialManager` is already inside the shared provider identity path and currently compares a payload scope assertion against `connection->entra_tenant_id`. That is acceptable as an implementation detail, but not as the platform-core contract wording.
- **Alternatives considered**:
- Ignore the message mismatch and leave it Microsoft-shaped: rejected because it leaks provider semantics back into the shared identity failure path.
- Change provider-credential persistence shape now: rejected because no new persistence is justified for this slice.
## Decision 9: Keep Filament surface behavior and deployment strategy unchanged
- **Decision**: `ProviderConnectionResource` remains non-globally-searchable, keeps `View` and `Edit` pages, preserves the current confirmation-protected mutations, and adds no new asset registration.
- **Rationale**: the feature changes summary and contract shaping only. The current resource already satisfies the required Filament action and search guardrails.
- **Alternatives considered**:
- Enable global search for provider connections while touching the resource: rejected because the feature does not need it and the spec explicitly keeps it out of scope.
- Add new UI chrome or asset bundles for provider profile detail: rejected because the existing resource and wizard can carry the nested disclosure.
## Decision 10: Prove the slice with focused feature coverage and one browser smoke
- **Decision**: use focused provider feature tests, one Filament/browser smoke covering provider-connections plus onboarding, and keep proof commands identical across spec, plan, and quickstart.
- **Rationale**: this slice changes shared contract truth across several PHP seams and two operator-facing consumers, but it does not justify a new heavy-governance family or broad browser matrix.
- **Alternatives considered**:
- Feature tests only: rejected because the live resource plus onboarding continuity is part of the user-visible contract.
- Broad browser or smoke expansion across unrelated provider pages: rejected because it would create unnecessary suite cost.
## Final Research Outcome
- `ProviderConnection` and `ProviderCredential` persistence stay unchanged.
- The canonical shared `target_scope` contract is the existing descriptor shape, not `entra_tenant_id`.
- The canonical shared identity result is the existing `ProviderIdentityResolution` seam, but its planned contract must stop centering `tenantContext` as shared truth and instead standardize on `target_scope`, `effective_client_identity`, and nested `provider_context`.
- Provider-specific Microsoft details remain available through `ProviderIdentityContextMetadata` and provider-owned consumers only.
- `ProviderConnectionSurfaceSummary` stays the one summary adapter for provider-connections, onboarding, and related context.
- `ProviderOperationStartGate` becomes the critical contract rewrite point for neutral run context.
- `ProviderConnectionResource` remains non-globally-searchable with confirmation-protected mutations intact.
- The narrowest honest proof is the feature suite already named in the spec, one browser smoke, and no new runtime abstractions.

View File

@ -0,0 +1,403 @@
# Feature Specification: Provider Connection, Provider Scope & Microsoft Profile Extraction
**Feature Branch**: `281-provider-connection-scope`
**Created**: 2026-05-07
**Status**: Ready
**Input**: User description: "Work only in /Users/ahmeddarrazi/Documents/projects/wt-plattform on the already-created feature branch 281-provider-connection-scope. This is preparation-only work. Do not modify application/runtime code, tests, migrations, models, routes, views, or any non-spec artifacts.
Task: fill /Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/281-provider-connection-scope/spec.md as the implementation-ready spec for candidate `281 - Provider Connection, Provider Scope & Microsoft Profile Extraction` from docs/product/spec-candidates.md and docs/product/roadmap.md.
Required repo truth and constraints:
- Use the existing style and depth of specs/280-workspace-tenancy-environment-routing/spec.md.
- Current branch/worktree safety is already checked and safe; stay on 281-provider-connection-scope.
- 279 is completed historical context; 280 is an active/prepared adjacent spec but not the target. Do not edit any existing completed or adjacent spec packages.
- The raw candidate text is partially stale: provider_connections already use managed_environment_id in repo truth, so do not claim 281 still needs the tenant_id -> managed_environment_id move. Document that as a candidate deviation.
- Current repo seams show the real remaining 281 work lives around:
- apps/platform/app/Models/ProviderConnection.php
- apps/platform/config/provider_boundaries.php
- apps/platform/app/Services/Providers/ProviderConnectionResolver.php
- apps/platform/app/Services/Providers/ProviderIdentityResolver.php
- apps/platform/app/Services/Providers/ProviderIdentityResolution.php
- apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php
- apps/platform/app/Services/Providers/ProviderOperationStartGate.php
- apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php
- apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php
- apps/platform/app/Services/Providers/CredentialManager.php
- Repo truth currently still hardcodes Microsoft-shaped identity/scope in platform-core seams via ProviderConnection::entra_tenant_id, tenantContext strings, and OperationRun context target_scope.entra_tenant_id.
- There is already provider-boundary groundwork and target-scope normalization, but no dedicated provider-profile model/table yet.
- Follow constitution rules: prefer bounded extraction over speculative multi-provider frameworks; no new persisted truth unless justified; provider-owned vs platform-core seams must be classified explicitly.
- Keep the spec narrow and implementation-ready. Prefer the smallest honest slice that normalizes provider-neutral target-scope and identity contracts while confining Microsoft-specific profile semantics to provider-owned metadata/profile seams.
- Preserve TenantPilot terminology where the repo still uses it, but make the platform-core/provider-boundary reasoning explicit.
- Include the mandatory spec-template sections, candidate gate summary, completed-spec guardrail result, assumptions, risks, in-scope/non-goals, and deferred adjacent candidates.
- Include explicit Candidate Selection Gate reasoning for why 281 is chosen now and why 282-287 are deferred.
- Include the explicit agent-output contract details relevant at spec level: Livewire v4 compliance note, provider registration location bootstrap/providers.php, globally searchable resource note if any touched resource is mentioned, destructive action confirmation note if any UI mutation is referenced, asset strategy note, testing plan summary.
- Make the final spec concrete enough that a later plan/tasks pass can generate research/data-model/quickstart/contracts without guesswork."
## Spec Candidate Check
- **Problem**: Repo truth already moved `ProviderConnection` onto `managed_environment_id`, but shared platform-core seams still treat Microsoft tenant identity as the generic provider-scope truth. `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionTargetScopeNormalizer`, `ProviderOperationStartGate`, and related operator summaries still lean on `entra_tenant_id`, `tenantContext`, and `target_scope.entra_tenant_id` as if they were neutral platform contracts.
- **Today's failure**: Operators can reach provider connections and start provider-backed work, but the shared connection identity story still depends on Microsoft-shaped field names. Onboarding readiness, provider connection summaries, audit metadata, and `OperationRun` context can all describe the same connection differently, which makes the cutover pack look provider-neutral in naming while still being Microsoft-shaped in the seams that actually decide behavior.
- **User-visible improvement**: Operators get one consistent provider-connection and target-scope story across provider connections, managed-environment detail summaries, onboarding readiness, and provider-operation follow-up. Microsoft consent, portal, and tenant-profile details remain available, but only inside clearly provider-owned profile/context disclosure instead of as the primary shared vocabulary.
- **Smallest enterprise-capable version**: Reuse the current `ProviderConnection` record, `ProviderConnectionResource`, onboarding wizard, target-scope normalizer, identity resolution path, and provider-operation start gate; standardize provider-neutral target-scope and identity outputs across those seams; move Microsoft-specific profile semantics into provider-owned metadata/profile sections; and stop writing `target_scope.entra_tenant_id` as the shared run-context contract. No new table, registry, package engine, or multi-provider framework is introduced.
- **Explicit non-goals**: No `tenant_id` -> `managed_environment_id` migration on `provider_connections`; no dedicated provider-profile table; no capability registry; no provider-neutral artifact taxonomy; no governance-artifact retargeting; no workspace-RBAC redesign; no broader copy/localization neutralization; no new provider implementation; no legacy fallback or backfill.
- **Permanent complexity imported**: One refined provider-neutral target-scope and identity contract across existing seams, one provider-owned Microsoft profile disclosure pattern on existing surfaces, and focused feature/browser coverage. No new persisted truth, registry, or extension framework is added.
- **Why now**: Spec `279` already completed the core managed-environment noun change, and Spec `280` prepares the workspace-first routing shell. The next verified blocker in the reserved cutover pack is the provider boundary itself: until `281` lands, later slices such as artifact retargeting and provider-neutral taxonomy would still inherit Microsoft-shaped shared connection and run context.
- **Why not local**: A label-only or page-local fix would leave the deciding seams untouched. `ProviderConnectionResolver`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderOperationStartGate`, and audit/run context are the real control points; if they stay Microsoft-shaped, the platform core remains Microsoft-shaped no matter how neutral the page copy becomes.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Shared provider/platform boundary, provider-operation execution context, and temptation to introduce new persistence or a speculative provider framework. Defense: this slice explicitly rejects a new table, registry, or capability framework and instead reuses the existing target-scope, identity, resource, and onboarding seams as the narrowest current-release extraction path.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields
- **Scope**: workspace
- **Primary Routes**:
- `/admin/provider-connections`
- `/admin/provider-connections/create` with `managed_environment_id` query context
- `/admin/provider-connections/{record}`
- `/admin/provider-connections/{record}/edit`
- named onboarding routes `admin.onboarding` and `admin.onboarding.draft`
- managed-environment detail and related-context entry points that deep-link into provider connections under the surviving admin panel
- **Data Ownership**:
- `ProviderConnection` remains the existing workspace-owned, managed-environment-scoped provider binding record
- `ProviderCredential` remains the existing provider-owned secret record attached to one `ProviderConnection`
- Microsoft-specific profile semantics remain provider-owned metadata/profile detail; this slice does not introduce a dedicated provider-profile table or other new persisted truth
- `OperationRun` remains execution truth and records provider-neutral target-scope fields plus provider-specific nested context only where provider-specific follow-up truly needs it
- **RBAC**:
- workspace membership remains the first isolation boundary
- managed-environment access remains the second isolation boundary
- `PROVIDER_VIEW`, `PROVIDER_MANAGE`, `PROVIDER_MANAGE_DEDICATED`, and `PROVIDER_RUN` remain the capability gates for the existing provider-connection surfaces
- non-members stay `404`, and in-scope actors missing a capability stay `403`
## Cross-Cutting / Shared Pattern Reuse
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: navigation entry points, list/detail summaries, onboarding readiness messaging, provider-operation start feedback, audit metadata, and provider-specific consent/navigation links
- **Systems touched**: `ProviderConnectionResource`, `ProviderConnectionSurfaceSummary`, `TenantResource` related-context/provider summary entries, `ManagedTenantOnboardingWizard`, `ProviderConnectionResolver`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderOperationStartGate`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionTargetScopeDescriptor`, `CredentialManager`, `RequiredPermissionsLinks`, `AdminConsentUrlFactory`, and `config/provider_boundaries.php`
- **Existing pattern(s) to extend**: the current target-scope descriptor/normalizer path, the current provider-connection resource action family, the current onboarding readiness/provider summary path, and the shared provider-operation start gate/presenter contract
- **Shared contract / presenter / builder / renderer to reuse**: `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderIdentityResolution`, `ProviderConnectionSurfaceSummary`, `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, and the existing `ProviderConnectionResource` action group/view model helpers
- **Why the existing shared path is sufficient or insufficient**: the repo already has the right shared seams. What is insufficient is not the absence of a framework, but the Microsoft-shaped payload and naming that still flows through those seams. Extending the existing path is sufficient; creating a new provider framework is not.
- **Allowed deviation and why**: one bounded deviation is allowed: Microsoft-specific tenant-profile, consent, portal, and required-permissions details may remain inside a clearly provider-owned profile/context block or nested provider metadata where current support and consent workflows need them. They must not become the shared label set for target scope, provider identity, or run context.
- **Consistency impact**: provider-connection list/detail pages, managed-environment summaries, onboarding readiness, audit metadata, and provider-operation follow-up must all describe the same connection with the same shared target-scope summary before any provider-specific detail is shown.
- **Review focus**: reviewers must verify that the shared target-scope/identity contract is reused instead of a parallel helper, that Microsoft detail is nested inside provider-owned sections only, that the provider-connection resource and onboarding wizard show the same summary contract, and that no new table or registry quietly appears.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, start/block/link semantics only
- **Shared OperationRun UX contract/layer reused**: `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, `OperationRunLinks`, and the existing `OperationRunService` lifecycle path
- **Delegated start/completion UX behaviors**: current shared start-gate and presenter paths remain responsible for queued/block/dedupe messaging, run links, and lifecycle semantics; this slice only changes the connection identity and target-scope data they carry
- **Local surface-owned behavior that remains**: `ProviderConnectionResource` and `ManagedTenantOnboardingWizard` continue to own only initiation inputs, connection selection, and current-surface follow-up links
- **Queued DB-notification policy**: `N/A` - unchanged shared policy
- **Terminal notification path**: existing central lifecycle mechanism
- **Exception required?**: none
## Provider Boundary / Platform Core Check
- **Shared provider/platform boundary touched?**: yes
- **Boundary classification**: mixed
- **Seams affected**:
- platform-core: `provider.connection_resolution`, `provider.identity_resolution`, `provider.operation_start_gate`
- provider-owned: Microsoft consent URL shaping, Microsoft portal/profile details, and Microsoft Graph runtime option mapping
- mixed UI bridge: provider-connection summaries, managed-environment related context, and onboarding readiness that expose shared target-scope truth plus provider-specific follow-up detail
- **Neutral platform terms preserved or introduced**: `provider connection`, `target scope`, `scope kind`, `scope identifier`, `scope display name`, `effective client identity`, `credential source`, `provider profile`, `provider context`, `workspace`, and `managed environment`
- **Provider-specific semantics retained and why**: Microsoft tenant directory ID, authority tenant, admin-consent callback/URL, required-permissions guidance, Graph client identity, domains, and portal links remain necessary for current consent and troubleshooting workflows. They stay provider-owned and must not be promoted into platform-core truth.
- **Why this does not deepen provider coupling accidentally**: shared platform-core seams move toward neutral `target_scope` and identity language, while Microsoft-specific profile detail is explicitly nested under provider-owned metadata/profile seams. The feature removes a Microsoft-shaped hotspot instead of creating a new generalized provider platform.
- **Follow-up path**: Spec `282` for governance-artifact retargeting consumers, Spec `283` for capability registry follow-through, Spec `284` for provider-neutral artifact taxonomy, Spec `285` for workspace-first RBAC follow-through, Spec `286` for broader operator-copy neutralization, and Spec `287` for the cutover quality-gate pack
## UI / Surface Guardrail Impact
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Provider connections resource family (`List`, `View`, `Create`, `Edit`) | yes | Native Filament resource plus shared action/presenter primitives | list/detail summary, consent/action group, provider-operation feedback | page, table, detail, modal, Livewire state | no | Reuses the existing resource, action surface declaration, and view/edit pages; the slice changes shared connection/profile semantics, not the route family |
| Managed-environment detail provider connection summary and related-context entry | yes | Native Filament resource detail plus existing related-context pattern | related navigation, summary chips, environment-scoped follow-up | detail, link state | no | Summary only; no new detail surface or action family is introduced |
| Managed-environment onboarding provider-connection step | yes | Native Filament custom wizard using existing onboarding shell and shared provider summaries | workflow selection, readiness summary, supporting links | page, wizard, session, Livewire state | no | Reuses the onboarding wizard; no second onboarding framework or connection picker is introduced |
## Decision-First Surface Role
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Provider connections resource family | Primary Decision Surface | Operator decides whether one provider connection is usable, default-worthy, or ready to run provider work | managed environment, provider, target scope, lifecycle, consent, verification | credential source, migration-review detail, provider-specific profile data, last error, and run follow-up | Primary because this is the configuration and execution surface where the operator chooses the active provider binding | Matches the existing integrations/settings workflow instead of inventing another workbench | Removes the need to jump between tenant detail, onboarding, and operations just to identify connection scope |
| Managed-environment detail provider connection summary and related-context entry | Secondary Context Surface | Operator confirms an environment has a usable provider connection and decides whether to drill in | high-level provider-connection summary and direct link | full connection detail remains on the provider-connections resource | Secondary because it advertises context and next step rather than owning the decision itself | Keeps managed-environment overview calm while still exposing the next relevant integration step | Reduces navigation search and duplicate summary cards |
| Managed-environment onboarding provider-connection step | Primary Decision Surface | Operator chooses or creates the connection that will unblock onboarding readiness | selected connection, target scope summary, consent/readiness state, next step | provider-specific profile detail, verification detail, and supporting links | Primary because onboarding cannot continue safely until the correct connection is chosen and understood | Matches the current onboarding workflow and keeps connection readiness inside it | Prevents operators from reconstructing connection identity from raw IDs or separate pages |
## Audience-Aware Disclosure
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Provider connections resource family | operator-MSP, support-platform | provider, target scope, lifecycle, consent, verification, and current default-connection truth | credential source, migration review, blocked reason, recent operation follow-up | Microsoft-specific profile identifiers, portal links, required-permissions detail, and other provider-owned troubleshooting context | `Open`, `Check connection`, or one existing primary provider action depending on surface | dedicated credential secrets, provider-owned raw detail, and support-only context stay gated or nested | target scope is stated once in the shared summary and not re-explained in every provider-specific subsection |
| Managed-environment detail provider connection summary and related-context entry | operator-MSP, support-platform | whether a connection exists, its high-level status, and where to go next | minimal summary only | none on this surface | `Open provider connections` | provider-specific profile detail stays on the provider-connections resource | the managed-environment page advertises the next step without duplicating the full connection detail card |
| Managed-environment onboarding provider-connection step | operator-MSP, support-platform | selected connection, target scope summary, consent/readiness status, and the next unblock action | verification result, blocked reason, supporting links, and run continuity | provider-specific profile detail stays behind provider-owned disclosure or support links | `Select connection`, `Create connection`, or `Continue` depending on current state | raw provider profile detail and credential data stay hidden | the wizard uses the same target-scope summary as the resource instead of inventing a second identity story |
## UI/UX Surface Classification
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Provider connections resource family | List / Detail / Integrations | CRUD / List-first Resource | Open one connection, verify it, or run provider work | clickable row to the View page | required | grouped under `More` on list/view surfaces | grouped under `More`, server-authorized, confirmation-required where mutating or dangerous | `/admin/provider-connections` | `/admin/provider-connections/{record}` and `/admin/provider-connections/{record}/edit` | managed-environment filter, workspace context, provider, target scope | Provider connection | target scope and connection health | none |
| Managed-environment detail provider connection summary and related-context entry | Detail / Related Context / Navigation | Environment detail follow-up entry | Open provider connections for the current managed environment | explicit related-context `Open` link | forbidden | none beyond the explicit open affordance | none | managed-environment detail surface with provider-connections deep link | `/admin/provider-connections?managed_environment_id={environment}` | current workspace and current managed environment | Provider connections | whether the environment is wired to a usable provider connection | none |
| Managed-environment onboarding provider-connection step | Workflow Hub / Wizard / Readiness | Workflow-step selector | Select or create the correct provider connection and continue onboarding | in-step selection and explicit create or manage actions | forbidden | supporting links and provider follow-up remain secondary | none added by this slice | named onboarding routes `admin.onboarding` and `admin.onboarding.draft` | same wizard step or draft route | current workspace, current managed environment, selected provider connection | Provider connection | selected connection and readiness state | none |
## Operator Surface Contract
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Provider connections resource family | Workspace operator | Decide whether one provider connection is the right and healthy binding for this managed environment | List/detail resource | Is this the right provider connection and is it safe to use right now? | managed environment, provider, target scope, lifecycle, consent, verification, default state | migration review, last error, credential source, provider-specific profile detail, run follow-up | lifecycle, consent, verification, migration-review | `TenantPilot only` for connection metadata and credentials, `Microsoft tenant` only when admin consent or provider execution is triggered | Open, Check connection, Inventory sync, Compliance snapshot, Edit | Set default, enable or rotate dedicated credentials, revert or delete credentials, enable or disable connection |
| Managed-environment detail provider connection summary and related-context entry | Workspace operator | Decide whether to inspect or fix provider connection state from the environment overview | Related-context summary | Do I need to drill into provider connections from this environment now? | high-level connection presence and status plus direct link | none beyond brief summary copy | lifecycle, verification | none | Open provider connections | none |
| Managed-environment onboarding provider-connection step | Workspace operator | Choose the connection that will unblock onboarding and later provider operations | Wizard step | Which provider connection should this onboarding flow use, and is it ready? | selected connection, target scope summary, readiness and consent state, next step | blocked reason, supporting verification links, provider-specific profile detail on demand | readiness, consent, verification | `TenantPilot only` for selection and onboarding draft state; provider execution remains separate | Select connection, Create connection, Continue | none added by this slice |
## Proportionality Review
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: the platform-core provider boundary still forces operators and downstream run/audit consumers to rely on Microsoft-specific scope identity even though provider connections are already modeled as managed-environment-bound records.
- **Existing structure is insufficient because**: the current structure already has shared target-scope and identity helpers, but those helpers still emit Microsoft-shaped fields and context. Leaving them untouched would keep the real platform-core contract Microsoft-specific even if page copy becomes more neutral.
- **Narrowest correct implementation**: extend the existing target-scope descriptor, identity resolution, provider-connection summary, onboarding readiness, and provider-operation start seams so they emit one neutral shared contract while keeping Microsoft profile detail nested under provider-owned metadata/profile blocks.
- **Ownership cost**: focused updates across provider resolution, run context, audit metadata, provider-connection resource summaries, onboarding readiness, and their tests. The cost stays bounded because no new table, registry, or framework is introduced.
- **Alternative intentionally rejected**: a new provider-profile table, a generic provider profile registry, or a broader multi-provider identity framework. Those options add structure without a current-release source-of-truth need and violate the constitution's bounded-extraction bias.
- **Release truth**: current-release truth; this slice closes an active platform-core/provider-boundary mismatch rather than preparing speculative future providers.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact
- **Test purpose / classification**: Feature, Browser
- **Validation lane(s)**: fast-feedback, confidence, browser
- **Why this classification and these lanes are sufficient**: feature coverage is the narrowest honest proof for provider-boundary contract changes across target-scope normalization, identity resolution, provider-operation start context, resource semantics, and onboarding readiness. One browser smoke is justified because the operator-facing resource and onboarding flow must show the same summary and action semantics in the real shell.
- **New or expanded test families**: one provider target-scope or identity feature family, one provider-operation start-gate context family, one provider-connection Filament resource behavior family, one onboarding provider-connection readiness family, and one narrow browser smoke covering the provider-connections plus onboarding path
- **Fixture / helper cost impact**: moderate. Tests need workspace, managed environment, provider connection, optional provider credential, and focused run fixtures. No new global defaults, provider registries, or browser-wide setup should become implicit.
- **Heavy-family visibility / justification**: one browser smoke only. No heavy-governance family is justified by this slice.
- **Special surface test profile**: standard-native-filament, workflow-hub, global-context-shell
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the shared contract shifts; one browser smoke is required to prove that provider-connections and onboarding show the same target-scope summary and action affordances on real surfaces.
- **Reviewer handoff**: reviewers must verify that Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` stays non-globally-searchable while continuing to offer View and Edit pages, touched destructive actions still use `->action(...)` plus `->requiresConfirmation()` and server authorization, no new asset registration appears, and the planned tests prove the shared neutral target-scope contract instead of page-local wording only.
- **Budget / baseline / trend impact**: moderate feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`
## Scope Boundaries *(required for this slice)*
### In Scope
- document the verified candidate deviation that `provider_connections` already use `managed_environment_id`
- normalize the shared provider-connection target-scope contract across connection resolution, identity resolution, audit metadata, resource summaries, onboarding readiness, and provider-operation start context
- normalize the shared provider-identity contract so platform-core seams talk about effective client identity, credential source, and target scope rather than Microsoft tenant context as generic truth
- update provider-operation start context so the shared `target_scope` payload is provider-neutral and no longer relies on `target_scope.entra_tenant_id`
- keep Microsoft-specific profile semantics in provider-owned metadata or profile detail, provider-specific links, and support-oriented disclosure
- align `ProviderConnectionResource`, managed-environment related context, and onboarding readiness on one shared target-scope summary contract
- keep current provider-connection actions, consent links, and capability boundaries intact while clarifying which ones are provider-owned versus platform-core
- keep provider-boundary seam classification explicit in `config/provider_boundaries.php` and related review proof
### Non-Goals
- repeating the already-completed `tenant_id` -> `managed_environment_id` move on `provider_connections`
- introducing a dedicated provider-profile table or any other new persisted profile truth
- introducing a provider capability registry, package engine, provider-neutral artifact taxonomy, or governance-artifact retargeting
- widening the slice into workspace-first RBAC redesign, broader copy or localization neutralization, or cutover quality-gate work reserved for Specs `285`-`287`
- enabling global search for `ProviderConnectionResource`
- adding a second provider implementation, compatibility shim, backfill path, or speculative multi-provider identity framework
## Assumptions
- Spec `279` already completed the managed-environment core cutover and is prerequisite context only.
- Spec `280` already defines the adjacent workspace-first routing shell and remains separate prepared context; `281` must not absorb its route-cutover work.
- `ProviderConnection` already remains anchored by `workspace_id` plus `managed_environment_id`, and the 281 problem is contract extraction rather than relationship migration.
- The existing `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionTargetScopeDescriptor`, `ProviderIdentityResolution`, `ProviderConnectionSurfaceSummary`, and provider-connection resource or onboarding surfaces are the correct extension points for this slice.
- Microsoft remains the only implemented provider today, but platform-core seams touched here must still use neutral terms so later provider work is not blocked.
- `ProviderConnectionResource` remains non-globally-searchable; touched searchable resources such as `TenantResource` keep their existing valid view destinations.
## Risks
- downstream run, audit, or support readers may still expect `target_scope.entra_tenant_id` even after the start gate writes the neutral shape
- some operator surfaces may migrate to the new target-scope summary while others still read raw `entra_tenant_id`, leaving the same connection described differently
- dedicated credential validation and blocked reason messaging may stay Microsoft-shaped if `CredentialManager` and identity-resolution seams are not updated together
- the slice could sprawl into capability-registry, taxonomy, or copy-neutralization work if reviewers allow 283, 284, or 286 concerns to enter the implementation
## Candidate Selection Gate Summary
- **Selected candidate**: `281 - Provider Connection, Provider Scope & Microsoft Profile Extraction`
- **Source locations**:
- `docs/product/spec-candidates.md` under the reserved workspace-first or provider-neutral cutover pack
- `docs/product/roadmap.md` under the same cutover ordering
- **Why selected now**: after the completed `279` core cutover and the prepared `280` routing cutover, the next verified blocker is the provider boundary itself. The repo already has target-scope groundwork, but shared provider connection and run identity still leak Microsoft semantics in the platform core.
- **Why close alternatives were deferred**:
- `282` depends on `281` because governance-artifact retargeting should inherit provider-neutral connection and run scope instead of another Microsoft-shaped shared contract
- `283` is capability-registry follow-through and should not be pulled into this slice while the connection or profile contract is still being normalized
- `284` is a broader artifact-source taxonomy and belongs after the provider-connection and run-scope boundary is neutralized
- `285` is workspace-first RBAC follow-through and can remain on the current capability model while `281` closes the connection or profile hotspot
- `286` is broader operator-copy and localization neutralization and should follow the concrete provider-boundary extraction rather than precede it
- `287` is the cutover quality-gate pack and should harden the finished slices instead of expanding `281`
- **Smallest viable implementation slice**: one neutral shared target-scope and identity contract across existing provider seams plus provider-owned Microsoft profile disclosure on the current resource and onboarding surfaces
- **Documented deviations from raw candidate wording**:
- the raw candidate still mentions replacing `provider_connections.tenant_id` with `provider_connections.managed_environment_id`, but repo truth already completed that move
- the raw candidate also proposed a new shared field family around `provider_key`, `external_account_id`, `provider_metadata`, and explicit run-context workspace/environment keys; this package narrows that to existing repo truth by keeping `provider`, `workspace_id`, and `managed_environment_id` where they already exist, using the canonical shared `target_scope` plus `effective_client_identity`, and confining provider-specific identifiers or profile detail to nested `provider_context` and existing provider-owned metadata
## Completed-Spec Guardrail Result
- `specs/279-workspace-managed-environment-core/spec.md` already exists with `Status: Ready with approved feature-local exception` and remains historical prerequisite context only
- `specs/280-workspace-tenancy-environment-routing/spec.md` already exists with `Status: Ready` and remains an adjacent prepared package only; its route-cutover work must not be absorbed into `281`
- the target package `specs/281-provider-connection-scope/spec.md` existed only as the raw template before this update and is the sole spec artifact edited in this package
## Deferred Adjacent Candidates
- `282 - Governance Artifact Retargeting to ManagedEnvironment`
- `283 - Provider Capability Registry v1`
- `284 - Provider-neutral Artifact Source Taxonomy v1`
- `285 - Workspace-first RBAC & Environment Access Scoping`
- `286 - UI Copy, IA & Localization Neutralization`
- `287 - Cutover Quality Gates & No-Legacy Enforcement`
## User Scenarios & Testing
### User Story 1 - Inspect a provider connection with one neutral target-scope summary (Priority: P1)
As an operator, I want provider-connection list and detail surfaces to tell me immediately which managed environment and target scope a connection represents without forcing me to interpret a Microsoft-only field name before I can decide whether the connection is usable.
**Why this priority**: this is the core operator-facing trust problem in the current seam. If the shared summary remains Microsoft-shaped, later provider-neutral cutover work keeps inheriting that drift.
**Independent Test**: open the provider-connections list and one connection detail page for a managed environment, then confirm the shared summary shows provider, target scope, and health first while Microsoft profile detail stays nested under a provider-owned disclosure.
**Acceptance Scenarios**:
1. **Given** a managed environment has a provider connection, **When** the operator opens the provider-connections list or detail page, **Then** the default-visible summary shows provider, target scope, lifecycle, consent, and verification without requiring a Microsoft-only field label to understand the connection identity.
2. **Given** the operator needs provider-specific consent or troubleshooting data, **When** they open the provider-owned profile or context disclosure on the connection detail surface, **Then** Microsoft-specific details appear there without replacing the shared target-scope label set.
---
### User Story 2 - Start provider work without Microsoft-shaped shared run context (Priority: P1)
As an operator, I want connection checks and provider operations started from provider-connection or onboarding surfaces to record provider-neutral target-scope context so later run follow-up, audit, and support flows do not depend on Microsoft-only keys.
**Why this priority**: `ProviderOperationStartGate` is one of the verified remaining hotspots, and later artifact and taxonomy work depends on this shared execution context becoming neutral first.
**Independent Test**: start one provider-backed operation from a provider connection and verify that the resulting run can be identified by provider connection and target scope without relying on `target_scope.entra_tenant_id` as the shared contract.
**Acceptance Scenarios**:
1. **Given** an operator starts `Check connection`, `Inventory sync`, or `Compliance snapshot` from a valid provider connection, **When** the run is created, **Then** the resulting run context identifies provider connection and target scope through provider-neutral shared fields, with any Microsoft-specific detail nested under provider-owned context only.
2. **Given** a provider operation is blocked because consent, scope, or credentials are invalid, **When** the blocked result is shown, **Then** the operator still sees a usable target-scope summary and blocked reason without the shared contract collapsing back to Microsoft-only field names.
---
### User Story 3 - See the same connection story in onboarding and provider settings (Priority: P2)
As an operator, I want the onboarding wizard to describe provider connections with the same target-scope summary used on the provider-connections resource so I do not need to reinterpret the same connection differently just because I am in onboarding.
**Why this priority**: onboarding is already one of the verified seams reusing provider-connection summaries and audit metadata. Drift here would immediately create a second identity language.
**Independent Test**: select or create a provider connection from the onboarding wizard and confirm that the displayed target-scope summary matches the one shown on the provider-connections resource for the same record.
**Acceptance Scenarios**:
1. **Given** onboarding lists multiple provider connections for the current managed environment, **When** the operator reviews the choices, **Then** each option uses the same target-scope summary contract as the provider-connections list and detail surfaces.
2. **Given** onboarding shows provider verification or supporting links for the selected connection, **When** the operator inspects that state, **Then** the readiness surface uses the same shared target-scope and provider-context labels as the provider-connections resource.
---
### User Story 4 - Jump from managed-environment detail into provider connections without duplicate truth (Priority: P3)
As an operator, I want the managed-environment detail page to advertise provider-connection state and take me into the provider-connections resource without duplicating the full provider profile summary on the overview page.
**Why this priority**: this is the boundary between context surfaces and primary decision surfaces. The environment overview should stay calm while still exposing the integration step.
**Independent Test**: open one managed-environment detail page, use its provider-connections related-context entry, and verify that the overview stays summary-first while the provider-connections resource becomes the primary decision surface.
**Acceptance Scenarios**:
1. **Given** a managed environment has an accessible provider connection, **When** the operator uses the provider-connections related-context entry from the environment view, **Then** the destination opens the provider-connections resource scoped to that environment and carries the same shared target-scope summary.
2. **Given** the operator stays on the managed-environment overview page, **When** they read the provider-connection summary there, **Then** the page shows only the minimal connection truth needed to decide whether to drill in and does not duplicate full provider-profile detail.
### Edge Cases
- A provider connection with missing or invalid target-scope input must stay blocked explicitly and must not fall back to an implicit Microsoft tenant context.
- An unsupported provider or scope combination must fail as an unsupported binding or invalid connection rather than being normalized into a Microsoft-shaped default.
- A dedicated credential payload whose embedded environment or scope hint no longer matches the connection's normalized target scope must fail validation rather than silently reusing stale Microsoft identity.
- Multiple default provider connections for the same managed environment and provider must remain blocked and clearly diagnosable.
- A provider connection record or onboarding selection outside the current workspace or managed environment must resolve as `404` or unavailable, not as a leaked or silently widened option.
- If provider-specific profile data such as domains or portal links are absent, the shared target-scope summary must still render truthfully without forcing a new persisted profile table into scope.
## Requirements
**Constitution alignment (required):** This slice changes provider connection resolution, identity resolution, provider-operation start context, provider-connection admin surfaces, and onboarding readiness. It does not introduce new Microsoft Graph contract registry entries, new provider implementations, or new long-running workflow types.
**Constitution alignment (PROP-001 / PROV-001 / BLOAT-001):** The slice must stay bounded to current provider-connection and run-context hotspots. No new table, registry, or generic provider framework is justified unless later planning can prove a current-release source-of-truth need.
**Constitution alignment (XCUT-001 / UI-FIL-001 / DECIDE-001):** The slice must reuse the existing provider-connections resource, existing onboarding wizard, and existing provider-operation start path. It may refine shared summaries and provider-owned detail disclosure, but it must not invent a second provider workbench, a second onboarding picker, or ad-hoc status styling.
**Constitution alignment (RBAC-UX):** Workspace and managed-environment membership remain the isolation boundaries. Provider manage or run mutations stay server-authorized and confirmation-protected where destructive or high-impact. Navigation-only actions such as `Grant admin consent` stay capability-gated but do not claim confirmation behavior.
**Constitution alignment (TEST-GOV-001 / OPS-UX-START-001):** Proof must stay bounded to feature tests plus one browser smoke. Operation start behavior must continue to flow through the shared provider-operation gate or presenter path, with the only change being the shared target-scope and provider-context contract.
### Functional Requirements
- **FR-001**: The system MUST preserve `ProviderConnection` as the existing workspace-owned, managed-environment-scoped provider binding record and MUST NOT reintroduce `tenant_id` semantics into `provider_connections`.
- **FR-002**: Shared provider-connection target-scope output MUST use provider-neutral fields for provider, scope kind, scope identifier, and scope display name wherever the platform core records or renders connection scope.
- **FR-003**: `ProviderConnectionResolver` and related validation paths MUST treat provider plus managed environment plus normalized target scope as the controlling scope contract, not `entra_tenant_id` as generic platform truth.
- **FR-004**: Shared provider identity resolution MUST separate neutral identity semantics such as effective client identity, credential source, and target scope from provider-specific profile or context detail.
- **FR-005**: `ProviderIdentityResolution` and `PlatformProviderIdentityResolver` MUST stop using `tenantContext` as the canonical shared contract term and MUST confine any remaining Microsoft tenant-context semantics to provider-owned profile or context detail.
- **FR-006**: `CredentialManager` validation MUST align dedicated credentials with the connection's normalized target scope and provider binding rather than treating a Microsoft-specific field as the only source of truth.
- **FR-007**: `ProviderConnectionTargetScopeNormalizer` and `ProviderConnectionTargetScopeDescriptor` MUST continue to block unsupported provider or scope combinations explicitly and MUST emit the same shared summary contract used by resource, onboarding, audit, and run surfaces.
- **FR-008**: Provider-connection audit metadata MUST use the neutral target-scope descriptor fields in shared audit context and MUST keep Microsoft-specific identity context nested under provider-owned detail only.
- **FR-009**: `ProviderOperationStartGate` MUST write provider-neutral `target_scope` fields into `OperationRun` context and MUST NOT use `target_scope.entra_tenant_id` as the shared contract key.
- **FR-010**: Any Microsoft-specific scope or profile data still needed by provider-operation follow-up, consent, or support flows MUST live inside a clearly provider-owned nested context or metadata block rather than in the shared `target_scope` shape.
- **FR-011**: `ProviderConnectionResource` list, create, view, and edit surfaces MUST show neutral target-scope labels and summaries in their shared columns, sections, and helper text while moving Microsoft-specific profile detail into provider-owned disclosure.
- **FR-012**: `ProviderConnectionResource` MUST remain non-globally-searchable in this slice. Its existing View and Edit pages remain the canonical inspect and mutation surfaces.
- **FR-013**: `TenantResource` managed-environment detail summaries and related-context entries that advertise provider connections MUST use the same shared target-scope summary contract as `ProviderConnectionResource`.
- **FR-014**: `ManagedTenantOnboardingWizard` MUST use the same shared target-scope summary contract for provider-connection options, readiness state, and supporting audit metadata as the provider-connections resource.
- **FR-015**: Provider-specific consent, required-permissions, portal, and troubleshooting detail MUST remain provider-owned guidance and MUST NOT redefine shared platform nouns such as `workspace`, `managed environment`, `provider connection`, or `target scope`.
- **FR-016**: The implementation MUST NOT introduce a dedicated provider-profile table, a provider-profile registry, a capability registry, a provider-neutral artifact taxonomy, or any other framework work reserved for Specs `282`-`287`.
- **FR-017**: The feature MUST preserve the existing provider-connection action family and provider-operation start entry points, changing only the shared connection or profile semantics they expose.
- **FR-018**: `config/provider_boundaries.php` and any related review proof touched by implementation MUST classify the remaining Microsoft-specific exceptions explicitly instead of leaving them implicit in platform-core seams.
### Authorization and Safety Requirements
- **AR-001**: Workspace membership MUST remain the first access boundary for provider-connections and onboarding provider-connection flows.
- **AR-002**: Managed-environment entitlement MUST remain the second access boundary for provider-connections list, detail, create, or edit access and onboarding connection selection.
- **AR-003**: Non-members or cross-workspace or cross-environment access attempts MUST resolve as `404`, while in-scope actors missing provider capabilities MUST resolve as `403`.
- **AR-004**: Mutating provider-connection actions such as setting default, enabling dedicated override, rotating or deleting credentials, reverting to platform, and enabling or disabling the connection MUST remain server-authorized and use confirmation where the existing action contract requires it.
- **AR-005**: Navigation-only consent actions such as `Grant admin consent` MUST remain capability-gated and truthful about being navigation, not mutation.
- **AR-006**: Provider-operation starts triggered from the touched surfaces MUST continue to use the shared provider-operation gate and existing capability checks before any run is created.
### Non-Functional Requirements
- **NFR-001**: Filament remains v5 on Livewire v4.
- **NFR-002**: Provider registration remains in `apps/platform/bootstrap/providers.php`; this slice does not move any provider or panel registration into `bootstrap/app.php`.
- **NFR-003**: Asset strategy remains unchanged. No new panel or shared asset registration is expected; if a later implementation registers assets unexpectedly, deployment continues to use `cd apps/platform && php artisan filament:assets`.
- **NFR-004**: `ProviderConnectionResource` remains non-globally-searchable, and any touched searchable resource such as `TenantResource` must keep its valid view destination intact.
- **NFR-005**: The feature must stay reviewable as one bounded slice and must not silently absorb route-cutover work from Spec `280` or capability, taxonomy, RBAC, copy, or quality-gate work from Specs `282`-`287`.
- **NFR-006**: The touched Filament surfaces must continue to follow the current TenantPilot enterprise UI standard, existing action hierarchy, and native Filament semantics rather than introducing local card, button, or status styling.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List or Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create or Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Provider connections list | `ProviderConnectionResource` -> `ListProviderConnections` | `New connection` | `recordUrl()` clickable row to View | `More` group containing `Edit`, `Check connection`, `Inventory sync`, `Compliance snapshot`, `Set as default`, `Enable dedicated override`, `Rotate dedicated credential`, `Delete dedicated credential`, `Revert to platform`, `Enable connection`, `Disable connection` | none | `New connection` | `N/A` | `N/A` | existing mutation actions already write audit logs where required | Resource remains non-globally-searchable and keeps one inspect model via clickable row |
| Provider connection view and edit surfaces | `ProviderConnectionResource` -> `ViewProviderConnection`, `EditProviderConnection`, `CreateProviderConnection` | `Grant admin consent` plus existing `More` action group on View; existing Edit or Create header behavior stays native | `N/A` | none beyond the shared `More` group on View or Edit | none | `N/A` | `Grant admin consent`, `More` | native save or cancel flow | existing mutation actions already write audit logs where required | `Grant admin consent` is navigation-only; mutating actions in `More` remain server-authorized and confirmation-protected where dangerous |
| Managed-environment onboarding provider-connection step | `ManagedTenantOnboardingWizard` provider-connection selection or readiness step | none | explicit in-step selector and create or manage actions only | none | none | existing onboarding create or select affordances only | `N/A` | wizard save or continue semantics only | existing onboarding connection changes already write audit logs | This slice changes summary semantics and supporting links only; it does not introduce a second onboarding action family |
### Key Entities *(include if feature involves data)*
- **Provider Connection**: the existing runtime binding between one managed environment and one provider, including connection type, default state, consent state, verification state, and attached credentials.
- **Target Scope Descriptor**: the shared neutral summary of what platform scope a provider connection represents, including provider, scope kind, scope identifier, and scope display name.
- **Provider Identity Resolution**: the shared runtime result that determines effective client identity, credential source, target scope, and blocked or resolved state for one provider connection.
- **Provider Profile Detail**: provider-owned Microsoft-specific metadata and guidance such as tenant directory identity, consent or required-permissions links, domains, portal links, and similar follow-up detail that must remain secondary to the shared target-scope summary.
- **Provider Operation Context**: the `OperationRun` identity and context payload created from a provider-backed start path, linking one run to one provider connection and one normalized target scope.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of affected default-visible provider-connection summaries use neutral target-scope wording instead of requiring a Microsoft-specific field label as the primary identity signal.
- **SC-002**: An operator can move from managed-environment detail or onboarding to the correct provider-connection detail surface and identify the connection's target scope in 3 interactions or fewer.
- **SC-003**: 100% of provider operations started from the affected surfaces carry enough shared provider-connection and target-scope information that follow-up run and audit surfaces can identify the target without reconstructing it from Microsoft-only context.
- **SC-004**: Microsoft-specific consent and profile details remain accessible for troubleshooting and admin-consent follow-up without becoming the primary visible identity vocabulary on affected shared platform surfaces.

View File

@ -0,0 +1,214 @@
---
description: "Task list for Provider Connection Scope & Microsoft Profile Extraction"
---
# Tasks: Provider Connection Scope & Microsoft Profile Extraction
**Input**: Design documents from `specs/281-provider-connection-scope/`
**Prerequisites**: `specs/281-provider-connection-scope/spec.md`, `specs/281-provider-connection-scope/plan.md`, `specs/281-provider-connection-scope/checklists/requirements.md`, `specs/281-provider-connection-scope/research.md`, `specs/281-provider-connection-scope/data-model.md`, `specs/281-provider-connection-scope/quickstart.md`, and `specs/281-provider-connection-scope/contracts/provider-connection-scope.logical.openapi.yaml`
**Implementation Posture**: Runtime work, targeted test execution, browser smoke, and dirty-file formatting validation have been completed for this package.
**Tests**: REQUIRED (Pest). Keep proof bounded to `apps/platform/tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php`, `apps/platform/tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php`, `apps/platform/tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php`, `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php`, `apps/platform/tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php`, and `apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php`.
**Operations**: No new `OperationRun` family. Reuse `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, the existing `ProviderOperationStartResultPresenter` path, `OperationUxPresenter`, and the current `OperationRunService` lifecycle ownership. This slice only changes the shared target-scope and identity context carried through that existing provider-operation path.
**RBAC**: Workspace membership remains the first `404` boundary, managed-environment access remains the second `404` boundary, and `PROVIDER_VIEW`, `PROVIDER_MANAGE`, `PROVIDER_MANAGE_DEDICATED`, and `PROVIDER_RUN` denials remain `403`.
**Shared Pattern Reuse**: Reuse `ProviderConnectionTargetScopeDescriptor`, `ProviderConnectionTargetScopeNormalizer`, `ProviderConnectionSurfaceSummary`, `ProviderConnectionResolver`, `ProviderIdentityResolution`, `ProviderOperationStartGate`, `ProviderConnectionResource`, and `ManagedTenantOnboardingWizard`. Do not introduce app-wide provider frameworks, provider-profile tables, capability registries, artifact taxonomy work, RBAC redesign, routing cutover work, or copy-neutralization work.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`. `ProviderConnectionResource` remains non-globally-searchable while keeping `View` and `Edit` pages. Any touched destructive action must continue to use `->action(...)`, `->requiresConfirmation()`, and current server authorization. Asset strategy stays unchanged.
**Compatibility Posture**: Reject shared `tenantContext` as canonical platform-core naming, reject shared `target_scope.entra_tenant_id`, reject dual-write compatibility aliases for Microsoft-shaped shared truth, and keep Specs `282` through `287` deferred.
**Organization**: Tasks are grouped by user story so provider-connection summary truth, provider-operation context, onboarding convergence, and managed-environment related-context drill-in stay independently testable.
**Review Outcome**: `implementation-ready`
**Workflow Outcome**: `keep`
**Test-governance Outcome**: `keep`
## Test Governance Checklist
- [x] Lane assignment stays `fast-feedback`, `confidence`, and one narrow `browser` lane.
- [x] New or changed tests stay in the named feature and browser files only.
- [x] Workspace, managed-environment, provider-connection, optional credential, and run fixtures remain explicit and opt-in; no new defaults, registry setup, or provider-profile persistence is planned.
- [x] Planned validation commands match `spec.md`, `plan.md`, and `quickstart.md` exactly.
- [x] `standard-native-filament`, `workflow-hub`, and `global-context-shell` expectations stay explicit for touched surfaces.
- [x] Any attempt to absorb Specs `282` through `287` resolves as `split` or `reject-or-split`, not hidden follow-up inside `281`.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the bounded provider-boundary inventory, the proof files, and the explicit deferred-scope posture before implementation begins.
- [x] T001 Review `specs/281-provider-connection-scope/spec.md`, `specs/281-provider-connection-scope/plan.md`, `specs/281-provider-connection-scope/checklists/requirements.md`, `specs/281-provider-connection-scope/research.md`, `specs/281-provider-connection-scope/data-model.md`, `specs/281-provider-connection-scope/quickstart.md`, and `specs/281-provider-connection-scope/contracts/provider-connection-scope.logical.openapi.yaml` together so implementation stays on Spec `281` only.
- [x] T002 [P] Confirm the current persisted-truth and Filament guardrail seams in `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` before changing shared summaries.
- [x] T003 [P] Confirm the current shared target-scope and surface-summary seams in `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, and `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php`.
- [x] T004 [P] Confirm the current provider-resolution and identity-result seams in `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolution.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`, and `apps/platform/app/Services/Providers/CredentialManager.php`.
- [x] T005 [P] Confirm the current provider-operation and provider-owned consumer seams in `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, `apps/platform/app/Services/Providers/AdminConsentUrlFactory.php`, `apps/platform/app/Support/Links/RequiredPermissionsLinks.php`, and `apps/platform/app/Services/Providers/ProviderGateway.php`.
- [x] T006 [P] Confirm the current provider-boundary classification and deferred-scope limits in `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`, `apps/platform/config/provider_boundaries.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and `specs/281-provider-connection-scope/checklists/requirements.md` so Specs `282` through `287` remain explicitly out of scope.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the proving suite and the canonical shared target-scope or provider-boundary ownership that every story depends on.
**Critical**: No user-story work should begin until this phase is complete.
- [x] T007 [P] Add failing coverage in `apps/platform/tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php` for the neutral target-scope contract across `ProviderConnectionResolver`, `ProviderConnectionSurfaceSummary`, and `ProviderOperationStartGate` so the same provider connection does not produce parallel Microsoft-shaped shared truth.
- [x] T008 [P] Add failing coverage in `apps/platform/tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php` for the `ProviderIdentityResolution` contract shift away from shared `tenantContext` naming and toward `target_scope`, effective client identity, credential source, blocked reason, and nested provider context.
- [x] T009 [P] Add failing coverage in `apps/platform/tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php` for started and blocked provider operations to use neutral shared `target_scope` context plus nested provider context instead of shared `target_scope.entra_tenant_id`.
- [x] T010 [P] Add failing guard coverage in `apps/platform/tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php` for Microsoft-shaped shared-contract regressions, including shared `tenantContext`, shared `target_scope.entra_tenant_id`, or new compatibility aliases reappearing in platform-core seams.
- [x] T011 [P] Add the narrow browser smoke in `apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php` for the managed-environment related-context entry, the provider-connections detail summary, and onboarding summary continuity under the live Filament shell.
- [x] T012 Reconcile shared seam ownership in `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`, `apps/platform/config/provider_boundaries.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php`, and `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php` so later story work consumes one canonical neutral `target_scope` contract and keeps Microsoft profile detail nested.
**Checkpoint**: The proving suite exists, the platform-core versus provider-owned boundary is explicit, and later stories can reuse one canonical neutral target-scope contract without reopening Specs `282` through `287`.
---
## Phase 3: User Story 1 - Inspect a provider connection with one neutral target-scope summary (Priority: P1)
**Goal**: Provider-connections list and detail surfaces tell the operator immediately which managed environment and target scope a connection represents without forcing Microsoft-specific field names into the shared summary.
**Independent Test**: Open the provider-connections list and one connection detail page for a managed environment, then confirm the default-visible summary shows provider, target scope, and health first while Microsoft profile detail stays nested under provider-owned disclosure.
### Tests for User Story 1
- [x] T013 [P] [US1] Extend `apps/platform/tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php` after T012 to prove `ProviderConnectionResolver` and `ProviderConnectionSurfaceSummary` return the same neutral `target_scope` contract for valid, blocked, and review-needed connections without depending on shared `entra_tenant_id` naming.
- [x] T014 [P] [US1] Extend `apps/platform/tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php` after T012 to prove `ProviderConnectionResource` list, view, and edit context surfaces show provider, target scope, lifecycle, consent, and verification first, keep provider-owned Microsoft profile detail secondary, and remain non-globally-searchable with `View` and `Edit` destinations intact.
### Implementation for User Story 1
- [x] T015 [US1] Update `apps/platform/app/Services/Providers/ProviderConnectionResolver.php` and `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` so the platform-core connection-resolution contract publishes the canonical neutral `target_scope` descriptor instead of shared Microsoft-shaped naming.
- [x] T016 [US1] Update `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php` and `apps/platform/app/Models/ProviderConnection.php` only as needed so target-scope summary text derives from the canonical descriptor and still handles invalid or review-needed scope states explicitly.
- [x] T017 [US1] Update `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` so provider-connections surfaces converge on the shared summary adapter, keep provider profile or context detail nested, and preserve current confirmation-protected destructive actions.
**Checkpoint**: Provider-connections list and detail surfaces now tell one neutral target-scope story before any provider-specific profile detail is disclosed.
---
## Phase 4: User Story 2 - Start provider work without Microsoft-shaped shared run context (Priority: P1)
**Goal**: Provider-backed operations started from provider-connections or onboarding surfaces record neutral shared target-scope context so later run follow-up, audit, and support flows do not depend on Microsoft-only keys.
**Independent Test**: Start one provider-backed operation from a provider connection and verify that the resulting run can be identified by provider connection and target scope without relying on shared `target_scope.entra_tenant_id`.
### Tests for User Story 2
- [x] T018 [P] [US2] Extend `apps/platform/tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php` after T012 to prove shared identity results no longer expose `tenantContext` as platform-core truth and instead expose `target_scope`, effective client identity, credential source, blocked reason, and nested provider context across platform and dedicated credential paths.
- [x] T019 [P] [US2] Extend `apps/platform/tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php` after T012 to prove started and blocked results record neutral `target_scope` plus nested provider context details and never require shared `target_scope.entra_tenant_id`.
### Implementation for User Story 2
- [x] T020 [US2] Update `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, and `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php` so the canonical shared identity contract shifts away from `tenantContext` naming while preserving provider-owned Microsoft detail as nested context only.
- [x] T021 [US2] Update `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php` so shared `OperationRun` context, blocked or start feedback, and shared audit metadata carry the canonical neutral `target_scope` descriptor plus nested provider context.
- [x] T022 [US2] Update `apps/platform/app/Services/Providers/CredentialManager.php`, `apps/platform/app/Services/Providers/AdminConsentUrlFactory.php`, and `apps/platform/app/Services/Providers/ProviderGateway.php` only where required to consume the shifted identity contract without reintroducing shared `tenantContext` or shared `target_scope.entra_tenant_id` aliases.
**Checkpoint**: Provider-operation start and blocked flows now carry one neutral shared run-context contract, with Microsoft detail available only through nested provider-owned context.
---
## Phase 5: User Story 3 - See the same connection story in onboarding and provider settings (Priority: P2)
**Goal**: The onboarding wizard describes provider connections with the same target-scope summary used on the provider-connections resource so operators do not need to reinterpret the same connection in a second vocabulary.
**Independent Test**: Select or create a provider connection from onboarding and confirm that the displayed target-scope summary matches the one shown on the provider-connections resource for the same record.
### Tests for User Story 3
- [x] T023 [P] [US3] Extend `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php` after T012 to prove onboarding option lists and selected-connection readiness reuse the same `ProviderConnectionSurfaceSummary` target-scope contract as the provider-connections resource for the same record, and that provider-owned required-permissions guidance stays nested under `permission_overview.required_permissions_url` rather than becoming a second shared summary path.
### Implementation for User Story 3
- [x] T024 [US3] Update `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and confirm `apps/platform/app/Support/Links/RequiredPermissionsLinks.php` required no code change so connection selection, readiness, verification, and required-permissions guidance consume the shared `ProviderConnectionSurfaceSummary` contract while keeping provider-owned guidance nested under `permission_overview` instead of local identity wording.
- [x] T025 [US3] Update `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` together where needed so `ProviderConnectionResource` and onboarding converge on one summary presenter rather than parallel formatting branches.
**Checkpoint**: Provider-connections and onboarding now describe the same connection with the same summary contract and supporting detail hierarchy.
---
## Phase 6: User Story 4 - Jump from managed-environment detail into provider connections without duplicate truth (Priority: P3)
**Goal**: The managed-environment detail page advertises provider-connection state and takes operators into the provider-connections resource without duplicating the full provider profile summary on the overview page.
**Independent Test**: Open one managed-environment detail page, use its provider-connections related-context entry, and verify that the overview stays summary-first while the provider-connections resource becomes the primary decision surface.
### Tests for User Story 4
- [x] T026 [P] [US4] Extend `apps/platform/tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php` after T012 to prove the managed-environment detail entry opens provider connections scoped to the current environment and the live shell still shows the same shared target-scope summary without duplicating full provider profile detail on the overview page.
### Implementation for User Story 4
- [x] T027 [US4] Update `apps/platform/app/Filament/Resources/TenantResource.php` so managed-environment related-context provider summaries stay minimal, reuse the shared target-scope summary contract, and deep-link into `ProviderConnectionResource` with the current `managed_environment_id` context.
- [x] T028 [US4] Update `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` and `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php` only as needed so environment-scoped deep links preserve the same shared target-scope summary and keep provider-profile disclosure on the primary decision surface instead of the overview page.
**Checkpoint**: Managed-environment detail stays calm and summary-first, while provider-connections remains the primary decision surface for connection identity and troubleshooting.
---
## Phase 7: Polish & Cross-Cutting Validation
**Purpose**: Run the exact bounded proof set, perform the final Filament and provider-boundary review, and confirm the package stayed inside Spec `281`.
- [x] T029 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderConnectionTargetScopeNeutralityTest.php tests/Feature/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Feature/Providers/ProviderOperationStartGateTargetScopeContextTest.php tests/Feature/Filament/ProviderConnectionResourceScopeSummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingProviderConnectionScopeTest.php tests/Feature/Guards/ProviderConnectionMicrosoftScopeLeakGuardTest.php)`.
- [x] T030 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec281ProviderConnectionScopeSmokeTest.php)`.
- [x] T031 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`.
- [x] T032 [P] Review `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, `apps/platform/app/Support/Links/RequiredPermissionsLinks.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/config/provider_boundaries.php`, and `apps/platform/bootstrap/providers.php` to confirm Filament v5 or Livewire v4 compliance, unchanged provider registration location, unchanged asset strategy, `ProviderConnectionResource` non-global-search posture, preserved destructive-action confirmation plus authorization, provider-owned required-permissions guidance staying nested under `permission_overview`, and that Specs `282` through `287` remained deferred without new provider-profile tables, registries, taxonomy work, RBAC redesign, routing cutover, or copy-neutralization work.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: no dependencies; start immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user-story work.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical neutral provider-connection summary contract.
- **Phase 4 (US2)**: depends on Phase 2 and should land after or with US1 so provider-operation context carries the same canonical target-scope contract shown on provider-connections surfaces.
- **Phase 5 (US3)**: depends on US1 and should land after or with US2 when onboarding readiness also consumes the shifted identity contract.
- **Phase 6 (US4)**: depends on US1 and US3 so managed-environment related context advertises the final shared summary instead of an intermediate contract.
- **Phase 7 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: independently testable after Phase 2 and is the first required implementation increment.
- **US2 (P1)**: independently testable after Phase 2, but should ship after or with US1 because shared run context should match the final provider-connection summary contract.
- **US3 (P2)**: independently testable after US1 and should follow once the shared summary presenter is stable.
- **US4 (P3)**: independently testable after US1 and US3 and closes the summary-first versus drill-in boundary on managed-environment detail.
### Within Each User Story
- Write or extend the listed Pest coverage first and make it fail for the intended gap.
- Apply the smallest shared-seam changes needed to satisfy the story without reopening Specs `282` through `287`.
- Re-run the narrowest relevant validation command for that story before moving to the next story.
## Parallel Execution Examples
- **Setup**: T002 through T006 can run in parallel once T001 sets the bounded scope.
- **Foundational**: T007 through T011 can run in parallel before T012 converges the canonical boundary and target-scope ownership.
- **US1**: T013 and T014 can run in parallel; T015 through T017 should merge serially around resolver, summary, and Filament resource files.
- **US2**: T018 and T019 can run in parallel; T020 through T022 should follow serially around the shared identity and start-gate seams.
- **US3**: T023 can run alongside T024, then T025 should converge the shared presenter.
- **US4**: T026 can run alongside T027, then T028 should finalize the deep-link and summary-first resource behavior.
- **Polish**: T029 through T031 can run in parallel after implementation is complete; T032 should close the bounded-scope review last.
## Implementation Strategy
### Suggested MVP Scope
- MVP = **US1**. Land the neutral provider-connection summary contract first so every later consumer reads one canonical target-scope story.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 so provider-connections stops depending on Microsoft-shaped shared summary truth.
3. Deliver US2 so provider-operation start and blocked flows record the same neutral shared target-scope contract.
4. Deliver US3 so onboarding reuses the same summary presenter and identity language.
5. Deliver US4 so managed-environment detail stays summary-first and drills into the provider-connections decision surface cleanly.
6. Finish with the exact validation commands and the final bounded-scope review in Phase 7.
### Team Strategy
1. Parallelize the failing test work first.
2. Serialize merges around `apps/platform/app/Support/Providers/TargetScope/`, `apps/platform/app/Services/Providers/`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` to avoid conflicting contract-shape edits.
3. Reject any implementation branch that introduces provider-profile persistence, shared compatibility aliases, registry or taxonomy work, routing cutover work, RBAC redesign, or copy-neutralization tasks reserved for Specs `282` through `287`.
## Deferred Follow-Ups / Non-Goals
- Spec `282` governance-artifact retargeting to `ManagedEnvironment`
- Spec `283` provider capability registry work
- Spec `284` provider-neutral artifact source or taxonomy work
- Spec `285` workspace-first RBAC and environment-access redesign
- Spec `286` broader UI copy, IA, and localization neutralization
- Spec `287` cutover quality gates and no-legacy enforcement beyond this feature-local proof