feat: implement spec 427 source contract enablement (#494)

Automated PR for spec 427 Exchange Teams verified source contract enablement.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #494
This commit is contained in:
ahmido 2026-07-03 23:12:45 +00:00
parent f7d06621a0
commit bfb52b84d6
26 changed files with 1996 additions and 17 deletions

View File

@ -8,6 +8,22 @@
final readonly class CoverageSourceContractDecision
{
public const CONTRACT_VERIFIED_PENDING_CAPTURE = 'contract_verified_pending_capture';
public const CONTRACT_BLOCKED_MISSING_SOURCE = 'contract_blocked_missing_source';
public const CONTRACT_BLOCKED_PERMISSION_UNCLEAR = 'contract_blocked_permission_unclear';
public const CONTRACT_BLOCKED_BETA_ONLY = 'contract_blocked_beta_only';
public const CONTRACT_BLOCKED_RESPONSE_SHAPE_UNSAFE = 'contract_blocked_response_shape_unsafe';
public const CONTRACT_BLOCKED_REPO_ADAPTER_MISSING = 'contract_blocked_repo_adapter_missing';
public const CONTRACT_BLOCKED_IDENTITY_UNSAFE = 'contract_blocked_identity_unsafe';
public const CONTRACT_BLOCKED_REDACTION_UNSAFE = 'contract_blocked_redaction_unsafe';
/**
* @param array<string, mixed> $contract
* @param array<string, mixed> $sourceMetadata
@ -20,10 +36,28 @@ public function __construct(
public string $sourceVersion = 'v1.0',
public ?string $sourceSchemaHash = null,
public ?string $reasonCode = null,
public ?string $sourceContractState = null,
public array $contract = [],
public array $sourceMetadata = [],
) {}
/**
* @return list<string>
*/
public static function sourceContractStates(): array
{
return [
self::CONTRACT_VERIFIED_PENDING_CAPTURE,
self::CONTRACT_BLOCKED_MISSING_SOURCE,
self::CONTRACT_BLOCKED_PERMISSION_UNCLEAR,
self::CONTRACT_BLOCKED_BETA_ONLY,
self::CONTRACT_BLOCKED_RESPONSE_SHAPE_UNSAFE,
self::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING,
self::CONTRACT_BLOCKED_IDENTITY_UNSAFE,
self::CONTRACT_BLOCKED_REDACTION_UNSAFE,
];
}
public function capturable(): bool
{
return $this->outcome === CaptureOutcome::Captured

View File

@ -40,11 +40,19 @@ final class CoverageSourceContractResolver
* @var list<string>
*/
private const MISSING_SOURCE_CONTRACT_TYPES = [
'dlpCompliancePolicy',
];
/**
* Source-contract review targets that have safe helper/identity metadata but no verified repo adapter.
*
* @var list<string>
*/
private const EXCHANGE_TEAMS_REVIEWED_CONTRACT_TYPES = [
'transportRule',
'acceptedDomain',
'appPermissionPolicy',
'meetingPolicy',
'dlpCompliancePolicy',
];
public function __construct(
@ -64,6 +72,10 @@ public function resolve(TenantConfigurationResourceType $resourceType, bool $all
$contractKey = self::CONTRACT_KEYS[$canonicalType] ?? null;
if (! is_string($contractKey) || $contractKey === '') {
if (in_array($canonicalType, self::EXCHANGE_TEAMS_REVIEWED_CONTRACT_TYPES, true)) {
return $this->blockedReviewedSourceContract($canonicalType, $sourceClass, $supportState);
}
if (in_array($canonicalType, self::MISSING_SOURCE_CONTRACT_TYPES, true)) {
return $this->blocked($canonicalType, CaptureOutcome::BlockedMissingContract, 'missing_source_contract_mapping');
}
@ -129,6 +141,239 @@ private function blocked(string $canonicalType, CaptureOutcome $outcome, string
);
}
private function blockedReviewedSourceContract(
string $canonicalType,
?SourceClass $sourceClass,
?SupportState $supportState,
): CoverageSourceContractDecision {
$state = CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING;
$metadata = [
'reason_code' => $state,
'source_contract_state' => $state,
'contract_blocker_reason' => $state,
'capture_eligibility_state' => 'blocked',
'source_class' => $sourceClass?->value,
'registry_source_class' => $sourceClass?->value,
'support_state' => $supportState?->value,
'registry_support_state' => $supportState?->value,
'workload' => $this->exchangeTeamsWorkload($canonicalType),
'source_version' => 'not_verified',
'source_contract_name' => $this->exchangeTeamsContractName($canonicalType),
'source_contract_version' => 'review-2026-07-03',
'provider_adapter_state' => 'missing',
'provider_adapter_proof' => 'No repo provider adapter or explicit Graph contract exists for this source contract.',
'provider_calls_allowed' => false,
'evidence_promotion_allowed' => false,
'customer_claims_allowed' => false,
'restore_allowed' => false,
'certification_allowed' => false,
'not_certifiable' => true,
'not_customer_claimable' => true,
'permission_model' => $this->exchangeTeamsPermissionModel($canonicalType),
'response_shape' => $this->exchangeTeamsResponseShape($canonicalType),
'identity_handoff' => $this->exchangeTeamsIdentityHandoff($canonicalType),
'normalization_handoff' => $this->exchangeTeamsNormalizationHandoff($canonicalType),
'redaction_rules' => $this->exchangeTeamsRedactionRules($canonicalType),
'documentation_reference' => 'repo-proof: Spec 426 removed unverified Graph endpoint claims; Spec 427 keeps the target blocked until a repo adapter/source contract exists.',
];
return new CoverageSourceContractDecision(
canonicalType: $canonicalType,
outcome: CaptureOutcome::BlockedMissingContract,
sourceVersion: 'not_verified',
reasonCode: $state,
sourceContractState: $state,
sourceMetadata: array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
private function exchangeTeamsWorkload(string $canonicalType): string
{
return match ($canonicalType) {
'transportRule', 'acceptedDomain' => 'exchange',
'appPermissionPolicy', 'meetingPolicy' => 'teams',
default => 'microsoft_365',
};
}
private function exchangeTeamsContractName(string $canonicalType): string
{
return match ($canonicalType) {
'transportRule' => 'exchange.transportRule.source_contract_review',
'acceptedDomain' => 'exchange.acceptedDomain.source_contract_review',
'appPermissionPolicy' => 'teams.appPermissionPolicy.source_contract_review',
'meetingPolicy' => 'teams.meetingPolicy.source_contract_review',
default => 'microsoft365.unknown.source_contract_review',
};
}
/**
* @return array<string, mixed>
*/
private function exchangeTeamsPermissionModel(string $canonicalType): array
{
return [
'status' => 'not_productized',
'required_application_permissions' => [],
'delegated_permissions' => [],
'admin_consent_required' => true,
'least_privilege_note' => 'Blocked until a repo-owned source adapter and least-privilege permission path are productized.',
'permission_failure_mode' => 'block_without_provider_call',
'redacted_permission_context' => true,
'resource_type' => $canonicalType,
];
}
/**
* @return array<string, mixed>
*/
private function exchangeTeamsResponseShape(string $canonicalType): array
{
return [
'raw_payload_shape' => 'provider_collection_candidate',
'collection_semantics' => 'collection_candidate',
'pagination_model' => 'blocked_until_adapter_exists',
'collection_item_path' => 'value',
'pagination_cursor_path' => '@odata.nextLink',
'empty_response_meaning' => 'must_be_distinguished_by_future_adapter',
'permission_denied_response_meaning' => 'must_be_distinguished_by_future_adapter',
'unsupported_response_meaning' => 'must_be_distinguished_by_future_adapter',
'source_unavailable_response_meaning' => 'must_be_distinguished_by_future_adapter',
'malformed_response_meaning' => 'must_be_distinguished_by_future_adapter',
'response_shape_safety' => 'blocked_until_empty_denied_unsupported_unavailable_and_malformed_are_distinguishable',
'display_safe_fields' => $this->exchangeTeamsDisplayFields($canonicalType),
'sensitive_fields' => $this->exchangeTeamsSensitiveFields($canonicalType),
'volatile_fields' => ['@odata.context', '@odata.etag', 'createdDateTime', 'modifiedDateTime', 'whenChanged'],
];
}
/**
* @return array<string, mixed>
*/
private function exchangeTeamsIdentityHandoff(string $canonicalType): array
{
return [
'preferred_identity_fields' => $this->exchangeTeamsPreferredIdentityFields($canonicalType),
'fallback_identity_fields' => $this->exchangeTeamsFallbackIdentityFields($canonicalType),
'identity_stability_class' => 'stable_candidate',
'singleton_or_collection_identity_rule' => 'collection_item_identity_required',
'known_identity_risks' => [
'display_name_not_stable',
'identity_field_can_be_display_like',
'order_and_payload_hash_not_stable_identity',
],
'display_name_is_stable_identity' => false,
];
}
/**
* @return array<string, mixed>
*/
private function exchangeTeamsNormalizationHandoff(string $canonicalType): array
{
return [
'resource_type' => $canonicalType,
'source_class' => SourceClass::Tcm->value,
'source_contract_name' => $this->exchangeTeamsContractName($canonicalType),
'source_version' => 'not_verified',
'raw_payload_shape' => 'provider_collection_candidate',
'expected_normalized_shape' => 'exchange_teams_comparable_payload',
'identity_fields' => [
...$this->exchangeTeamsPreferredIdentityFields($canonicalType),
...$this->exchangeTeamsFallbackIdentityFields($canonicalType),
],
'volatile_fields' => ['@odata.context', '@odata.etag', 'createdDateTime', 'modifiedDateTime', 'whenChanged'],
'sensitive_fields' => $this->exchangeTeamsSensitiveFields($canonicalType),
'collection_item_path' => 'value',
'pagination_cursor_path' => '@odata.nextLink',
];
}
/**
* @return array<string, mixed>
*/
private function exchangeTeamsRedactionRules(string $canonicalType): array
{
return [
'raw_payload_default_visible' => false,
'permission_context_default_visible' => false,
'redacted_permission_context' => true,
'sensitive_fields' => $this->exchangeTeamsSensitiveFields($canonicalType),
'forbidden_default_output' => [
'raw_provider_payload',
'provider_response',
'authorization_header',
'tokens',
'credentials',
'mail_content',
'teams_chat_message_file_recording_or_transcript_content',
],
];
}
/**
* @return list<string>
*/
private function exchangeTeamsPreferredIdentityFields(string $canonicalType): array
{
return match ($canonicalType) {
'transportRule' => ['id', 'sourceId', 'Guid', 'RuleId'],
'acceptedDomain' => ['id', 'sourceId'],
'appPermissionPolicy', 'meetingPolicy' => ['id', 'sourceId', 'policyId'],
default => ['id', 'sourceId'],
};
}
/**
* @return list<string>
*/
private function exchangeTeamsFallbackIdentityFields(string $canonicalType): array
{
return match ($canonicalType) {
'acceptedDomain' => ['DomainName', 'domainName'],
default => [],
};
}
/**
* @return list<string>
*/
private function exchangeTeamsDisplayFields(string $canonicalType): array
{
return match ($canonicalType) {
'acceptedDomain' => ['DomainName', 'domainName', 'DisplayName', 'displayName', 'Name', 'name'],
default => ['DisplayName', 'displayName', 'Name', 'name'],
};
}
/**
* @return list<string>
*/
private function exchangeTeamsSensitiveFields(string $canonicalType): array
{
$common = ['authorization', 'access_token', 'refresh_token', 'client_secret', 'password', 'credential', 'provider_response', 'raw_payload'];
return match ($canonicalType) {
'transportRule' => [
...$common,
'SubjectContainsWords',
'SubjectOrBodyContainsWords',
'BodyContainsWords',
'ApplyHtmlDisclaimerText',
'SetHeaderValue',
],
'meetingPolicy' => [
...$common,
'RecordingContent',
'RecordingUrl',
'TranscriptContent',
'TranscriptText',
'ChatContent',
],
default => $common,
};
}
/**
* @param array<string, mixed> $contract
*/

View File

@ -56,7 +56,7 @@
foreach (collect($result['outcomes'])->keyBy('canonical_type') as $canonicalType => $outcome) {
expect(spec426CoreTypes())->toContain($canonicalType)
->and($outcome['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value)
->and($outcome['reason_code'])->toBe('missing_source_contract_mapping')
->and($outcome['reason_code'])->toBe('contract_blocked_repo_adapter_missing')
->and($outcome['source_contract_key'])->toBeNull();
}
});
@ -98,6 +98,9 @@
->and(collect(data_get($run->context, 'capture.resource_type_outcomes', []))->pluck('outcome')->unique()->values()->all())->toBe([
CaptureOutcome::BlockedMissingContract->value,
])
->and(collect(data_get($run->context, 'capture.resource_type_outcomes', []))->pluck('reason_code')->unique()->values()->all())->toBe([
'contract_blocked_repo_adapter_missing',
])
->and(json_encode($run->context, JSON_THROW_ON_ERROR))->not->toContain('spec426-provider-secret')
->and(TenantConfigurationResource::query()->count())->toBe(0)
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0)

View File

@ -58,7 +58,7 @@
])
->and($result['run_outcome'])->toBe(OperationRunOutcome::Blocked->value)
->and($result['outcomes'][0]['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value)
->and($result['outcomes'][0]['reason_code'])->toBe('missing_source_contract_mapping')
->and($result['outcomes'][0]['reason_code'])->toBe('contract_blocked_repo_adapter_missing')
->and(TenantConfigurationResource::query()->count())->toBe(0)
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
});

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\CoverageLevel;
it('Spec427 does not promote Exchange or Teams types to content backed comparable renderable or certified levels', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
$rows = TenantConfigurationResourceType::query()
->whereIn('canonical_type', ['transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy'])
->get();
expect($rows)->toHaveCount(4);
foreach ($rows as $row) {
expect($row->default_coverage_level)->toBe(CoverageLevel::Detected)
->and($row->default_coverage_level)->not->toBe(CoverageLevel::ContentBacked)
->and($row->default_coverage_level)->not->toBe(CoverageLevel::Comparable)
->and($row->default_coverage_level)->not->toBe(CoverageLevel::Renderable)
->and($row->default_coverage_level)->not->toBe(CoverageLevel::Certified)
->and($row->allows_certified_claims)->toBeFalse();
}
});

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\ClaimGuard;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\ClaimState;
use App\Support\TenantConfiguration\CoverageLevel;
use App\Support\TenantConfiguration\IdentityState;
use App\Support\TenantConfiguration\RestoreTier;
it('Spec427 keeps Exchange and Teams source-contract states out of customer and restore claims', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
$rows = TenantConfigurationResourceType::query()
->whereIn('canonical_type', ['transportRule', 'acceptedDomain', 'appPermissionPolicy', 'meetingPolicy'])
->get();
expect($rows)->toHaveCount(4);
foreach ($rows as $row) {
expect($row->default_claim_state)->toBe(ClaimState::InternalOnly)
->and($row->restore_tier)->not->toBe(RestoreTier::Restorable)
->and((bool) data_get($row->metadata, 'customer_claims_allowed'))->toBeFalse()
->and(app(ClaimGuard::class)->evaluate(
scopeKey: 'spec427_exchange_teams_internal',
requestedLevel: CoverageLevel::Renderable,
actualLevel: CoverageLevel::Detected,
scopeComplete: true,
customerFacing: true,
customerClaimsAllowed: false,
sourceClass: $row->source_class,
restoreTier: $row->restore_tier,
identityState: IdentityState::Stable,
))->toBe(ClaimState::ClaimBlocked)
->and(app(ClaimGuard::class)->evaluate(
scopeKey: 'spec427_exchange_teams_internal',
requestedLevel: CoverageLevel::Renderable,
actualLevel: CoverageLevel::Detected,
scopeComplete: true,
sourceClass: $row->source_class,
restoreTier: $row->restore_tier,
identityState: IdentityState::Stable,
restoreClaim: true,
))->toBe(ClaimState::ClaimBlocked);
}
expect(app(ClaimGuard::class)->evaluateStatement('Microsoft 365 customer-ready Exchange Teams evidence', internalOperatorOnly: true))
->toBe(ClaimState::ClaimBlocked);
});

View File

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantConfigurationResource;
use App\Models\TenantConfigurationResourceEvidence;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\TenantConfiguration\GenericContentEvidenceCaptureService;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\TenantConfiguration\CaptureOutcome;
it('Spec427 blocks Exchange and Teams source contracts without provider calls or evidence rows', function (): void {
app(ResourceTypeRegistry::class)->syncDefaults();
[$user, $environment] = createMinimalUserWithTenant(role: 'owner');
$connection = ProviderConnection::factory()->withCredential()->create([
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'scopes_granted' => ['Exchange.ManageAsApp', 'Policy.Read.All'],
]);
$graph = spec427NoEvidenceGraphClient();
app()->instance(GraphClientInterface::class, $graph);
$run = spec427NoEvidenceRun($user, $environment, $connection, spec427NoEvidenceTypes());
$result = app(GenericContentEvidenceCaptureService::class)->capture(
tenant: $environment,
providerConnection: $connection,
operationRun: $run,
canonicalTypes: spec427NoEvidenceTypes(),
);
expect($graph->calls)->toBe([])
->and($result['summary_counts'])->toMatchArray([
'total' => 4,
'processed' => 4,
'succeeded' => 0,
'skipped' => 4,
'failed' => 0,
'errors_recorded' => 0,
])
->and($result['run_outcome'])->toBe(OperationRunOutcome::Blocked->value)
->and(TenantConfigurationResource::query()->count())->toBe(0)
->and(TenantConfigurationResourceEvidence::query()->count())->toBe(0);
foreach (collect($result['outcomes'])->keyBy('canonical_type') as $canonicalType => $outcome) {
expect(spec427NoEvidenceTypes())->toContain($canonicalType)
->and($outcome['outcome'])->toBe(CaptureOutcome::BlockedMissingContract->value)
->and($outcome['reason_code'])->toBe('contract_blocked_repo_adapter_missing')
->and($outcome['source_contract_key'])->toBeNull();
}
});
/**
* @return list<string>
*/
function spec427NoEvidenceTypes(): array
{
return ['acceptedDomain', 'appPermissionPolicy', 'meetingPolicy', 'transportRule'];
}
function spec427NoEvidenceRun($user, $environment, ProviderConnection $connection, array $resourceTypes): OperationRun
{
return OperationRun::factory()->withUser($user)->forTenant($environment)->create([
'type' => OperationRunType::TenantConfigurationCapture->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'target_scope' => [
'workspace_id' => (int) $environment->workspace_id,
'managed_environment_id' => (int) $environment->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
],
'resource_types' => $resourceTypes,
'required_capability' => 'evidence.manage',
],
]);
}
function spec427NoEvidenceGraphClient(): GraphClientInterface
{
return new class implements GraphClientInterface
{
/**
* @var list<array{policy_type: string, options: array<string, mixed>}>
*/
public array $calls = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
$this->calls[] = ['policy_type' => $policyType, 'options' => $options];
return new GraphResponse(true, [['id' => 'unexpected-provider-call']]);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(false, [], 501);
}
};
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\File;
it('Spec427 does not introduce Exchange or Teams mini-platform services routes migrations reports or adapters', function (): void {
expect(File::exists(app_path('Services/TenantConfiguration/Exchange')))->toBeFalse()
->and(File::exists(app_path('Services/TenantConfiguration/Teams')))->toBeFalse()
->and(File::exists(app_path('Services/Providers/ExchangeProviderAdapter.php')))->toBeFalse()
->and(File::exists(app_path('Services/Providers/TeamsProviderAdapter.php')))->toBeFalse()
->and(File::exists(app_path('Services/Providers/GraphV1Adapter.php')))->toBeFalse()
->and(File::exists(app_path('Models/TenantConfigurationExchangeResource.php')))->toBeFalse()
->and(File::exists(app_path('Models/TenantConfigurationTeamsResource.php')))->toBeFalse()
->and(glob(database_path('migrations/*exchange*')) ?: [])->toBe([])
->and(glob(database_path('migrations/*teams*')) ?: [])->toBe([])
->and(glob(base_path('routes/*exchange*')) ?: [])->toBe([])
->and(glob(base_path('routes/*teams*')) ?: [])->toBe([])
->and(glob(resource_path('views/**/*exchange*')) ?: [])->toBe([])
->and(glob(resource_path('views/**/*teams*')) ?: [])->toBe([]);
});

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Schema;
it('Spec427 does not introduce tenant_id platform ownership for Exchange and Teams contract state', function (): void {
foreach (['tenant_configuration_resources', 'tenant_configuration_resource_evidence', 'tenant_configuration_resource_types'] as $table) {
expect(Schema::getColumnListing($table))
->not->toContain('tenant_id')
->not->toContain('provider_tenant_id')
->not->toContain('entra_tenant_id');
}
$content = collect([
app_path('Services/TenantConfiguration/CoverageSourceContractDecision.php'),
app_path('Services/TenantConfiguration/CoverageSourceContractResolver.php'),
app_path('Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php'),
app_path('Services/TenantConfiguration/GenericContentEvidenceCaptureService.php'),
])->map(fn (string $file): string => file_get_contents($file) ?: '')
->implode("\n");
expect($content)
->not->toContain('tenant_id')
->not->toContain('provider_tenant_id')
->not->toContain('entra_tenant_id');
});

View File

@ -3,8 +3,9 @@
declare(strict_types=1);
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Models\TenantConfigurationResourceType;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\CaptureOutcome;
use App\Support\TenantConfiguration\ClaimState;
@ -17,7 +18,7 @@
use App\Support\TenantConfiguration\SupportState;
use App\Support\TenantConfiguration\Workload;
it('Spec420 never derives runtime endpoints from remaining M365 source aliases without explicit contracts', function (string $canonicalType): void {
it('Spec420 never derives runtime endpoints from remaining M365 source aliases without explicit contracts', function (string $canonicalType, string $reasonCode): void {
$resourceType = spec420EligibilityResourceType($canonicalType);
$aliases = $resourceType->metadata['source_aliases'] ?? [];
@ -26,13 +27,13 @@
expect($aliases)->toBeArray()->not->toBeEmpty()
->and($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract)
->and($decision->sourceEndpoint)->toBeNull()
->and($decision->sourceMetadata['reason_code'])->toBe('missing_source_contract_mapping');
->and($decision->sourceMetadata['reason_code'])->toBe($reasonCode);
})->with([
'transportRule',
'acceptedDomain',
'appPermissionPolicy',
'meetingPolicy',
'dlpCompliancePolicy',
'transportRule' => ['transportRule', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING],
'acceptedDomain' => ['acceptedDomain', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING],
'appPermissionPolicy' => ['appPermissionPolicy', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING],
'meetingPolicy' => ['meetingPolicy', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING],
'dlpCompliancePolicy' => ['dlpCompliancePolicy', 'missing_source_contract_mapping'],
]);
it('Spec420 keeps existing beta and unsupported outcomes instead of adding a new outcome family', function (): void {

View File

@ -4,6 +4,7 @@
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\CaptureOutcome;
@ -23,18 +24,19 @@
->and($decision->sourceMetadata['registry_support_state'])->toBe('out_of_scope');
});
it('Spec420 blocks remaining missing-contract M365 types without falling back to unsupported', function (string $canonicalType): void {
it('Spec420 blocks remaining missing-contract M365 types without falling back to unsupported', function (string $canonicalType, string $reasonCode, ?string $contractState): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec420UnitResourceType($canonicalType));
expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract)
->and($decision->reasonCode)->toBe('missing_source_contract_mapping')
->and($decision->reasonCode)->toBe($reasonCode)
->and($decision->sourceContractState)->toBe($contractState)
->and($decision->contractKey)->toBeNull()
->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]);
})->with([
'Exchange accepted domain' => ['acceptedDomain'],
'Teams app permission policy' => ['appPermissionPolicy'],
'Security and Compliance DLP policy' => ['dlpCompliancePolicy'],
'Exchange accepted domain' => ['acceptedDomain', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING, CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING],
'Teams app permission policy' => ['appPermissionPolicy', CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING, CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING],
'Security and Compliance DLP policy' => ['dlpCompliancePolicy', 'missing_source_contract_mapping', null],
]);
function spec420UnitResourceType(string $canonicalType): TenantConfigurationResourceType

View File

@ -4,6 +4,7 @@
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\CaptureOutcome;
@ -13,7 +14,8 @@
->resolve(spec426ContractResourceType($canonicalType));
expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract)
->and($decision->reasonCode)->toBe('missing_source_contract_mapping')
->and($decision->reasonCode)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($decision->contractKey)->toBeNull()
->and($decision->sourceEndpoint)->toBeNull()
->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]);

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
it('Spec427 classifies acceptedDomain as adapter-blocked with bounded Exchange source metadata', function (): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427AcceptedDomainResourceType());
expect($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($decision->sourceMetadata['workload'])->toBe('exchange')
->and($decision->sourceMetadata['source_class'])->toBe('tcm')
->and($decision->sourceMetadata['source_contract_name'])->toBe('exchange.acceptedDomain.source_contract_review')
->and($decision->sourceMetadata['source_contract_version'])->toBe('review-2026-07-03')
->and($decision->sourceMetadata['provider_adapter_state'])->toBe('missing')
->and($decision->sourceMetadata['identity_handoff']['preferred_identity_fields'])->toBe(['id', 'sourceId'])
->and($decision->sourceMetadata['identity_handoff']['fallback_identity_fields'])->toBe(['DomainName', 'domainName'])
->and($decision->sourceMetadata['response_shape']['display_safe_fields'])->toContain('DomainName')
->and($decision->sourceMetadata['normalization_handoff']['identity_fields'])->toContain('DomainName');
});
function spec427AcceptedDomainResourceType(): TenantConfigurationResourceType
{
$definition = collect(ResourceTypeRegistry::defaultDefinitions())
->firstWhere('canonical_type', 'acceptedDomain');
expect($definition)->not->toBeNull('Missing default resource type definition for acceptedDomain.');
return new TenantConfigurationResourceType($definition);
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\CaptureOutcome;
it('Spec427 maps the bounded source-contract state vocabulary without adding parallel truth', function (): void {
expect(CoverageSourceContractDecision::sourceContractStates())->toBe([
'contract_verified_pending_capture',
'contract_blocked_missing_source',
'contract_blocked_permission_unclear',
'contract_blocked_beta_only',
'contract_blocked_response_shape_unsafe',
'contract_blocked_repo_adapter_missing',
'contract_blocked_identity_unsafe',
'contract_blocked_redaction_unsafe',
]);
});
it('Spec427 resolves every target type to an exact non-capturable source-contract blocker', function (string $canonicalType): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427StateResourceType($canonicalType));
expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract)
->and($decision->reasonCode)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($decision->sourceMetadata['source_contract_state'])->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($decision->sourceMetadata['capture_eligibility_state'])->toBe('blocked')
->and($decision->sourceMetadata['provider_adapter_state'])->toBe('missing')
->and($decision->capturable())->toBeFalse()
->and($decision->contractKey)->toBeNull()
->and($decision->sourceEndpoint)->toBeNull()
->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]);
})->with([
'transportRule',
'acceptedDomain',
'appPermissionPolicy',
'meetingPolicy',
]);
it('Spec427 keeps non-target missing-contract resource types on the existing generic blocker', function (): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427StateResourceType('dlpCompliancePolicy'));
expect($decision->outcome)->toBe(CaptureOutcome::BlockedMissingContract)
->and($decision->reasonCode)->toBe('missing_source_contract_mapping')
->and($decision->sourceContractState)->toBeNull()
->and($decision->sourceMetadata['reason_code'])->toBe('missing_source_contract_mapping');
});
it('Spec427 does not assign source-contract state to unsupported non-target resource types', function (): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427StateResourceType('application'));
expect($decision->outcome)->toBe(CaptureOutcome::BlockedUnsupported)
->and($decision->reasonCode)->toBe('resource_type_unsupported')
->and($decision->sourceContractState)->toBeNull()
->and($decision->sourceMetadata['reason_code'])->toBe('resource_type_unsupported')
->and($decision->sourceMetadata)->not->toHaveKey('source_contract_state');
});
it('Spec427 does not relabel already enabled non-target source contracts', function (): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427StateResourceType('conditionalAccessPolicy'));
expect($decision->outcome)->toBe(CaptureOutcome::Captured)
->and($decision->contractKey)->toBe('conditionalAccessPolicy')
->and($decision->sourceContractState)->toBeNull()
->and($decision->sourceMetadata)->not->toHaveKey('source_contract_state');
});
function spec427StateResourceType(string $canonicalType): TenantConfigurationResourceType
{
$definition = collect(ResourceTypeRegistry::defaultDefinitions())
->firstWhere('canonical_type', $canonicalType);
expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}.");
return new TenantConfigurationResourceType($definition);
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
it('Spec427 classifies transportRule as adapter-blocked with bounded Exchange source metadata', function (): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427TransportRuleResourceType());
expect($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($decision->sourceMetadata['workload'])->toBe('exchange')
->and($decision->sourceMetadata['source_class'])->toBe('tcm')
->and($decision->sourceMetadata['source_contract_name'])->toBe('exchange.transportRule.source_contract_review')
->and($decision->sourceMetadata['source_contract_version'])->toBe('review-2026-07-03')
->and($decision->sourceMetadata['provider_adapter_state'])->toBe('missing')
->and($decision->sourceMetadata['identity_handoff']['preferred_identity_fields'])->toBe(['id', 'sourceId', 'Guid', 'RuleId'])
->and($decision->sourceMetadata['identity_handoff']['fallback_identity_fields'])->toBe([])
->and($decision->sourceMetadata['redaction_rules']['sensitive_fields'])->toContain('SubjectContainsWords')
->and($decision->sourceMetadata['normalization_handoff']['expected_normalized_shape'])->toBe('exchange_teams_comparable_payload');
});
function spec427TransportRuleResourceType(): TenantConfigurationResourceType
{
$definition = collect(ResourceTypeRegistry::defaultDefinitions())
->firstWhere('canonical_type', 'transportRule');
expect($definition)->not->toBeNull('Missing default resource type definition for transportRule.');
return new TenantConfigurationResourceType($definition);
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CanonicalIdentityResolver;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
use App\Support\TenantConfiguration\IdentityState;
it('Spec427 identity handoff metadata uses stable candidates and rejects display-name identity', function (string $canonicalType): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427IdentityResourceType($canonicalType));
$handoff = $decision->sourceMetadata['identity_handoff'];
expect($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($handoff['identity_stability_class'])->toBe('stable_candidate')
->and($handoff['singleton_or_collection_identity_rule'])->toBe('collection_item_identity_required')
->and($handoff['display_name_is_stable_identity'])->toBeFalse()
->and($handoff['known_identity_risks'])->toContain('display_name_not_stable')
->and($handoff['known_identity_risks'])->toContain('order_and_payload_hash_not_stable_identity');
$identityFields = [
...$handoff['preferred_identity_fields'],
...$handoff['fallback_identity_fields'],
];
expect(array_intersect($identityFields, ['displayName', 'DisplayName', 'name', 'Name', 'Identity']))
->toBe([]);
})->with([
'transportRule',
'acceptedDomain',
'appPermissionPolicy',
'meetingPolicy',
]);
it('Spec427 keeps display-only payloads blocked from stable identity readiness', function (string $canonicalType): void {
$result = app(CanonicalIdentityResolver::class)->resolve(
spec427IdentityResourceType($canonicalType),
['DisplayName' => 'Display-only object', 'Name' => 'Display-only object', 'Identity' => 'Global'],
['source_contract_key' => $canonicalType, 'source_version' => 'not_verified'],
);
expect($result->identityState)->toBe(IdentityState::MissingExternalId)
->and($result->sourceResourceId)->toStartWith('missing:')
->and($result->canonicalResourceKey)->not->toContain('Display-only object');
})->with([
'transportRule',
'acceptedDomain',
'appPermissionPolicy',
'meetingPolicy',
]);
function spec427IdentityResourceType(string $canonicalType): TenantConfigurationResourceType
{
$definition = collect(ResourceTypeRegistry::defaultDefinitions())
->firstWhere('canonical_type', $canonicalType);
expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}.");
return new TenantConfigurationResourceType($definition);
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
it('Spec427 blocks unclear or unproductized permissions without widening provider scopes', function (string $canonicalType): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427PermissionResourceType($canonicalType));
$permissions = $decision->sourceMetadata['permission_model'];
expect($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($permissions['status'])->toBe('not_productized')
->and($permissions['required_application_permissions'])->toBe([])
->and($permissions['delegated_permissions'])->toBe([])
->and($permissions['admin_consent_required'])->toBeTrue()
->and($permissions['permission_failure_mode'])->toBe('block_without_provider_call')
->and($permissions['redacted_permission_context'])->toBeTrue()
->and(config("graph_contracts.types.{$canonicalType}", []))->toBe([]);
})->with([
'transportRule',
'acceptedDomain',
'appPermissionPolicy',
'meetingPolicy',
]);
it('Spec427 does not add target-specific Graph contract permissions for the Exchange and Teams blocker slice', function (): void {
$registered = array_keys((array) config('graph_contracts.types', []));
expect($registered)
->not->toContain('transportRule')
->not->toContain('acceptedDomain')
->not->toContain('appPermissionPolicy')
->not->toContain('meetingPolicy');
});
function spec427PermissionResourceType(string $canonicalType): TenantConfigurationResourceType
{
$definition = collect(ResourceTypeRegistry::defaultDefinitions())
->firstWhere('canonical_type', $canonicalType);
expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}.");
return new TenantConfigurationResourceType($definition);
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ExchangeTeamsComparablePayloadNormalizer;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
it('Spec427 source contract metadata keeps raw payloads permission context and secrets out of default output', function (string $canonicalType): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427RedactionResourceType($canonicalType));
$rules = $decision->sourceMetadata['redaction_rules'];
$metadataJson = json_encode($decision->sourceMetadata, JSON_THROW_ON_ERROR);
expect($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($rules['raw_payload_default_visible'])->toBeFalse()
->and($rules['permission_context_default_visible'])->toBeFalse()
->and($rules['redacted_permission_context'])->toBeTrue()
->and($rules['forbidden_default_output'])->toContain('raw_provider_payload')
->and($rules['forbidden_default_output'])->toContain('tokens')
->and($rules['forbidden_default_output'])->toContain('credentials')
->and($rules['forbidden_default_output'])->toContain('mail_content')
->and($metadataJson)->not->toContain('client-secret-value')
->and($metadataJson)->not->toContain('authorization-token-value');
})->with([
'transportRule',
'acceptedDomain',
'appPermissionPolicy',
'meetingPolicy',
]);
it('Spec427 preserves Exchange and Teams helper redaction for future verified payloads', function (): void {
$normalized = app(ExchangeTeamsComparablePayloadNormalizer::class)->normalize('transportRule', [
'Guid' => 'rule-guid',
'DisplayName' => 'Sensitive transport rule',
'ProviderResponse' => 'raw-provider-response-secret',
'SubjectContainsWords' => ['private subject phrase'],
'clientSecret' => 'client-secret-value',
]);
$json = json_encode($normalized, JSON_THROW_ON_ERROR);
expect($json)
->not->toContain('raw-provider-response-secret')
->not->toContain('private subject phrase')
->not->toContain('client-secret-value')
->and(data_get($normalized, 'conditions.subject_contains_words'))->toBe('[redacted]');
});
function spec427RedactionResourceType(string $canonicalType): TenantConfigurationResourceType
{
$definition = collect(ResourceTypeRegistry::defaultDefinitions())
->firstWhere('canonical_type', $canonicalType);
expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}.");
return new TenantConfigurationResourceType($definition);
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
it('Spec427 response metadata keeps empty denied unavailable unsupported and malformed outcomes distinct before verification', function (string $canonicalType): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427ResponseShapeResourceType($canonicalType));
$shape = $decision->sourceMetadata['response_shape'];
expect($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($shape['raw_payload_shape'])->toBe('provider_collection_candidate')
->and($shape['collection_semantics'])->toBe('collection_candidate')
->and($shape['pagination_model'])->toBe('blocked_until_adapter_exists')
->and($shape['empty_response_meaning'])->toBe('must_be_distinguished_by_future_adapter')
->and($shape['permission_denied_response_meaning'])->toBe('must_be_distinguished_by_future_adapter')
->and($shape['unsupported_response_meaning'])->toBe('must_be_distinguished_by_future_adapter')
->and($shape['source_unavailable_response_meaning'])->toBe('must_be_distinguished_by_future_adapter')
->and($shape['malformed_response_meaning'])->toBe('must_be_distinguished_by_future_adapter')
->and($shape['response_shape_safety'])->toBe('blocked_until_empty_denied_unsupported_unavailable_and_malformed_are_distinguishable')
->and($shape['collection_item_path'])->toBe('value')
->and($shape['pagination_cursor_path'])->toBe('@odata.nextLink');
})->with([
'transportRule',
'acceptedDomain',
'appPermissionPolicy',
'meetingPolicy',
]);
function spec427ResponseShapeResourceType(string $canonicalType): TenantConfigurationResourceType
{
$definition = collect(ResourceTypeRegistry::defaultDefinitions())
->firstWhere('canonical_type', $canonicalType);
expect($definition)->not->toBeNull("Missing default resource type definition for {$canonicalType}.");
return new TenantConfigurationResourceType($definition);
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
it('Spec427 classifies appPermissionPolicy as adapter-blocked with bounded Teams source metadata', function (): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427AppPermissionPolicyResourceType());
expect($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($decision->sourceMetadata['workload'])->toBe('teams')
->and($decision->sourceMetadata['source_class'])->toBe('tcm')
->and($decision->sourceMetadata['source_contract_name'])->toBe('teams.appPermissionPolicy.source_contract_review')
->and($decision->sourceMetadata['source_contract_version'])->toBe('review-2026-07-03')
->and($decision->sourceMetadata['provider_adapter_state'])->toBe('missing')
->and($decision->sourceMetadata['identity_handoff']['preferred_identity_fields'])->toBe(['id', 'sourceId', 'policyId'])
->and($decision->sourceMetadata['identity_handoff']['fallback_identity_fields'])->toBe([])
->and($decision->sourceMetadata['response_shape']['display_safe_fields'])->toContain('DisplayName')
->and($decision->sourceMetadata['normalization_handoff']['identity_fields'])->toContain('policyId');
});
function spec427AppPermissionPolicyResourceType(): TenantConfigurationResourceType
{
$definition = collect(ResourceTypeRegistry::defaultDefinitions())
->firstWhere('canonical_type', 'appPermissionPolicy');
expect($definition)->not->toBeNull('Missing default resource type definition for appPermissionPolicy.');
return new TenantConfigurationResourceType($definition);
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Models\TenantConfigurationResourceType;
use App\Services\Graph\GraphContractRegistry;
use App\Services\TenantConfiguration\CoverageSourceContractDecision;
use App\Services\TenantConfiguration\CoverageSourceContractResolver;
use App\Services\TenantConfiguration\ResourceTypeRegistry;
it('Spec427 classifies meetingPolicy as adapter-blocked with bounded Teams source metadata', function (): void {
$decision = (new CoverageSourceContractResolver(new GraphContractRegistry))
->resolve(spec427MeetingPolicyResourceType());
expect($decision->sourceContractState)->toBe(CoverageSourceContractDecision::CONTRACT_BLOCKED_REPO_ADAPTER_MISSING)
->and($decision->sourceMetadata['workload'])->toBe('teams')
->and($decision->sourceMetadata['source_class'])->toBe('tcm')
->and($decision->sourceMetadata['source_contract_name'])->toBe('teams.meetingPolicy.source_contract_review')
->and($decision->sourceMetadata['source_contract_version'])->toBe('review-2026-07-03')
->and($decision->sourceMetadata['provider_adapter_state'])->toBe('missing')
->and($decision->sourceMetadata['identity_handoff']['preferred_identity_fields'])->toBe(['id', 'sourceId', 'policyId'])
->and($decision->sourceMetadata['identity_handoff']['fallback_identity_fields'])->toBe([])
->and($decision->sourceMetadata['redaction_rules']['sensitive_fields'])->toContain('TranscriptContent')
->and($decision->sourceMetadata['redaction_rules']['sensitive_fields'])->toContain('RecordingUrl');
});
function spec427MeetingPolicyResourceType(): TenantConfigurationResourceType
{
$definition = collect(ResourceTypeRegistry::defaultDefinitions())
->firstWhere('canonical_type', 'meetingPolicy');
expect($definition)->not->toBeNull('Missing default resource type definition for meetingPolicy.');
return new TenantConfigurationResourceType($definition);
}

View File

@ -0,0 +1,112 @@
# Requirements Checklist: Spec 427 - Exchange / Teams Verified Source Contract Enablement
**Purpose**: Validate specification completeness and quality before implementation
**Created**: 2026-07-03
**Feature**: `specs/427-exchange-teams-verified-source-contract-enablement/spec.md`
## Scope
- [x] Only selected Exchange/Teams source contracts are in scope.
- [x] No evidence capture is in scope.
- [x] No content-backed promotion is in scope.
- [x] No compare/render promotion is in scope.
- [x] No certification is in scope.
- [x] No restore/apply flow is in scope.
- [x] No customer claim is in scope.
- [x] No new UI surface is in scope.
## Target Types
- [x] `exchange.transportRule` maps to repo canonical `transportRule`.
- [x] `exchange.acceptedDomain` maps to repo canonical `acceptedDomain`.
- [x] `teams.appPermissionPolicy` maps to repo canonical `appPermissionPolicy`.
- [x] `teams.meetingPolicy` maps to repo canonical `meetingPolicy`.
- [x] Repo-canonical names are documented in the spec.
## Contract Verification
- [x] Source class metadata is required for verified contracts.
- [x] Draft source class labels are mapped to repo-canonical source classes such as `graph_v1_fallback`.
- [x] Contract name/version metadata is required for verified contracts.
- [x] Permission model metadata is required for verified contracts.
- [x] Response shape metadata is required for verified contracts.
- [x] Pagination/collection semantics are required for verified contracts.
- [x] Identity handoff metadata is required for verified contracts.
- [x] Redaction rules are required for verified contracts.
- [x] Final state is explicit for verified and blocked contracts.
## Fail-Safe
- [x] Missing source blocks.
- [x] Unclear permissions block.
- [x] Beta-only source blocks certification/customer/restore claims.
- [x] Unsafe response shape blocks.
- [x] Missing repo adapter blocks.
- [x] Unsafe identity blocks.
- [x] Unsafe redaction blocks.
- [x] Blocked contracts create no provider call, resource row, or evidence row.
## No Promotion
- [x] No raw evidence created.
- [x] No normalized evidence created.
- [x] No content-backed level.
- [x] No comparable level.
- [x] No renderable level.
- [x] No certified level.
- [x] No customer claim.
- [x] No restore-ready state.
## Architecture
- [x] Uses Coverage v2 source contract infrastructure.
- [x] No direct HTTP bypass.
- [x] No endpoint guessing.
- [x] No new provider subsystem.
- [x] No `tenant_id`.
- [x] No Exchange mini-platform.
- [x] No Teams mini-platform.
- [x] Completed specs are read-only context and are not rewritten.
## Product Surface
- [x] No rendered UI changed by default.
- [x] Product Surface Contract result is `N/A - no rendered UI surface changed`.
- [x] Browser proof is `N/A - no rendered UI surface changed`.
- [x] Human Product Sanity is `N/A - no product surface changed`.
- [x] Product Surface exceptions are `none`.
## Tests
- [x] Unit tests are planned for resolver/contract states/permissions/response/identity/redaction.
- [x] Feature tests are planned for all four target types.
- [x] No-promotion tests are planned.
- [x] Spec 426 regression is planned.
- [x] Spec 417/420 regression is planned.
- [x] No real provider calls are allowed.
- [x] Test lane impact is documented.
## Candidate Selection Gate
- [x] Candidate exists as a direct user-provided draft.
- [x] The active auto-prep queue was checked and is not used as the source.
- [x] The candidate is not already covered by an existing active or completed Spec 427 package.
- [x] Related completed specs are dependency context only.
- [x] The candidate aligns with the current Coverage v2 / Exchange/Teams sequence.
- [x] The scope is the smallest viable implementation slice.
## Spec Readiness Gate
- [x] `spec.md` exists.
- [x] `plan.md` exists.
- [x] `tasks.md` exists.
- [x] Problem, value, requirements, non-goals, acceptance criteria, assumptions, and risks are documented.
- [x] RBAC, workspace/managed-environment/provider scope, auditability/observability, evidence/result truth, and UX no-impact posture are addressed.
- [x] No open question blocks safe implementation.
- [x] Scope is bounded for a later implementation loop.
## Final Candidate Gate
Result: `PASS`
Rationale: The package is a direct, manually supplied candidate and is scoped to precise source-contract verification only. It preserves fail-safe behavior, forbids evidence/readiness/customer promotion, and includes explicit implementation stop conditions.

View File

@ -0,0 +1,132 @@
# Implementation Report: Spec 427 - Exchange / Teams Verified Source Contract Enablement
**Branch**: `427-exchange-teams-verified-source-contract-enablement`
**HEAD**: `f7d06621 feat: implement Exchange Teams evidence identity readiness (#493)`
**Implementation date**: 2026-07-03
**Result**: PASS - exact source-contract blocker enablement, no evidence/readiness promotion
## Preflight
- Initial dirty state before implementation: untracked active spec package `specs/427-exchange-teams-verified-source-contract-enablement/`.
- Dirty state after implementation: expected runtime/test/spec report changes only.
- Completed Specs 414, 415, 417, 419, 420, and 426 were read-only context. Their spec/plan/tasks/implementation reports were not modified.
- Active gates used: Spec Kit implementation loop, Spec Readiness Gate, TCM cutover guard, provider freshness semantics, evidence anchor contract, workspace scope safety, and Pest testing.
- Hard-gate result: no stop condition met. No UI, route, navigation, Filament provider, OperationRun start UX, real provider capture, evidence promotion, compare/render promotion, certification, restore, customer output, `tenant_id`, legacy adapter, fallback reader, or dual write was required.
## Implementation Summary
- Added bounded source-contract state constants to `CoverageSourceContractDecision`.
- Added a separate `sourceContractState` field so source-contract truth is explicit without changing the capture outcome enum family.
- Left already enabled non-target source contracts unchanged to avoid broad relabeling outside Spec 427.
- Mapped exactly `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` to `contract_blocked_repo_adapter_missing`.
- Kept all four target types non-capturable: no contract key, no source endpoint, no provider call, no resource row, no evidence row.
- Kept `dlpCompliancePolicy` and other non-target missing contracts on the existing generic missing-source path.
## Source Contract Matrix
| Type | Workload | Source class | Final state | Capture outcome | Provider call | Evidence promotion |
| --- | --- | --- | --- | --- | --- | --- |
| `transportRule` | Exchange | `tcm` | `contract_blocked_repo_adapter_missing` | `capture_blocked_missing_contract` | no | no |
| `acceptedDomain` | Exchange | `tcm` | `contract_blocked_repo_adapter_missing` | `capture_blocked_missing_contract` | no | no |
| `appPermissionPolicy` | Teams | `tcm` | `contract_blocked_repo_adapter_missing` | `capture_blocked_missing_contract` | no | no |
| `meetingPolicy` | Teams | `tcm` | `contract_blocked_repo_adapter_missing` | `capture_blocked_missing_contract` | no | no |
No endpoint guessing, direct HTTP, runtime documentation fetch, provider bypass, Graph contract entry, or provider OAuth/scope widening was added.
## Safety Matrices
### Permission
- Permission model for all four types is `not_productized`.
- Required application permissions and delegated permissions remain empty in Spec 427 metadata.
- Failure mode is `block_without_provider_call`.
- Permission context remains redacted and is not default output.
### Response Shape
- Response shape remains `provider_collection_candidate`.
- Empty collection, permission denied, unsupported, source unavailable, and malformed response are distinct future-adapter meanings.
- State remains blocked until a repo adapter can distinguish those cases.
### Identity
- Identity handoff is `stable_candidate`.
- Preferred/fallback identity fields are type-specific and do not include `displayName`, `DisplayName`, `name`, `Name`, or `Identity`.
- Display-only payloads still resolve to `missing_external_id`.
### Redaction
- Raw payload and permission context are not default-visible.
- Metadata forbids raw provider payloads, provider responses, authorization headers, tokens, credentials, mail content, and Teams chat/message/file/recording/transcript content in default output.
- Existing Exchange/Teams helper redaction remains covered by tests.
## No-Promotion Matrix
| Guard | Result |
| --- | --- |
| No resource/evidence rows | passed |
| No content-backed/comparable/renderable/certified promotion | passed |
| No customer or restore claim | passed |
| No `tenant_id` ownership truth | passed |
| No Exchange/Teams mini-platform | passed |
| Spec 426 fail-safe behavior | passed with exact blocker reason |
| Spec 417 identity registry regression | passed |
| Spec 420 generic evidence regression | passed |
## Product Surface / Filament / Deployment
- Product Surface result: `N/A - no rendered UI surface changed`.
- Browser proof: `N/A - no rendered UI surface changed`.
- Human Product Sanity: `N/A - no product surface changed`.
- Product Surface exceptions: none.
- Visible complexity outcome: neutral.
- Livewire v4 compliance: unchanged; Filament v5 remains on Livewire v4.1.4.
- Provider registration location: unchanged; Laravel providers remain in `apps/platform/bootstrap/providers.php`.
- Global search posture: unchanged; no Filament Resource/global search behavior changed.
- Destructive/high-impact actions: none added.
- Asset strategy: no assets; no new `filament:assets` deployment requirement.
- Deployment impact: no migrations, env vars, queue/cron changes, storage changes, assets, or runtime provider permission changes.
## Validation
Passed:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec427ExchangeTeamsSourceContractStateTest.php tests/Unit/Support/TenantConfiguration/Spec427ExchangeTransportRuleContractTest.php tests/Unit/Support/TenantConfiguration/Spec427ExchangeAcceptedDomainContractTest.php tests/Unit/Support/TenantConfiguration/Spec427TeamsAppPermissionPolicyContractTest.php tests/Unit/Support/TenantConfiguration/Spec427TeamsMeetingPolicyContractTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractPermissionMetadataTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractResponseShapeTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractIdentityHandoffTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractRedactionTest.php`
- Initial run: 32 passed, 282 assertions.
- Post-analysis rerun after non-target relabeling fix: 33 passed, 287 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoEvidencePromotionTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoCompareRenderCertificationTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoCustomerRestoreClaimTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoMiniPlatformTest.php`
- Initial pre-fix run hit Signal 9 because the no-mini-platform guard loaded all app PHP files into memory.
- After narrowing that guard to path-level checks: 5 passed, 104 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php`
- 8 passed, 91 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php`
- 8 passed, 192 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php`
- Post-analysis rerun for non-target explicit contract behavior: 6 passed, 90 assertions.
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec420M365CaptureEligibilityTest.php`
- 6 passed, 33 assertions.
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- passed.
- `git diff --check`.
- passed.
## Deferred Work
- Spec 428: Exchange/Teams content-backed evidence promotion after a real source adapter/contract exists.
- Spec 429: Exchange/Teams comparable/renderable promotion after source-backed evidence exists.
- Later certified compare pack, customer reporting claims, restore/apply, provider permission productization, and optional resource expansion.
## Post-Implementation Analysis
- Analysis/fix iterations: 1.
- Finding fixed: assigning `contract_verified_pending_capture` to already-enabled non-target contracts could have created misleading source-contract metadata outside Spec 427. Remediation: leave existing enabled contracts unchanged and add a regression proving `conditionalAccessPolicy` is not relabeled.
- Final manual review fix: generic blocked resolver outcomes no longer receive `sourceContractState`; only the four Spec 427 reviewed target types receive the explicit source-contract blocker state. Added regression proof for a non-target unsupported type.
- Merge-state fix: active Spec 427 spec artifacts and new Spec 427 tests are intended branch contents and must be tracked with the runtime/test changes.
- Remaining confirmed in-scope findings: none.
- Residual risks: none inside active scope. Future verified contracts remain separate-spec work.
## Merge Readiness Notes
- No confirmed in-scope implementation findings remain at this report stage.
- Browser Smoke Test Gate: passed as not applicable because no rendered UI changed.
- Merge Readiness Gate: passed.

View File

@ -0,0 +1,217 @@
# Implementation Plan: Spec 427 - Exchange / Teams Verified Source Contract Enablement
**Branch**: `427-exchange-teams-verified-source-contract-enablement` | **Date**: 2026-07-03 | **Spec**: `specs/427-exchange-teams-verified-source-contract-enablement/spec.md`
**Input**: Feature specification from `/specs/427-exchange-teams-verified-source-contract-enablement/spec.md`
## Summary
Verify and enable precise source-contract states for exactly four Exchange/Teams Coverage v2 resource types: `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy`. The implementation must use the existing Coverage v2 source-contract resolver/registry path, record verified-pending-capture or exact blocker reasons, and prove that no evidence, compare/render, certification, restore, customer output, UI, or provider capture is promoted by this spec.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament 5, Livewire 4
**Primary Dependencies**: Existing Coverage v2 Tenant Configuration services, `GraphClientInterface` / repo provider abstraction, Pest 4
**Storage**: PostgreSQL via existing Coverage v2 tables/metadata; no new table by default
**Testing**: Pest 4 unit and feature tests; browser N/A by default
**Validation Lanes**: focused fast-feedback/confidence files; no browser lane unless the spec is amended for UI
**Target Platform**: Laravel monolith under `apps/platform`
**Project Type**: Web application / Laravel monolith
**Performance Goals**: No real provider calls; resolver decisions must be deterministic and cheap
**Constraints**: no remote capture, no evidence promotion, no UI, no `tenant_id`, no endpoint guessing, no provider permission widening
**Scale/Scope**: exactly four repo-canonical resource types; no optional Exchange/Teams expansion
## UI / Surface Guardrail Plan
- **Guardrail scope**: no operator-facing surface change.
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: N/A.
- **No-impact class, if applicable**: backend/internal contract metadata and tests only.
- **Native vs custom classification summary**: N/A.
- **Shared-family relevance**: Coverage v2 contract/metadata family only; no rendered shared detail family.
- **State layers in scope**: none for UI; internal source-contract state/reason metadata only.
- **Audience modes in scope**: N/A.
- **Decision/diagnostic/raw hierarchy plan**: N/A for UI; raw/support data stays out of default output/logs.
- **Raw/support gating plan**: raw payloads and secrets are not displayed/logged.
- **One-primary-action / duplicate-truth control**: N/A.
- **Handling modes by drift class or surface**: report-only N/A path for no rendered UI.
- **Repository-signal treatment**: no UI signal expected.
- **Special surface test profiles**: N/A.
- **Required tests or manual smoke**: functional core tests only; browser `N/A - no rendered UI surface changed`.
- **Exception path and spread control**: none.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage: `N/A - no rendered UI surface changed`.
- **UI/Productization coverage decision**: No UI surface impact.
- **Coverage artifacts to update**: none.
- **No-impact rationale**: Contract verification is internal prerequisite truth and requires no reachable rendered surface.
- **Navigation / Filament provider-panel handling**: no panel/provider change.
- **Screenshot or page-report need**: no.
## Product Surface Contract Plan
- **Product Surface Contract reference**: N/A - no rendered product surface changed.
- **No-legacy posture**: canonical replacement / no compatibility exception.
- **Page archetype and surface budget plan**: N/A.
- **Technical Annex and deep-link demotion plan**: N/A for UI. Contract proof stays in tests/implementation report; raw payloads, source keys, permissions, and blocker diagnostics are not product content.
- **Canonical status vocabulary plan**: N/A for rendered UI. Internal source-contract states are prerequisite states, not product badges.
- **Product Surface exceptions**: none.
- **Browser verification plan**: `N/A - no rendered UI surface changed`.
- **Human Product Sanity plan**: N/A.
- **Visible complexity outcome target**: neutral.
- **Implementation report target**: `specs/427-exchange-teams-verified-source-contract-enablement/implementation-report.md`.
## Filament / Livewire / Deployment Posture
- **Livewire v4 compliance**: Livewire v4.x confirmed; no Livewire code change planned.
- **Panel provider registration location**: no panel change; Laravel providers remain in `apps/platform/bootstrap/providers.php`.
- **Global search posture**: no Filament Resource/global search behavior changed.
- **Destructive/high-impact action posture**: none; no UI or mutation action added.
- **Asset strategy**: no assets; no new `filament:assets` requirement.
- **Testing plan**: resolver, contract metadata, fail-safe/no-promotion, identity, redaction, no-tenant-id, no-mini-platform, and regression tests.
- **Deployment impact**: no env vars, migrations, queues, scheduler, storage, or assets by default. If implementation discovers a required migration or provider permission productization, stop and amend this spec first.
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes.
- **Systems touched**: `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php`, `CoverageSourceContractDecision`, `ResourceTypeRegistry`, `apps/platform/config/graph_contracts.php` only when verified repo-safe contracts exist, identity registry/resolver, claim guard, redaction helpers, and focused tests.
- **Shared abstractions reused**: existing Coverage v2 source-contract resolver/registry, `GraphClientInterface` / repo provider abstraction, identity strategy registry, and Claim Guard.
- **New abstraction introduced? why?**: none planned. A small value object/helper is allowed only if it replaces ambiguity in the existing resolver and remains proportional.
- **Why the existing abstraction was sufficient or insufficient**: Existing resolver decides captured vs blocked, but current generic missing-contract behavior is too coarse for the next Exchange/Teams evidence sequence. The plan adds precise metadata/state inside the existing path rather than a new platform.
- **Bounded deviation / spread control**: source-contract state/reason values are limited to four target types and must have direct behavior/test consequences.
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no.
- **Central contract reused**: N/A.
- **Delegated UX behaviors**: N/A.
- **Surface-owned behavior kept local**: none.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: N/A.
- **Exception path**: none.
No remote capture, provider operation, queued work, or OperationRun creation is in scope. If live provider verification becomes necessary, implementation must stop and the spec must be amended.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes.
- **Provider-owned seams**: Exchange/Teams source contract names, Microsoft permission names, response shapes, provider-native IDs, and source versions.
- **Platform-core seams**: Coverage v2 contract state/blocker semantics, capture eligibility, claim safety, evidence promotion rules, workspace/managed-environment/provider-connection ownership.
- **Neutral platform terms / contracts preserved**: workspace, managed environment, provider connection, resource type, source contract, capture eligibility, blocker reason, evidence, claim state.
- **Retained provider-specific semantics and why**: Exchange/Teams labels and Microsoft contract metadata are necessary to verify these four concrete contracts, but remain provider-owned metadata.
- **Bounded extraction or follow-up path**: document-in-feature for any contained provider-specific blocker; follow-up-spec only if provider permission productization or contract verification requires a broader product decision.
## Constitution Check
- Inventory-first: no inventory/snapshot truth changes; source contracts remain prerequisites.
- Read/write separation: no write/change/provider capture action in scope.
- Graph contract path: any verified Graph source must be represented through existing contract registry/provider abstraction; direct HTTP and endpoint guessing are forbidden.
- Deterministic capabilities: contract states and blocker reasons must be testable.
- RBAC-UX: no new UI/action; provider-context checks must preserve existing workspace/environment/provider scope if touched.
- Workspace isolation: any provider-context verification must remain same workspace and managed environment.
- Tenant isolation / SCOPE-001: no `tenant_id`; provider-native tenant IDs remain metadata only.
- Run observability: no remote/queued work; OperationRun is N/A.
- Data minimization: no raw provider payload, secrets, or raw permission context in logs/default output.
- Test governance: focused Unit/Feature tests are the narrowest proof; browser N/A.
- Proportionality: bounded contract-state/reason metadata is justified by false-readiness prevention and immediate prerequisite value.
- No premature abstraction: extend existing resolver/registry; no Exchange/Teams mini-platform.
- Persisted truth: no new persisted entity by default.
- Behavioral state: every new state/reason changes a blocker/follow-up action; presentation-only labels are forbidden.
- Shared pattern first: reuse Coverage v2 resolver/registry and claim guard.
- Provider boundary: provider details remain provider-owned metadata.
- UI/Productization coverage: no rendered UI; Product Surface N/A.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for resolver/metadata/state mapping; Feature for no-promotion, no-tenant-id, no-mini-platform, and regressions.
- **Affected validation lanes**: focused fast-feedback/confidence.
- **Why this lane mix is the narrowest sufficient proof**: No rendered UI and no real provider calls are in scope; service/config behavior can be proven with focused tests.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec427ExchangeTeamsSourceContractStateTest.php tests/Unit/Support/TenantConfiguration/Spec427ExchangeTransportRuleContractTest.php tests/Unit/Support/TenantConfiguration/Spec427ExchangeAcceptedDomainContractTest.php tests/Unit/Support/TenantConfiguration/Spec427TeamsAppPermissionPolicyContractTest.php tests/Unit/Support/TenantConfiguration/Spec427TeamsMeetingPolicyContractTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractPermissionMetadataTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractResponseShapeTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractIdentityHandoffTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractRedactionTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoEvidencePromotionTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoCompareRenderCertificationTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoCustomerRestoreClaimTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoMiniPlatformTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php`
- **Fixture / helper / factory / seed / context cost risks**: local fake contract metadata/payload shapes only; no broader default setup.
- **Expensive defaults or shared helper growth introduced?**: no.
- **Heavy-family additions, promotions, or visibility changes**: none.
- **Surface-class relief / special coverage rule**: N/A - no rendered UI surface changed.
- **Closing validation and reviewer handoff**: implementation report must list exact commands, pass counts, and any Signal 9 direct-file fallback.
- **Budget / baseline / trend follow-up**: none expected.
- **Review-stop questions**: no endpoint guessing, no fake verified contract, no evidence promotion, no `tenant_id`, no hidden provider calls.
- **Escalation path**: document-in-feature for exact blocked contracts; follow-up-spec for provider permission productization or live capture.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage.
- **Why no dedicated follow-up spec is needed**: This spec is already the bounded source-contract prerequisite; broader promotions are listed as separate follow-ups.
## Project Structure
### Documentation (this feature)
```text
specs/427-exchange-teams-verified-source-contract-enablement/
|-- spec.md
|-- plan.md
|-- tasks.md
`-- checklists/
`-- requirements.md
```
### Source Code (likely affected during later implementation)
```text
apps/platform/
|-- app/
| |-- Services/TenantConfiguration/
| | |-- CoverageSourceContractResolver.php
| | |-- CoverageSourceContractDecision.php
| | |-- CoverageIdentityStrategyRegistry.php
| | `-- ClaimGuard.php
| `-- Support/TenantConfiguration/
| `-- CaptureOutcome.php
|-- config/
| `-- graph_contracts.php
`-- tests/
|-- Unit/Support/TenantConfiguration/
`-- Feature/TenantConfiguration/
```
**Structure Decision**: Keep all runtime work inside existing Coverage v2 Tenant Configuration services/config/tests. Do not create new base folders, Exchange/Teams table families, dashboards, routes, or provider subsystem.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
| --- | --- | --- |
| Bounded source-contract state/reason vocabulary | Later evidence/certification specs need exact blockers and next actions | Generic `capture_blocked_missing_contract` cannot distinguish missing source, permission, beta-only, response shape, adapter, identity, or redaction blockers |
## Proportionality Review
- **Current operator problem**: Release reviewers need exact proof that Exchange/Teams contracts are verified or blocked before later evidence/certification claims.
- **Existing structure is insufficient because**: Existing generic blocked outcomes are safe but too coarse to unblock or intentionally defer later work.
- **Narrowest correct implementation**: Add precise state/reason metadata for four existing resource types in existing resolver/registry paths.
- **Ownership cost created**: Four per-type matrices, focused tests, and implementation-report proof.
- **Alternative intentionally rejected**: Leave all four as generic missing-contract blockers. Rejected because it would force future specs to rediscover blockers and risks endpoint guessing.
- **Release truth**: Current prerequisite truth for the immediate Coverage v2 Exchange/Teams sequence.
## Implementation Phases
### Phase 0 - Preflight
Confirm branch, dirty state, completed dependency status, target canonical names, current resolver behavior, no UI scope, no OperationRun scope, and no evidence promotion scope.
### Phase 1 - Target Mapping And Current-State Audit
Map spec labels to repo-canonical resource types and document current registry/source-contract/identity/readiness state for each.
### Phase 2 - Contract State Model
Add or confirm the bounded verified/blocker state model and mapping to repo-canonical outcomes/metadata, including the draft `graph_v1` label to repo-canonical `graph_v1_fallback` where applicable.
### Phase 3 - Per-Type Contract Verification
For each target type, verify source class, contract name/version, permission model, response shape, identity handoff, redaction rules, and final state. Block instead of guessing.
### Phase 4 - Resolver / Registry Integration
Integrate verified or blocked contract states through the existing resolver/registry path.
### Phase 5 - No-Promotion And Regression Proof
Prove no evidence, coverage promotion, compare/render, certification, restore, customer claim, `tenant_id`, or mini-platform appears. Re-run Spec 426/417/420 regressions.
### Phase 6 - Implementation Report
Record matrices, validation, Product Surface N/A, Filament/Livewire output contract, deployment impact, deferred work, and gate results.

View File

@ -0,0 +1,409 @@
# Feature Specification: Spec 427 - Exchange / Teams Verified Source Contract Enablement
**Feature Branch**: `427-exchange-teams-verified-source-contract-enablement`
**Created**: 2026-07-03
**Status**: Draft
**Input**: Direct user-provided draft: "Spec 427 - Exchange/Teams Verified Source Contract Enablement"
## Selection And Preflight
- **Selected candidate**: Spec 427 - Exchange / Teams Verified Source Contract Enablement.
- **Source**: Direct user-provided draft attached in the 2026-07-03 session.
- **Why selected**: The active automatic candidate queue in `docs/product/spec-candidates.md` explicitly has no safe auto-prep target, but the user provided a manual/direct P0 candidate. Completed Spec 426 proves the four Exchange/Teams target types fail closed until verified source contracts exist, making this the next narrow unblocker.
- **Roadmap relationship**: Continues the Coverage v2 / Microsoft 365 sequence after Specs 414, 415, 417, 419, 420, and 426. It preserves the intended sequence: Spec 426 fail-safe proof -> Spec 427 verified source contracts -> later content-backed evidence promotion -> later compare/render promotion -> later certification.
- **Close alternatives deferred**: content-backed Exchange/Teams evidence promotion, compare/render promotion, certified compare pack, customer reports, Review Pack output, management PDF output, restore/apply flows, provider permission productization, broad Exchange/Teams coverage claims, optional resource expansion, Exchange/Teams dashboards, and customer-facing pages.
- **Completed-spec guardrail result**: Specs 414, 415, 417, 419, 420, and 426 are completed/validated dependency context only. Their implementation close-out, validation results, browser proof, and completed task markers must not be rewritten by this spec.
- **Current branch before Spec Kit execution**: `platform-dev`.
- **Spec Kit branch created**: `427-exchange-teams-verified-source-contract-enablement`.
- **Baseline HEAD**: `f7d06621 feat: implement Exchange Teams evidence identity readiness (#493)`.
- **Dirty state before Spec Kit execution**: clean.
- **Activated skills / repo gates**: `spec-kit-next-best-prep`, `tenantpilot-spec-readiness-gate`, `tenantpilot-tcm-cutover-guard`, `tenantpilot-provider-freshness-semantics`, and `tenantpilot-evidence-anchor-contract`.
- **Hard-gate stop conditions preserved**: no remote capture, no rendered UI/customer proof, no evidence promotion, no legacy adapter/fallback/dual truth, no `tenant_id` ownership truth, no raw payload/default proof exposure, and no provider permission widening without an explicit blocked/productization state.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Spec 426 proved the safe failure mode for Exchange/Teams source-backed evidence, but the four target types still lack verified source contracts that later capture/evidence work can rely on.
- **Today's failure**: TenantPilot can currently say these types are blocked by missing contracts, but it cannot distinguish whether the blocker is missing source, unclear permissions, beta-only source, unsafe response shape, missing adapter, unsafe identity, or unsafe redaction. Without that precision, later evidence/certification work risks endpoint guessing or false readiness.
- **User-visible improvement**: Release reviewers and platform operators get a precise, test-proven contract state per target type before any evidence, compare/render, restore, or customer claim is allowed. The product remains honest about what is blocked and why.
- **Smallest enterprise-capable version**: Verify exactly four target types, record a deterministic source-contract state or exact blocker reason for each, update the existing resolver/registry path only, and prove no evidence or readiness promotion occurs.
- **Explicit non-goals**: No real provider capture, no raw evidence persistence, no normalized evidence persistence, no content-backed promotion, no compare/render promotion, no certification, no restore readiness, no customer claim, no provider OAuth/scope productization, no UI route/navigation/page/action, no Review Pack/report/PDF output, no Exchange/Teams mini-platform, no optional resource expansion, no `tenant_id`, and no v1 compatibility.
- **Permanent complexity imported**: A bounded contract-state/blocker vocabulary for four concrete resource types, resolver/registry metadata where the existing path needs it, and focused tests. No new table, no new provider subsystem, no new UI framework, and no customer-facing taxonomy are allowed.
- **Why now**: Spec 426 ended in a deliberate fail-safe state and names verified source contracts as the next blocker before content-backed evidence or certification can proceed.
- **Why not local**: A page-local or test-local contract marker would bypass the shared Coverage v2 resolver, provider boundary, identity handoff, redaction, and claim-safety rules. The contract decision must live in the existing source-contract infrastructure.
- **Approval class**: Core Enterprise.
- **Red flags triggered**: New status/reason vocabulary and resolver metadata. Defense: each value has a concrete fail-safe behavior and distinct follow-up action; the scope is exactly four existing resource types and avoids a generalized Exchange/Teams framework.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve as a narrow prerequisite unblocker.
## Spec Scope Fields *(mandatory)*
- **Scope**: Coverage v2 source-contract verification for exactly four Microsoft 365 Exchange/Teams resource types.
- **Primary Routes**: None. No rendered route, page, navigation entry, action, dashboard, report, or customer surface is in scope.
- **Data Ownership**: Existing Coverage v2 definitions/metadata only. Any environment-owned runtime rows touched by future implementation remain scoped by `workspace_id`, `managed_environment_id`, and same-scope `provider_connection_id`. No new persisted evidence rows are created by this spec.
- **RBAC**: No new UI or mutation surface. If resolver access includes provider context in implementation, provider connections must remain same workspace + managed environment, non-members must receive 404, and members missing capability must receive 403 through existing policy/gate paths.
For canonical-view specs:
- **Default filter behavior when tenant-context is active**: N/A - no canonical route or rendered filter is added.
- **Explicit entitlement checks preventing cross-tenant leakage**: Any provider-context verification must use workspace + managed-environment + provider-connection scope, never provider-native tenant identifiers as ownership truth.
## No Legacy / No Backward Compatibility Constraint *(mandatory)*
TenantPilot is pre-production unless this spec explicitly records a compatibility exception.
- **Compatibility posture**: canonical Coverage v2 source-contract extension; no compatibility exception.
- **Legacy aliases, fallback readers, hidden routes, duplicate UI, old labels, or historical fixtures kept?**: no.
- **Why clean replacement is safe now**: This is a blocked/prerequisite source-contract slice over active Coverage v2 services. No production data or external API consumer requires Coverage v1 adapters, fallback readers, dual writes, or old customer-facing coverage vocabulary.
## Target Resource Mapping
The attached draft used spec/product labels with workload prefixes. The runtime implementation must use repo-canonical resource type keys already registered in Coverage v2.
| Spec Label | Repo Canonical Type | Workload | Initial Required Result |
| --- | --- | --- | --- |
| `exchange.transportRule` | `transportRule` | Exchange | explicit verified-or-blocked contract state |
| `exchange.acceptedDomain` | `acceptedDomain` | Exchange | explicit verified-or-blocked contract state |
| `teams.appPermissionPolicy` | `appPermissionPolicy` | Teams | explicit verified-or-blocked contract state |
| `teams.meetingPolicy` | `meetingPolicy` | Teams | explicit verified-or-blocked contract state |
No additional resource type may be added unless it is already in the registry and is directly necessary to verify one of these four contracts. Forbidden expansion includes `remoteDomain`, `organizationConfig`, `messagingPolicy`, `appSetupPolicy`, `voiceRoute`, `updateManagementPolicy`, all Exchange settings, all Teams policies, and all Microsoft 365 resources.
## Source Class Mapping
The draft's source-class labels are contract-review labels. Runtime implementation must use repo-canonical values where they already exist.
| Spec Label | Repo Canonical Equivalent |
| --- | --- |
| `tcm` | existing `tcm` source class |
| `graph_v1` | existing `graph_v1_fallback` source class unless the repo already has a narrower canonical value |
| `graph_beta_experimental` | existing `graph_beta_experimental` source class |
| `repo_existing_provider_adapter` | existing provider abstraction / adapter path, not a new provider subsystem |
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Release Reviewer Gets Exact Contract State (Priority: P1)
As a release reviewer, I need each target resource type to resolve to a precise source-contract state so I can tell whether a later evidence spec may proceed or must stay blocked.
**Why this priority**: This is the smallest proof needed before content-backed capture can be attempted safely.
**Independent Test**: Resolver/registry tests prove every target type has one explicit final state and no target falls back to a generic or guessed source.
**Acceptance Scenarios**:
1. **Given** the four target resource types, **When** the source-contract resolver evaluates them, **Then** each receives either `contract_verified_pending_capture` or an exact blocked state.
2. **Given** a missing or unverifiable source contract, **When** the resolver evaluates the type, **Then** it fails closed with a specific blocker and does not call a provider.
### User Story 2 - Platform Operator Cannot See False Evidence Readiness (Priority: P1)
As a platform operator, I need verified source contracts to remain prerequisites rather than evidence/readiness claims.
**Why this priority**: A verified contract is not evidence, not comparison support, and not certification.
**Independent Test**: No-promotion tests prove no raw evidence, normalized evidence, content-backed level, comparable level, renderable level, certification, restore-ready state, or customer claim appears.
**Acceptance Scenarios**:
1. **Given** a target type marked verified pending capture, **When** readiness/claim paths are inspected, **Then** it remains pending capture only and not content-backed or customer claimable.
2. **Given** a blocked contract, **When** readiness/claim paths are inspected, **Then** it remains blocked with no fake evidence rows.
### User Story 3 - Security Reviewer Confirms Permission, Identity, And Redaction Safety (Priority: P1)
As a security reviewer, I need source contracts to include permission model, response shape, identity handoff, and redaction metadata before any future capture uses them.
**Why this priority**: Unsafe permissions, identity, response shape, or redaction would turn a source contract into misleading proof.
**Independent Test**: Focused tests prove verified contracts include permission metadata, response shape, identity handoff, and redaction rules; unsafe contracts stay blocked.
**Acceptance Scenarios**:
1. **Given** a contract candidate lacks clear permissions, **When** it is evaluated, **Then** it resolves to `contract_blocked_permission_unclear` or an equivalent exact blocked state.
2. **Given** a contract candidate depends on display names as identity, **When** it is evaluated, **Then** it resolves to `contract_blocked_identity_unsafe`.
3. **Given** a contract candidate exposes sensitive fields without redaction rules, **When** it is evaluated, **Then** it resolves to `contract_blocked_redaction_unsafe`.
### User Story 4 - Implementation Reviewer Preserves Spec 426 Fail-Safe Behavior (Priority: P2)
As an implementation reviewer, I need Spec 427 to prove it did not weaken Spec 426 fail-safe behavior.
**Why this priority**: Spec 427 must unblock only safe contracts, not silently reopen fake capture.
**Independent Test**: Spec 426, Spec 417, and Spec 420 regressions pass with all four Exchange/Teams target types still fail-safe where verification is not complete.
**Acceptance Scenarios**:
1. **Given** Spec 426 missing-contract behavior, **When** Spec 427 is implemented, **Then** unverifiable contracts still fail closed and create no provider call, resource row, or evidence row.
2. **Given** existing identity and generic evidence services, **When** Spec 427 adds contract metadata, **Then** existing Coverage v2 regressions remain green.
### Edge Cases
- Repo-canonical names differ from spec labels: use repo-canonical names and document the mapping.
- A source is Graph beta only: mark experimental/not-certifiable and blocked from customer/restore/certification claims.
- Required provider permission is not currently productized: do not widen scopes; block with a permission/productization reason.
- Response shape cannot distinguish empty, denied, unsupported, unavailable, and malformed responses: block as response-shape unsafe.
- Identity is display-name-only or order/hash based: block as identity unsafe.
- Redaction rules cannot keep secrets/raw provider payloads out of logs/default output: block as redaction unsafe.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-427-001**: The implementation MUST evaluate exactly `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy`, using the mapping in this spec.
- **FR-427-002**: Each target type MUST receive one explicit final source-contract state or exact blocker reason; no target may remain an unclassified generic missing-contract outcome.
- **FR-427-003**: Allowed final states are `contract_verified_pending_capture`, `contract_blocked_missing_source`, `contract_blocked_permission_unclear`, `contract_blocked_beta_only`, `contract_blocked_response_shape_unsafe`, `contract_blocked_repo_adapter_missing`, `contract_blocked_identity_unsafe`, and `contract_blocked_redaction_unsafe`. If implementation uses repo-canonical enums instead, it MUST map these states one-to-one in source metadata/tests and MUST NOT create ambiguous or parallel truth.
- **FR-427-004**: Allowed source class labels are `tcm`, `graph_v1`, `graph_beta_experimental`, and `repo_existing_provider_adapter`, but implementation MUST map them to repo-canonical source-class values such as existing `graph_v1_fallback` where applicable. `graph_beta_experimental` MUST be marked experimental, not certifiable, not customer claimable, and not restore ready.
- **FR-427-005**: Forbidden source classes are guessed endpoint, manual export, operator upload, browser scrape, admin portal scrape, direct HTTP bypass, and temporary stub.
- **FR-427-006**: A verified contract MUST include resource type, workload, source class, source version, source contract name, source contract version, request shape, response shape, collection/singleton semantics, pagination model, identity fields, permission model, permission failure mode, redaction rules, normalization handoff shape, capture eligibility state, and documentation reference or repo-proof note.
- **FR-427-007**: Verified contracts MUST resolve deterministically through the existing `CoverageSourceContractResolver` / source-contract infrastructure or repo-canonical equivalent.
- **FR-427-008**: Missing, beta-only, permission-unclear, response-shape-unsafe, adapter-missing, identity-unsafe, and redaction-unsafe contracts MUST remain fail-closed with a specific blocker.
- **FR-427-009**: Implementation MUST NOT guess endpoints from resource type names, hardcode direct HTTP calls, bypass `GraphClientInterface` or the repo provider abstraction, perform runtime Microsoft docs fetches, or mark fake/test-only contracts as verified.
- **FR-427-010**: Each verified contract MUST declare required application permissions, delegated permissions where applicable, least-privilege note, admin-consent requirement, permission failure mode, and redacted permission context. New OAuth/provider scopes MUST NOT be added unless an existing approved mechanism already productizes them; otherwise the contract remains blocked.
- **FR-427-011**: Each verified contract MUST declare response semantics: collection/singleton, primary identity field, secondary identity fields, display-safe fields, sensitive fields, volatile fields, pagination model, empty response meaning, permission-denied response meaning, unsupported response meaning, and malformed response meaning.
- **FR-427-012**: The contract state model MUST distinguish empty collection, permission denied, source unavailable, unsupported contract, and malformed response. These must not collapse into generic "missing data".
- **FR-427-013**: Identity handoff metadata MUST include preferred identity fields, fallback identity fields, identity stability class, singleton/collection identity rule, known identity risks, and confirmation that display name is not stable identity.
- **FR-427-014**: A contract may be verified pending capture only when identity handoff is `stable_candidate` or `singleton_resource_with_source_contract_identity`. Otherwise it MUST be blocked as identity unsafe.
- **FR-427-015**: Normalization handoff metadata MUST include resource type, source class, source contract name, source version, raw payload shape, expected normalized shape, identity fields, volatile fields, sensitive fields, collection item path, and pagination cursor path where applicable.
- **FR-427-016**: Spec 427 MUST NOT persist raw provider payloads, normalized evidence, or evidence rows except local test fixtures. It must not promote target types to `content_backed`, `comparable`, `renderable`, `certified`, `restore_ready`, or `customer_claimable`.
- **FR-427-017**: Allowed registry/metadata updates are source contract state, source class, source contract name/version, capture eligibility state, blocker reason, experimental flag, not-certifiable flag, and not-customer-claimable flag. Forbidden updates include content-backed/comparable/renderable/certified coverage levels, assisted/automatic restore tiers, and customer-claimable truth.
- **FR-427-018**: Restore tier for the target types MUST remain current repo truth or stricter; this spec MUST NOT make any Exchange/Teams type restore ready.
- **FR-427-019**: No `tenant_id` platform-core ownership field, compatibility alias, fallback reader, dual write, or parallel ownership truth may be introduced.
- **FR-427-020**: No new Exchange-specific or Teams-specific table family, model family, dashboard, route, navigation item, report, export, Review Pack output, PDF output, or mini-platform may be introduced.
- **FR-427-021**: Safe contract metadata may be logged or displayed only when it contains no raw provider payloads, secrets, tokens, credentials, raw permission context, mail content, Teams chat/message/file/recording/transcript content, or private key/certificate material.
- **FR-427-022**: Tests MUST prove verified contracts resolve, blocked contracts remain blocked, permission metadata is present, response shape is safe, identity handoff is safe, redaction metadata is present, no evidence is created, no coverage/claim/restore promotion occurs, no `tenant_id` appears, and Spec 426/417/420 regressions still pass.
### Non-Functional Requirements
- **NFR-427-001 Security and privacy**: Logs, audit metadata, OperationRun context, diagnostics, and test output must not expose secrets, tokens, authorization headers, cookies, raw provider responses, raw payloads, permission context, mail content, Teams content, private keys, certificates, or passwords.
- **NFR-427-002 Determinism**: The same resource type and contract metadata must always resolve to the same contract state and blocker reason.
- **NFR-427-003 No provider cost**: No real Microsoft tenant calls, remote provider calls, or network-dependent tests are allowed in this spec.
- **NFR-427-004 Maintainability**: The implementation must extend existing Coverage v2 source-contract, identity, redaction, and claim-safety paths. New abstractions are allowed only when the proportionality review remains satisfied.
- **NFR-427-005 Auditability**: Implementation reports must include per-type source contract matrix, blocker matrix, permission summary, response-shape summary, identity handoff summary, redaction proof, and no-promotion proof.
## UI Surface Impact *(mandatory - UI-COV-001)*
Does this spec add, remove, rename, or materially change any reachable UI surface?
- [x] No UI surface impact
- [ ] Existing page changed
- [ ] New page/route added
- [ ] Navigation changed
- [ ] Filament panel/provider surface changed
- [ ] New modal/drawer/wizard/action added
- [ ] New table/form/state added
- [ ] Customer-facing surface changed
- [ ] Dangerous action changed
- [ ] Status/evidence/review presentation changed
- [ ] Workspace/environment context presentation changed
Default decision: no new runtime UI surface and no rendered UI change. New Exchange/Teams page, Teams page, customer route, navigation item, readiness badge, certified badge, restore action, report, export, Review Pack output, or PDF output is forbidden.
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)*
- **Route/page/surface**: N/A - no reachable UI surface impact.
- **Current or new page archetype**: N/A.
- **Design depth**: N/A.
- **Repo-truth level**: N/A.
- **Existing pattern reused**: Existing Coverage v2 service/test paths only; no rendered surface change.
- **New pattern required**: none.
- **Screenshot required**: no.
- **Page audit required**: no.
- **Customer-safe review required**: negative proof only; no customer-facing output or claim may appear.
- **Dangerous-action review required**: no dangerous/high-impact UI action in scope.
- **Coverage files updated or explicitly not needed**:
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
- [ ] `docs/ui-ux-enterprise-audit/page-reports/...`
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
- [x] `N/A - no reachable UI surface impact`
- **No-impact rationale when applicable**: Source-contract enablement is internal contract metadata and resolver behavior only; no operator-facing surface is required to make the contract state truthful.
## Product Surface Impact *(mandatory for UI-affecting specs; otherwise write `N/A - no rendered product surface changed` plus rationale)*
Reference: `docs/product/standards/product-surface-contract.md`.
- **Product Surface Contract applies?**: no for default implementation; no rendered product surface changes.
- **Page archetype**: N/A.
- **Primary user question**: N/A.
- **Primary action**: N/A.
- **Surface budget result**: N/A.
- **Technical Annex / deep-link demotion**: N/A. Raw provider payloads, source keys, permission context, identities, blocker diagnostics, and contract proof remain internal/test/report evidence only.
- **Canonical status vocabulary**: N/A for no rendered UI. Runtime source-contract states are internal prerequisite states, not product badges.
- **Visible complexity impact**: neutral.
- **Product Surface exceptions**: none.
## Browser Verification Plan *(mandatory)*
- **Browser proof required?**: no.
- **No-browser rationale**: `N/A - no rendered UI surface changed`.
- **Focused path when required**: N/A.
- **Primary interaction to execute**: N/A.
- **Console, Livewire, Filament, network, and 500-error checks**: N/A.
- **Full-suite failure triage**: N/A unless future implementation amends UI into scope.
## Human Product Sanity Check *(mandatory)*
- **Required?**: no.
- **No-human-sanity rationale**: `N/A - no product surface changed`.
- **Reviewer questions**: N/A.
- **Planned result location**: N/A.
## Product Surface Merge Gate Checklist *(mandatory)*
- [x] No-legacy posture or approved exception recorded.
- [x] Product Surface Impact is completed or `N/A` is justified.
- [x] Browser proof is completed or `N/A - no rendered UI surface changed` is justified.
- [x] Human Product Sanity is completed or not applicable with rationale.
- [x] Product Surface exceptions are documented or `none`.
- [x] Implementation report will state Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, visible complexity outcome, and no completed-spec rewrite assertion.
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
- **Cross-cutting feature?**: yes.
- **Interaction class(es)**: Coverage v2 source contract resolution, provider contract metadata, permission/blocker semantics, identity handoff, redaction, claim safety.
- **Systems touched**: existing `CoverageSourceContractResolver`, `CoverageSourceContractDecision`, resource type registry, graph/provider contract registry, identity registry/resolver, generic capture fail-safe path, claim guard, redaction helpers, and focused tests.
- **Existing pattern(s) to extend**: Coverage v2 source-contract resolver and registry metadata; no Exchange/Teams-specific platform.
- **Shared contract / presenter / builder / renderer to reuse**: Existing Tenant Configuration/Coverage v2 services.
- **Why the existing shared path is sufficient or insufficient**: Existing paths already decide capture eligibility, provider boundary, identity, evidence, and claim safety. Spec 427 needs more precise contract states for four concrete types, not a new subsystem.
- **Allowed deviation and why**: A bounded source-contract state/reason metadata addition is allowed if existing `CaptureOutcome` values are too coarse to distinguish blocker reasons.
- **Consistency impact**: State names, blocker reasons, permission metadata, identity handoff, and no-promotion behavior must remain aligned across resolver, registry, tests, and implementation report.
- **Review focus**: No endpoint guessing, direct HTTP, real provider calls, permission widening, fake contract verification, evidence promotion, customer claim, restore claim, `tenant_id`, or mini-platform.
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: no.
- **Shared OperationRun UX contract/layer reused**: N/A.
- **Delegated start/completion UX behaviors**: N/A.
- **Local surface-owned behavior that remains**: none.
- **Queued DB-notification policy**: N/A.
- **Terminal notification path**: N/A.
- **Exception required?**: none.
No remote capture or long-running provider work is allowed in this spec. If future implementation needs live provider checks or OperationRun creation, it must stop and amend this spec/plan/tasks first.
## Provider Boundary / Platform Core Check *(mandatory)*
- **Shared provider/platform boundary touched?**: yes.
- **Boundary classification**: mixed. Coverage v2 source-contract state and claim safety are platform-core; Microsoft Exchange/Teams source details, permission names, response shapes, and provider-native IDs are provider-owned metadata.
- **Seams affected**: source contract resolver, graph/provider contract registry, resource type registry metadata, permission metadata, identity handoff metadata, redaction metadata, and capture eligibility.
- **Neutral platform terms preserved or introduced**: `resource type`, `source contract`, `capture eligibility`, `blocker reason`, `provider connection`, `managed environment`, `workspace`, `evidence`, and `claim state`.
- **Provider-specific semantics retained and why**: Exchange/Teams labels, Microsoft permission names, endpoint/contract names, and provider-native IDs are retained only as provider-owned source metadata needed to decide whether a contract is safe.
- **Why this does not deepen provider coupling accidentally**: The runtime canonical keys already exist; this spec adds bounded contract metadata and blocker states for four concrete Microsoft resource types without spreading Microsoft names into platform ownership truth.
- **Follow-up path**: later specs may promote content-backed evidence only after this spec passes; no speculative multi-provider framework is introduced here.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
`N/A - no operator-facing surface change.`
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
| --- | --- | --- | --- | --- | --- | --- |
| Source contract resolver/registry metadata | no | N/A | Coverage v2 contract family | none | no | `N/A - backend/internal contract metadata only` |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
N/A - no operator-facing surface changed.
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
N/A - no operator-facing surface changed.
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
N/A - no operator-facing surface changed.
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
N/A - no operator-facing surface changed.
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no. The source of truth remains the existing Coverage v2 source-contract resolver/registry path.
- **New persisted entity/table/artifact?**: no.
- **New abstraction?**: no new subsystem is allowed; update existing resolver/registry paths unless implementation proves a smaller helper is necessary.
- **New enum/state/reason family?**: yes, bounded source-contract states or blocker reasons may be added if existing repo outcomes are too coarse.
- **New cross-domain UI framework/taxonomy?**: no.
- **Current operator problem**: Later evidence/certification work needs exact contract blockers so reviewers do not mistake generic fail-safe behavior for readiness.
- **Existing structure is insufficient because**: Current `capture_blocked_missing_contract`-style outcomes do not distinguish missing source, unclear permission, beta-only, unsafe response, missing adapter, unsafe identity, or unsafe redaction.
- **Narrowest correct implementation**: Add exact contract-state metadata for exactly four existing resource types and keep it inside the existing Coverage v2 resolver/registry path.
- **Ownership cost**: Focused resolver/registry tests, per-type contract matrix maintenance, and implementation-report proof for each target type.
- **Alternative intentionally rejected**: Keep generic missing-contract blockers only. Rejected because later specs would not know which blocker is actionable or whether a type can safely attempt capture.
- **Release truth**: Current-release prerequisite truth for the immediate Coverage v2 Exchange/Teams sequence, not future speculative provider frameworking.
### 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.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit and Feature.
- **Validation lane(s)**: fast-feedback / confidence focused files; no browser lane by default.
- **Why this classification and these lanes are sufficient**: Resolver, metadata, permission, response-shape, identity, redaction, and no-promotion behavior can be proven through focused unit/feature tests without rendered UI or real provider calls.
- **New or expanded test families**: focused Spec 427 unit/feature files under `apps/platform/tests/Unit/Support/TenantConfiguration/` and `apps/platform/tests/Feature/TenantConfiguration/`.
- **Fixture / helper cost impact**: local fake contract metadata and payload-shape fixtures only; no real provider setup and no heavy default factories.
- **Heavy-family visibility / justification**: none.
- **Special surface test profile**: N/A - no rendered UI surface changed.
- **Standard-native relief or required special coverage**: browser proof is `N/A`; ordinary resolver/feature coverage is sufficient.
- **Reviewer handoff**: Confirm lane fit, no hidden browser/heavy-governance cost, no real provider calls, and exact focused commands in the implementation report.
- **Budget / baseline / trend impact**: none expected.
- **Escalation needed**: document-in-feature if implementation discovers a contract cannot be verified; follow-up-spec if provider permission productization or live capture becomes necessary.
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage: `N/A - no rendered UI surface changed`; no Product Surface exception.
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec427ExchangeTeamsSourceContractStateTest.php tests/Unit/Support/TenantConfiguration/Spec427ExchangeTransportRuleContractTest.php tests/Unit/Support/TenantConfiguration/Spec427ExchangeAcceptedDomainContractTest.php tests/Unit/Support/TenantConfiguration/Spec427TeamsAppPermissionPolicyContractTest.php tests/Unit/Support/TenantConfiguration/Spec427TeamsMeetingPolicyContractTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractPermissionMetadataTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractResponseShapeTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractIdentityHandoffTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractRedactionTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoEvidencePromotionTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoCompareRenderCertificationTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoCustomerRestoreClaimTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoMiniPlatformTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php`
- `git diff --check`
## Acceptance Criteria
- **AC-427-001**: All four target types have explicit final contract states.
- **AC-427-002**: Any verified contract is pending capture only and includes required permission, response-shape, identity, normalization, and redaction metadata.
- **AC-427-003**: Any blocked contract has an exact blocker state and creates no provider call, resource row, evidence row, coverage promotion, claim promotion, or restore readiness.
- **AC-427-004**: No content-backed, comparable, renderable, certified, customer claim, restore-ready, or Review Pack/report/PDF output is created.
- **AC-427-005**: No direct HTTP bypass, endpoint guessing, runtime docs fetch, provider subsystem, `tenant_id`, Exchange mini-platform, or Teams mini-platform is introduced.
- **AC-427-006**: Focused Spec 427 tests and Spec 426/417/420 regressions pass or exact bounded failures are documented.
- **AC-427-007**: Product Surface result remains `N/A - no rendered UI surface changed`.
## Success Criteria
- **SC-427-001**: The implementation report includes a target resource type mapping table and final contract state per type.
- **SC-427-002**: Resolver tests prove every target type resolves deterministically to verified-pending-capture or an exact blocked state.
- **SC-427-003**: No-promotion tests prove evidence, compare/render, certification, restore, and customer claims remain absent.
- **SC-427-004**: Security tests/static proof confirm no `tenant_id`, no raw payload/default output, and no secret/token logging.
- **SC-427-005**: The package is ready for a later implementation loop without open questions blocking safe implementation.
## Risks
| Risk | Severity | Mitigation |
| --- | ---: | --- |
| Contract verification becomes fake readiness | High | pending-capture-only state and no-promotion tests |
| Endpoint guessed and marked verified | High | contract metadata, resolver tests, and no direct HTTP/static guards |
| Beta source treated as certifiable | High | beta-only blocker/not-certifiable tests |
| Permission model unclear | High | permission blocker state and no scope widening |
| Response shape unsafe | High | response-shape blocker and empty/denied/unsupported/malformed distinctions |
| Identity unsafe | High | identity handoff blocker and Spec 417 regressions |
| Evidence accidentally created | High | no-evidence tests |
| Customer/restore claim appears | High | Claim Guard/no-promotion tests |
| `tenant_id` returns | High | ownership/static tests |
| Exchange/Teams mini-platform appears | Medium | architecture/no-mini-platform tests |
## Assumptions
- Spec 426 is completed and remains valid as fail-safe prerequisite context.
- Current repo-canonical resource type keys are `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy`.
- Existing Coverage v2 source-contract, identity, evidence, and claim-safety services remain the preferred path.
- Some target types may remain blocked after implementation; that is a valid safe outcome when the blocker is exact and test-proven.
## Open Questions
None blocking preparation. Implementation must document any discovered source-contract uncertainty as an exact blocked state rather than asking for permission to guess endpoints or widen provider permissions.
## Follow-up Spec Candidates
- Spec 428 - Exchange/Teams Content-Backed Evidence Promotion.
- Spec 429 - Exchange/Teams Comparable/Renderable Promotion.
- Later Exchange/Teams Certified Compare Pack.
- M365 Customer Reporting Claim Guard Pack.
- M365 Pilot Readiness Gate.
- Provider permission productization only if a verified contract requires scopes not already approved by repo mechanisms.

View File

@ -0,0 +1,124 @@
# Tasks: Spec 427 - Exchange / Teams Verified Source Contract Enablement
**Input**: Design documents from `/specs/427-exchange-teams-verified-source-contract-enablement/`
**Prerequisites**: `spec.md`, `plan.md`, `checklists/requirements.md`
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
- [x] Browser proof is explicitly `N/A - no rendered UI surface changed`.
- [x] Human Product Sanity and Product Surface close-out are `N/A - no rendered UI surface changed`.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or implementation report.
## Phase 1: Preflight And Dependency Guardrails
**Purpose**: Confirm the implementation can proceed without rewriting completed specs or drifting into runtime promotion.
- [x] T001 Capture branch, HEAD, and `git status --short` in `specs/427-exchange-teams-verified-source-contract-enablement/implementation-report.md`.
- [x] T002 Confirm Specs 414, 415, 417, 419, 420, and 426 are completed dependency context only and do not modify their artifacts.
- [x] T003 Verify current canonical resource names in `apps/platform/app/Services/TenantConfiguration/ResourceTypeRegistry.php` and document the mapping from `exchange.*` / `teams.*` labels to repo keys.
- [x] T004 Verify current fail-safe resolver behavior for `transportRule`, `acceptedDomain`, `appPermissionPolicy`, and `meetingPolicy` in `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php`.
- [x] T005 Confirm no UI, route, navigation, Filament provider, browser proof, OperationRun, real provider capture, evidence promotion, compare/render promotion, certification, restore, or customer output is required by this spec.
- [x] T006 Confirm no `tenant_id`, legacy adapter, fallback reader, dual write, or Coverage v1 vocabulary path is needed.
## Phase 2: Source Contract State Model
**Purpose**: Make the verified-or-blocked contract result precise without creating a parallel source-of-truth layer.
- [x] T007 [P] Add or update unit coverage for contract state mapping in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTeamsSourceContractStateTest.php`.
- [x] T008 Define or map `contract_verified_pending_capture`, `contract_blocked_missing_source`, `contract_blocked_permission_unclear`, `contract_blocked_beta_only`, `contract_blocked_response_shape_unsafe`, `contract_blocked_repo_adapter_missing`, `contract_blocked_identity_unsafe`, and `contract_blocked_redaction_unsafe` in `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` and `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractDecision.php`.
- [x] T009 Ensure any new state/reason values have behavior or follow-up consequences and are documented in `specs/427-exchange-teams-verified-source-contract-enablement/implementation-report.md`.
- [x] T010 Ensure existing `apps/platform/app/Support/TenantConfiguration/CaptureOutcome.php` values remain compatible; do not create ambiguous duplicate truth between capture outcomes and source-contract states.
## Phase 3: Per-Type Contract Verification
**Purpose**: Verify each target contract or block it with an exact safe reason.
- [x] T011 [P] Add `transportRule` contract verification tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeTransportRuleContractTest.php`.
- [x] T012 [P] Add `acceptedDomain` contract verification tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec427ExchangeAcceptedDomainContractTest.php`.
- [x] T013 [P] Add `appPermissionPolicy` contract verification tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec427TeamsAppPermissionPolicyContractTest.php`.
- [x] T014 [P] Add `meetingPolicy` contract verification tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec427TeamsMeetingPolicyContractTest.php`.
- [x] T015 Verify or block the `transportRule` source contract through existing resolver/registry files: `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` and, only if repo-safe, `apps/platform/config/graph_contracts.php`.
- [x] T016 Verify or block the `acceptedDomain` source contract through existing resolver/registry files: `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` and, only if repo-safe, `apps/platform/config/graph_contracts.php`.
- [x] T017 Verify or block the `appPermissionPolicy` source contract through existing resolver/registry files: `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` and, only if repo-safe, `apps/platform/config/graph_contracts.php`.
- [x] T018 Verify or block the `meetingPolicy` source contract through existing resolver/registry files: `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` and, only if repo-safe, `apps/platform/config/graph_contracts.php`.
- [x] T019 Prove the existing provider adapter path for any `repo_existing_provider_adapter` source class, or assert `contract_blocked_repo_adapter_missing` in the relevant per-type test; document the final source class, source contract name/version, permission model, response shape, identity handoff, redaction rules, provider adapter proof/blocker, and final blocker/verified state for all four types in `specs/427-exchange-teams-verified-source-contract-enablement/implementation-report.md`.
## Phase 4: Permission, Response Shape, Identity, And Redaction Safety
**Purpose**: Ensure verified contracts are safe enough for a later capture spec and unsafe contracts stay blocked.
- [x] T020 [P] Add permission metadata tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractPermissionMetadataTest.php`.
- [x] T021 [P] Add response-shape tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractResponseShapeTest.php`.
- [x] T022 [P] Add identity handoff tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractIdentityHandoffTest.php`.
- [x] T023 [P] Add redaction metadata tests in `apps/platform/tests/Unit/Support/TenantConfiguration/Spec427SourceContractRedactionTest.php`.
- [x] T024 Ensure unclear required permissions block verification, do not widen provider scopes in `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` or `apps/platform/config/graph_contracts.php`, and capture static/diff proof that no provider OAuth scope, provider capability, or permission productization config changed outside this bounded contract metadata path.
- [x] T025 Ensure unsafe response semantics block verification in `apps/platform/app/Services/TenantConfiguration/CoverageSourceContractResolver.php` when empty, denied, unsupported, unavailable, and malformed responses cannot be distinguished.
- [x] T026 Ensure display-name-only, order/hash-only, or otherwise unstable identity blocks verification using existing `apps/platform/app/Services/TenantConfiguration/CoverageIdentityStrategyRegistry.php` and `apps/platform/app/Services/TenantConfiguration/CanonicalIdentityResolver.php`.
- [x] T027 Ensure sensitive fields and permission context have redaction rules in `apps/platform/app/Services/TenantConfiguration/CoveragePayloadRedactor.php` or source-contract metadata before any contract is marked verified pending capture.
## Phase 5: No-Promotion And Architecture Guards
**Purpose**: Preserve Spec 426 fail-safe behavior and prevent premature readiness claims.
- [x] T028 [P] Add no-evidence-promotion tests in `apps/platform/tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoEvidencePromotionTest.php`.
- [x] T029 [P] Add no compare/render/certification tests in `apps/platform/tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoCompareRenderCertificationTest.php`.
- [x] T030 [P] Add no customer/restore claim tests in `apps/platform/tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoCustomerRestoreClaimTest.php`.
- [x] T031 [P] Add no `tenant_id` ownership regression in `apps/platform/tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoTenantIdTest.php`.
- [x] T032 [P] Add no Exchange/Teams mini-platform regression in `apps/platform/tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoMiniPlatformTest.php`.
- [x] T033 Ensure verified contracts remain `pending_capture` only and do not create `TenantConfigurationResource` or `TenantConfigurationResourceEvidence` rows through `apps/platform/app/Services/TenantConfiguration/GenericContentEvidenceCaptureService.php`, `apps/platform/app/Models/TenantConfigurationResource.php`, or `apps/platform/app/Models/TenantConfigurationResourceEvidence.php` in this spec.
- [x] T034 Ensure no new Exchange/Teams-specific migration, model, route, Filament Resource/Page/Widget, dashboard, report, export, Review Pack output, PDF output, restore action, or customer surface is introduced under `apps/platform/database/`, `apps/platform/app/Models/`, `apps/platform/app/Filament/`, `apps/platform/routes/`, or `apps/platform/resources/`.
## Phase 6: Regression And Validation
**Purpose**: Prove Spec 427 did not weaken completed Coverage v2 prerequisites.
- [x] T035 Run focused Spec 427 unit tests with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec427ExchangeTeamsSourceContractStateTest.php tests/Unit/Support/TenantConfiguration/Spec427ExchangeTransportRuleContractTest.php tests/Unit/Support/TenantConfiguration/Spec427ExchangeAcceptedDomainContractTest.php tests/Unit/Support/TenantConfiguration/Spec427TeamsAppPermissionPolicyContractTest.php tests/Unit/Support/TenantConfiguration/Spec427TeamsMeetingPolicyContractTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractPermissionMetadataTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractResponseShapeTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractIdentityHandoffTest.php tests/Unit/Support/TenantConfiguration/Spec427SourceContractRedactionTest.php`.
- [x] T036 Run focused Spec 427 feature tests with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoEvidencePromotionTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoCompareRenderCertificationTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoCustomerRestoreClaimTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoTenantIdTest.php tests/Feature/TenantConfiguration/Spec427ExchangeTeamsNoMiniPlatformTest.php`.
- [x] T037 Run Spec 426 source/fail-safe regressions with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec426ExchangeTeamsSourceContractResolverTest.php tests/Feature/TenantConfiguration/Spec426ExchangeTeamsCoreEvidenceReadinessTest.php`.
- [x] T038 Run Spec 417 identity and Spec 420 generic evidence regressions with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/TenantConfiguration/Spec417CoverageIdentityStrategyRegistryTest.php tests/Unit/Support/TenantConfiguration/Spec420M365CaptureSourceContractResolverTest.php tests/Feature/TenantConfiguration/Spec420M365GenericEvidenceCaptureTest.php`.
- [x] T039 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
- [x] T040 Run `git diff --check`.
- [x] T041 If any combined `--filter=Spec427` or regression filter is killed by Signal 9, document the combined command, Signal 9 result, direct-file runs, pass counts, assertion counts, and regression pass counts in `specs/427-exchange-teams-verified-source-contract-enablement/implementation-report.md`.
## Phase 7: Product Surface, Filament, Deployment, And Close-Out
**Purpose**: Finish the active spec without implying application implementation readiness beyond the bounded contract slice.
- [x] T042 Record Product Surface result as `N/A - no rendered UI surface changed` in `specs/427-exchange-teams-verified-source-contract-enablement/implementation-report.md`.
- [x] T043 Record Livewire v4 compliance, provider registration location, global search posture, destructive/high-impact action posture, asset strategy, tests/browser result, deployment impact, visible complexity outcome, and no completed-spec rewrite assertion in `specs/427-exchange-teams-verified-source-contract-enablement/implementation-report.md`.
- [x] T044 Complete the required source contract matrix and no-promotion matrix in `specs/427-exchange-teams-verified-source-contract-enablement/implementation-report.md`.
- [x] T045 Document deferred work: content-backed evidence promotion, compare/render promotion, certified compare pack, customer reporting claims, restore/apply, provider permission productization, and optional resource expansion.
## Dependencies & Execution Order
- Phase 1 blocks all implementation.
- Phase 2 must complete before per-type contract verification.
- Phase 3 and Phase 4 tests can be developed in parallel by file.
- Phase 5 no-promotion guards must pass before claiming any target type is verified pending capture.
- Phase 6 validation and Phase 7 close-out are final gates.
## Parallel Opportunities
- T011-T014 can run in parallel after T007-T010.
- T020-T023 can run in parallel after the contract metadata shape is decided.
- T028-T032 can run in parallel because they cover separate guard files.
## Implementation Strategy
1. Preserve current fail-safe behavior first.
2. Add exact blocker/verified metadata without promoting evidence.
3. Verify each target independently.
4. Run no-promotion guards before any regression close-out.
5. Stop if implementation requires UI, live provider calls, new permissions, migrations, or customer claims.
## Stop Conditions
- A target contract can only be "verified" through endpoint guessing or runtime docs fetch.
- A verified state would require provider permission widening not already productized.
- Implementation needs real provider capture or OperationRun creation.
- Evidence, compare/render, certification, restore, customer output, UI, route, navigation, or report output becomes necessary.
- `tenant_id`, legacy adapters, fallback readers, dual writes, or a new Exchange/Teams mini-platform appear.