Spec 204: harden platform core vocabulary (#234)

## Summary
- add the Spec 204 platform vocabulary foundation, including canonical glossary terms, registry ownership descriptors, canonical operation type and alias resolution, and explicit reason ownership and platform reason-family metadata
- harden platform-facing compare, snapshot, evidence, monitoring, review, and reporting surfaces so they prefer governed-subject and canonical operation semantics while preserving intentional Intune-owned terminology
- extend Spec 204 unit, feature, Filament, and architecture coverage and add the full spec artifacts, checklist, and completed task ledger

## Verification
- ran the focused recent-change Sail verification pack for the new glossary and reason-semantics work
- ran the full Spec 204 quickstart verification pack under Sail
- ran `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- ran an integrated-browser smoke pass covering tenant dashboard, operations, operation detail, baseline compare, evidence, reviews, review packs, provider connections, inventory items, backup schedules, onboarding, and the system dashboard/operations/failures/run-detail surfaces

## Notes
- provider registration is unchanged and remains in `bootstrap/providers.php`
- no new destructive actions or asset-registration changes are introduced by this branch

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #234
This commit is contained in:
ahmido 2026-04-14 06:09:42 +00:00
parent d644265d30
commit ad16eee591
83 changed files with 5104 additions and 155 deletions

View File

@ -182,6 +182,8 @@ ## Active Technologies
- PostgreSQL via existing `baseline_profiles.scope_jsonb`, `baseline_tenant_assignments.override_scope_jsonb`, and `operation_runs.context`; no new tables planned (202-governance-subject-taxonomy) - PostgreSQL via existing `baseline_profiles.scope_jsonb`, `baseline_tenant_assignments.override_scope_jsonb`, and `operation_runs.context`; no new tables planned (202-governance-subject-taxonomy)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services (203-baseline-compare-strategy) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services (203-baseline-compare-strategy)
- PostgreSQL via existing baseline snapshots, baseline snapshot items, `operation_runs`, findings, and baseline scope JSON; no new top-level tables planned (203-baseline-compare-strategy) - PostgreSQL via existing baseline snapshots, baseline snapshot items, `operation_runs`, findings, and baseline scope JSON; no new top-level tables planned (203-baseline-compare-strategy)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces (204-platform-core-vocabulary-hardening)
- PostgreSQL via existing `operation_runs.type`, `operation_runs.context`, `baseline_profiles.scope_jsonb`, `baseline_snapshot_items`, findings, evidence payloads, and current config-backed registries; no new top-level tables planned (204-platform-core-vocabulary-hardening)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -216,8 +218,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 204-platform-core-vocabulary-hardening: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces
- 203-baseline-compare-strategy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services - 203-baseline-compare-strategy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services
- 202-governance-subject-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService` - 202-governance-subject-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService`
- 196-hard-filament-nativity-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `DependencyQueryService`, `DependencyTargetResolver`, `TenantRequiredPermissionsViewModelBuilder`, `ArtifactTruthPresenter`, `WorkspaceContext`, Filament `InteractsWithTable`, Filament `TableComponent`, and existing badge and action-surface helpers
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -21,6 +21,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -203,6 +204,10 @@ public function refreshStats(): void
protected function getViewData(): array protected function getViewData(): array
{ {
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : []; $evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
$reasonPresenter = app(ReasonPresenter::class);
$reasonSemantics = $reasonPresenter->semantics(
$reasonPresenter->forArtifactTruth($this->reasonCode, 'baseline_compare_landing'),
);
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true); $hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null) $evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
? (int) $evidenceGapSummary['count'] ? (int) $evidenceGapSummary['count']
@ -276,6 +281,7 @@ protected function getViewData(): array
'whyNoFindingsFallback' => $whyNoFindingsFallback, 'whyNoFindingsFallback' => $whyNoFindingsFallback,
'whyNoFindingsColor' => $whyNoFindingsColor, 'whyNoFindingsColor' => $whyNoFindingsColor,
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null, 'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
'reasonSemantics' => $reasonSemantics,
]; ];
} }

View File

@ -130,15 +130,15 @@ public function form(Schema $schema): Schema
]) ])
->schema([ ->schema([
Select::make('draftSelectedPolicyTypes') Select::make('draftSelectedPolicyTypes')
->label('Policy types') ->label('Governed subjects')
->options(fn (): array => $this->matrixOptions('policyTypeOptions')) ->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
->multiple() ->multiple()
->searchable() ->searchable()
->preload() ->preload()
->native(false) ->native(false)
->placeholder('All policy types') ->placeholder('All governed subjects')
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === [] ->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
? 'Policy type filters appear after a usable reference snapshot is available.' ? 'Governed subject filters appear after a usable reference snapshot is available.'
: null) : null)
->extraFieldWrapperAttributes([ ->extraFieldWrapperAttributes([
'data-testid' => 'matrix-policy-type-filter', 'data-testid' => 'matrix-policy-type-filter',
@ -426,7 +426,7 @@ public function activeFilterSummary(): array
$summary = []; $summary = [];
if ($this->selectedPolicyTypes !== []) { if ($this->selectedPolicyTypes !== []) {
$summary['Policy types'] = count($this->selectedPolicyTypes); $summary['Governed subjects'] = count($this->selectedPolicyTypes);
} }
if ($this->selectedStates !== []) { if ($this->selectedStates !== []) {
@ -452,7 +452,7 @@ public function stagedFilterSummary(): array
$summary = []; $summary = [];
if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) { if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) {
$summary['Policy types'] = count($this->draftSelectedPolicyTypes); $summary['Governed subjects'] = count($this->draftSelectedPolicyTypes);
} }
if ($this->draftSelectedStates !== $this->selectedStates) { if ($this->draftSelectedStates !== $this->selectedStates) {

View File

@ -31,6 +31,7 @@
use App\Support\OpsUx\RunDurationInsights; use App\Support\OpsUx\RunDurationInsights;
use App\Support\OpsUx\SummaryCountsNormalizer; use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\RestoreSafety\RestoreSafetyResolver; use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -219,6 +220,15 @@ public static function table(Table $table): Table
->all(); ->all();
return FilterOptionCatalog::operationTypes(array_keys($types)); return FilterOptionCatalog::operationTypes(array_keys($types));
})
->query(function (Builder $query, array $data): Builder {
$value = data_get($data, 'value');
if (! is_string($value) || trim($value) === '') {
return $query;
}
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical($value));
}), }),
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())), ->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
@ -268,6 +278,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
: null; : null;
$artifactTruth = static::artifactTruthEnvelope($record); $artifactTruth = static::artifactTruthEnvelope($record);
$operatorExplanation = $artifactTruth?->operatorExplanation; $operatorExplanation = $artifactTruth?->operatorExplanation;
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation); $primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$restoreContinuation = static::restoreContinuation($record); $restoreContinuation = static::restoreContinuation($record);
$supportingGroups = static::supportingGroups( $supportingGroups = static::supportingGroups(
@ -275,6 +286,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
factory: $factory, factory: $factory,
referencedTenantLifecycle: $referencedTenantLifecycle, referencedTenantLifecycle: $referencedTenantLifecycle,
operatorExplanation: $operatorExplanation, operatorExplanation: $operatorExplanation,
reasonEnvelope: $reasonEnvelope,
primaryNextStep: $primaryNextStep, primaryNextStep: $primaryNextStep,
); );
@ -439,7 +451,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
id: 'baseline_compare_gap_details', id: 'baseline_compare_gap_details',
kind: 'type_specific_detail', kind: 'type_specific_detail',
title: 'Evidence gap details', title: 'Evidence gap details',
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.', description: 'Governed subjects affected by evidence gaps, grouped by reason and searchable by reason, governed subject, or subject key.',
view: 'filament.infolists.entries.evidence-gap-subjects', view: 'filament.infolists.entries.evidence-gap-subjects',
viewData: [ viewData: [
'summary' => $gapSummary, 'summary' => $gapSummary,
@ -537,10 +549,12 @@ private static function supportingGroups(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory, \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle, ?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
?OperatorExplanationPattern $operatorExplanation, ?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
array $primaryNextStep, array $primaryNextStep,
): array { ): array {
$groups = []; $groups = [];
$hasElevatedLifecycleState = static::lifecycleAttentionSummary($record) !== null; $hasElevatedLifecycleState = static::lifecycleAttentionSummary($record) !== null;
$reasonSemantics = app(ReasonPresenter::class)->semantics($reasonEnvelope);
$guidanceItems = array_values(array_filter([ $guidanceItems = array_values(array_filter([
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null $operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
@ -579,6 +593,24 @@ private static function supportingGroups(
); );
} }
$reasonSemanticsItems = array_values(array_filter([
is_string($reasonSemantics['owner_label'] ?? null)
? $factory->keyFact('Reason owner', (string) $reasonSemantics['owner_label'])
: null,
is_string($reasonSemantics['family_label'] ?? null)
? $factory->keyFact('Platform reason family', (string) $reasonSemantics['family_label'])
: null,
]));
if ($reasonSemanticsItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'reason_semantics',
title: 'Explanation semantics',
items: $reasonSemanticsItems,
description: 'Platform meaning stays separate from domain-specific diagnostic detail during rollout.',
);
}
$lifecycleItems = array_values(array_filter([ $lifecycleItems = array_values(array_filter([
$referencedTenantLifecycle !== null $referencedTenantLifecycle !== null
? $factory->keyFact( ? $factory->keyFact(

View File

@ -21,6 +21,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewCompletenessState; use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus; use App\Support\TenantReviewStatus;
@ -564,9 +565,12 @@ private static function reviewCompletenessCountLabel(string $state): string
private static function summaryPresentation(TenantReview $record): array private static function summaryPresentation(TenantReview $record): array
{ {
$summary = is_array($record->summary) ? $record->summary : []; $summary = is_array($record->summary) ? $record->summary : [];
$truthEnvelope = static::truthEnvelope($record);
$reasonPresenter = app(ReasonPresenter::class);
return [ return [
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(), 'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [], 'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [], 'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],

View File

@ -14,6 +14,7 @@
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -131,7 +132,7 @@ protected function getViewData(): array
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant); $canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
$latestPack = ReviewPack::query() $latestPack = ReviewPack::query()
->with('tenantReview') ->with(['tenantReview', 'operationRun'])
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('created_at') ->orderByDesc('created_at')
->orderByDesc('id') ->orderByDesc('id')
@ -166,10 +167,25 @@ protected function getViewData(): array
} }
$failedReason = null; $failedReason = null;
$failedReasonDetail = null;
$failedReasonSemantics = null;
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) { if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
$reasonPresenter = app(ReasonPresenter::class);
$failedEnvelope = $reasonPresenter->forOperationRun($latestPack->operationRun, 'review_pack_widget');
if ($failedEnvelope !== null) {
$failedReason = $failedEnvelope->operatorLabel;
$failedReasonDetail = $failedEnvelope->shortExplanation;
$failedReasonSemantics = $reasonPresenter->semantics($failedEnvelope);
}
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : []; $opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
if ($failedReason === null) {
$failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error'); $failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error');
} }
}
return [ return [
'tenant' => $tenant, 'tenant' => $tenant,
@ -180,6 +196,8 @@ protected function getViewData(): array
'canManage' => $canManage, 'canManage' => $canManage,
'downloadUrl' => $downloadUrl, 'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason, 'failedReason' => $failedReason,
'failedReasonDetail' => $failedReasonDetail,
'failedReasonSemantics' => $failedReasonSemantics,
'reviewUrl' => $reviewUrl, 'reviewUrl' => $reviewUrl,
]; ];
} }
@ -208,6 +226,8 @@ private function emptyState(): array
'canManage' => false, 'canManage' => false,
'downloadUrl' => null, 'downloadUrl' => null,
'failedReason' => null, 'failedReason' => null,
'failedReasonDetail' => null,
'failedReasonSemantics' => null,
'reviewUrl' => null, 'reviewUrl' => null,
]; ];
} }

View File

@ -94,13 +94,13 @@ public function table(Table $table): Table
->sortable() ->sortable()
->wrap() ->wrap()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('policy_type') TextColumn::make('governed_subject_label')
->label(__('baseline-compare.evidence_gap_policy_type')) ->label(__('baseline-compare.evidence_gap_policy_type'))
->badge() ->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType)) ->formatStateUsing(fn (mixed $state): string => is_string($state) && trim($state) !== '' ? $state : 'Unknown governed subject')
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)) ->color(fn (mixed $state, Model $record): string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->color)
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType)) ->icon(fn (mixed $state, Model $record): ?string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->icon)
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)) ->iconColor(fn (mixed $state, Model $record): ?string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->iconColor)
->searchable() ->searchable()
->sortable() ->sortable()
->wrap(), ->wrap(),

View File

@ -6,6 +6,7 @@
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationTypeResolution;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\Operations\OperationLifecyclePolicy; use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Operations\OperationRunFreshnessState; use App\Support\Operations\OperationRunFreshnessState;
@ -291,6 +292,16 @@ public function inventoryCoverage(): ?InventoryCoverage
return InventoryCoverage::fromContext($this->context); return InventoryCoverage::fromContext($this->context);
} }
public function resolvedOperationType(): OperationTypeResolution
{
return OperationCatalog::resolve((string) $this->type);
}
public function canonicalOperationType(): string
{
return $this->resolvedOperationType()->canonical->canonicalCode;
}
public function isGovernanceArtifactOperation(): bool public function isGovernanceArtifactOperation(): bool
{ {
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type); return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);

View File

@ -9,6 +9,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder; use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData; use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
@ -51,6 +52,8 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
static fn (RenderedSnapshotGroup $group): array => [ static fn (RenderedSnapshotGroup $group): array => [
'policyType' => $group->policyType, 'policyType' => $group->policyType,
'label' => $group->label, 'label' => $group->label,
'governedSubjectLabel' => data_get($group->subjectDescriptor, 'display_label', $group->label),
'subjectDescriptor' => $group->subjectDescriptor,
'itemCount' => $group->itemCount, 'itemCount' => $group->itemCount,
'fidelity' => $group->fidelity->value, 'fidelity' => $group->fidelity->value,
'gapCount' => $group->gapSummary->count, 'gapCount' => $group->gapSummary->count,
@ -166,7 +169,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Coverage summary', title: 'Coverage summary',
view: 'filament.infolists.entries.baseline-snapshot-summary-table', view: 'filament.infolists.entries.baseline-snapshot-summary-table',
viewData: ['rows' => $rendered->summaryRows], viewData: ['rows' => $rendered->summaryRows],
emptyState: $factory->emptyState('No captured policy types are available in this snapshot.'), emptyState: $factory->emptyState('No captured governed subjects are available in this snapshot.'),
), ),
$factory->viewSection( $factory->viewSection(
id: 'related_context', id: 'related_context',
@ -179,7 +182,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
$factory->viewSection( $factory->viewSection(
id: 'captured_policy_types', id: 'captured_policy_types',
kind: 'domain_detail', kind: 'domain_detail',
title: 'Captured policy types', title: 'Captured governed subjects',
view: 'filament.infolists.entries.baseline-snapshot-groups', view: 'filament.infolists.entries.baseline-snapshot-groups',
viewData: ['groups' => array_map( viewData: ['groups' => array_map(
static fn (RenderedSnapshotGroup $group): array => $group->toArray(), static fn (RenderedSnapshotGroup $group): array => $group->toArray(),
@ -250,7 +253,8 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
$renderer = $this->registry->rendererFor($policyType); $renderer = $this->registry->rendererFor($policyType);
$fallbackRenderer = $this->registry->fallbackRenderer(); $fallbackRenderer = $this->registry->fallbackRenderer();
$renderingError = null; $renderingError = null;
$technicalPayload = $this->technicalPayload($items); $subjectDescriptor = $this->subjectDescriptor($policyType);
$technicalPayload = $this->technicalPayload($items) + ['subject_descriptor' => $subjectDescriptor];
try { try {
$renderedItems = $items $renderedItems = $items
@ -261,7 +265,7 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $fallbackRenderer->render($item)) ->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $fallbackRenderer->render($item))
->all(); ->all();
$renderingError = 'Structured rendering failed for this policy type. Fallback metadata is shown instead.'; $renderingError = 'Structured rendering failed for this governed subject family. Fallback metadata is shown instead.';
} }
/** @var array<int, RenderedSnapshotItem> $renderedItems */ /** @var array<int, RenderedSnapshotItem> $renderedItems */
@ -299,6 +303,7 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
coverageHint: $coverageHint, coverageHint: $coverageHint,
capturedAt: is_string($capturedAt) ? $capturedAt : null, capturedAt: is_string($capturedAt) ? $capturedAt : null,
technicalPayload: $technicalPayload, technicalPayload: $technicalPayload,
subjectDescriptor: $subjectDescriptor,
); );
} }
@ -404,9 +409,28 @@ private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
private function typeLabel(string $policyType): string private function typeLabel(string $policyType): string
{ {
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType) return (string) (data_get($this->subjectDescriptor($policyType), 'display_label')
?? InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
?? InventoryPolicyTypeMeta::label($policyType) ?? InventoryPolicyTypeMeta::label($policyType)
?? Str::headline($policyType); ?? Str::headline($policyType));
}
/**
* @return array<string, mixed>
*/
private function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_snapshot');
return $cache[$policyType] = $result->descriptor->toArray();
} }
private function formatTimestamp(?string $value): string private function formatTimestamp(?string $value): string

View File

@ -9,6 +9,7 @@
/** /**
* @param array<int, RenderedSnapshotItem> $items * @param array<int, RenderedSnapshotItem> $items
* @param array<string, mixed> $technicalPayload * @param array<string, mixed> $technicalPayload
* @param array<string, mixed> $subjectDescriptor
*/ */
public function __construct( public function __construct(
public string $policyType, public string $policyType,
@ -22,6 +23,7 @@ public function __construct(
public ?string $coverageHint = null, public ?string $coverageHint = null,
public ?string $capturedAt = null, public ?string $capturedAt = null,
public array $technicalPayload = [], public array $technicalPayload = [],
public array $subjectDescriptor = [],
) {} ) {}
/** /**
@ -46,7 +48,8 @@ public function __construct(
* renderingError: ?string, * renderingError: ?string,
* coverageHint: ?string, * coverageHint: ?string,
* capturedAt: ?string, * capturedAt: ?string,
* technicalPayload: array<string, mixed> * technicalPayload: array<string, mixed>,
* subjectDescriptor: array<string, mixed>
* } * }
*/ */
public function toArray(): array public function toArray(): array
@ -66,6 +69,7 @@ public function toArray(): array
'coverageHint' => $this->coverageHint, 'coverageHint' => $this->coverageHint,
'capturedAt' => $this->capturedAt, 'capturedAt' => $this->capturedAt,
'technicalPayload' => $this->technicalPayload, 'technicalPayload' => $this->technicalPayload,
'subjectDescriptor' => $this->subjectDescriptor,
]; ];
} }
} }

View File

@ -5,6 +5,7 @@
namespace App\Support\Baselines; namespace App\Support\Baselines;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final class BaselineCompareEvidenceGapDetails final class BaselineCompareEvidenceGapDetails
@ -333,6 +334,8 @@ public static function tableRows(array $buckets): array
'reason_code' => $reasonCode, 'reason_code' => $reasonCode,
'reason_label' => self::reasonLabel($reasonCode), 'reason_label' => self::reasonLabel($reasonCode),
'policy_type' => $policyType, 'policy_type' => $policyType,
'governed_subject_label' => (string) ($row['governed_subject_label'] ?? self::governedSubjectLabel($policyType)),
'governed_subject' => is_array($row['governed_subject'] ?? null) ? $row['governed_subject'] : self::subjectDescriptor($policyType),
'subject_key' => $subjectKey, 'subject_key' => $subjectKey,
'subject_class' => $subjectClass, 'subject_class' => $subjectClass,
'subject_class_label' => self::subjectClassLabel($subjectClass), 'subject_class_label' => self::subjectClassLabel($subjectClass),
@ -373,10 +376,11 @@ public static function reasonFilterOptions(array $rows): array
public static function policyTypeFilterOptions(array $rows): array public static function policyTypeFilterOptions(array $rows): array
{ {
return collect($rows) return collect($rows)
->pluck('policy_type') ->filter(fn (array $row): bool => filled($row['policy_type'] ?? null))
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '') ->mapWithKeys(fn (array $row): array => [
->mapWithKeys(fn (string $value): array => [$value => $value]) (string) $row['policy_type'] => (string) ($row['governed_subject_label'] ?? $row['policy_type']),
->sortKeysUsing('strnatcasecmp') ])
->sortBy(fn (string $label): string => Str::lower($label))
->all(); ->all();
} }
@ -659,16 +663,20 @@ private static function projectSubjectRow(array $subject): array
$subjectClass = (string) $subject['subject_class']; $subjectClass = (string) $subject['subject_class'];
$resolutionOutcome = (string) $subject['resolution_outcome']; $resolutionOutcome = (string) $subject['resolution_outcome'];
$operatorActionCategory = (string) $subject['operator_action_category']; $operatorActionCategory = (string) $subject['operator_action_category'];
$policyType = (string) ($subject['policy_type'] ?? '');
return array_merge($subject, [ return array_merge($subject, [
'reason_label' => self::reasonLabel($reasonCode), 'reason_label' => self::reasonLabel($reasonCode),
'governed_subject' => self::subjectDescriptor($policyType),
'governed_subject_label' => self::governedSubjectLabel($policyType),
'subject_class_label' => self::subjectClassLabel($subjectClass), 'subject_class_label' => self::subjectClassLabel($subjectClass),
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome), 'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory), 'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
'search_text' => Str::lower(trim(implode(' ', array_filter([ 'search_text' => Str::lower(trim(implode(' ', array_filter([
$reasonCode, $reasonCode,
self::reasonLabel($reasonCode), self::reasonLabel($reasonCode),
(string) ($subject['policy_type'] ?? ''), $policyType,
self::governedSubjectLabel($policyType),
(string) ($subject['subject_key'] ?? ''), (string) ($subject['subject_key'] ?? ''),
$subjectClass, $subjectClass,
self::subjectClassLabel($subjectClass), self::subjectClassLabel($subjectClass),
@ -682,6 +690,29 @@ private static function projectSubjectRow(array $subject): array
]); ]);
} }
/**
* @return array<string, mixed>
*/
private static function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_compare');
return $cache[$policyType] = $result->descriptor->toArray();
}
private static function governedSubjectLabel(string $policyType): string
{
return (string) (data_get(self::subjectDescriptor($policyType), 'display_label') ?: $policyType);
}
private static function stringOrNull(mixed $value): ?string private static function stringOrNull(mixed $value): ?string
{ {
if (! is_string($value)) { if (! is_string($value)) {

View File

@ -128,7 +128,7 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.', $stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
$isFailed => 'The last compare run did not finish cleanly, so current counts should not be treated as decision-grade.', $isFailed => 'The last compare run did not finish cleanly, so current counts should not be treated as decision-grade.',
$stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.', $stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.',
$stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.', $stats->uncoveredTypes !== [] => 'One or more in-scope governed subjects were not fully covered in this compare run.',
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.', $hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
$isInProgress => 'Counts will become decision-grade after the compare run finishes.', $isInProgress => 'Counts will become decision-grade after the compare run finishes.',
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.', in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',

View File

@ -17,6 +17,7 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -81,8 +82,8 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '') ->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->unique() ->unique()
->sort() ->sort()
->mapWithKeys(static fn (string $type): array => [ ->mapWithKeys(fn (string $type): array => [
$type => InventoryPolicyTypeMeta::label($type) ?? $type, $type => $this->governedSubjectLabel($type),
]) ])
->all(); ->all();
@ -118,7 +119,7 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
], ],
'subjectSortOptions' => [ 'subjectSortOptions' => [
'deviation_breadth' => 'Deviation breadth', 'deviation_breadth' => 'Deviation breadth',
'policy_type' => 'Policy type', 'policy_type' => 'Governed subject',
'display_name' => 'Display name', 'display_name' => 'Display name',
], ],
'stateLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixState, [ 'stateLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixState, [
@ -209,6 +210,8 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
$subject = [ $subject = [
'subjectKey' => $subjectKey, 'subjectKey' => $subjectKey,
'policyType' => (string) $item->policy_type, 'policyType' => (string) $item->policy_type,
'governedSubjectLabel' => $this->governedSubjectLabel((string) $item->policy_type),
'subjectDescriptor' => $this->subjectDescriptor((string) $item->policy_type),
'displayName' => $this->subjectDisplayName($item), 'displayName' => $this->subjectDisplayName($item),
'baselineExternalId' => is_string($item->subject_external_id) ? $item->subject_external_id : null, 'baselineExternalId' => is_string($item->subject_external_id) ? $item->subject_external_id : null,
]; ];
@ -572,7 +575,7 @@ private function reasonSummary(string $state, ?string $reasonCode, bool $policyT
'stale_result' => 'Refresh recommended before acting on this result.', 'stale_result' => 'Refresh recommended before acting on this result.',
'not_compared' => $policyTypeCovered 'not_compared' => $policyTypeCovered
? 'No completed compare result is available yet.' ? 'No completed compare result is available yet.'
: 'Policy type coverage was not proven in the latest compare run.', : 'Governed subject coverage was not proven in the latest compare run.',
default => null, default => null,
}; };
} }
@ -642,6 +645,8 @@ private function subjectSummary(array $subject, array $cells): array
return [ return [
'subjectKey' => $subject['subjectKey'], 'subjectKey' => $subject['subjectKey'],
'policyType' => $subject['policyType'], 'policyType' => $subject['policyType'],
'governedSubjectLabel' => $subject['governedSubjectLabel'] ?? $this->governedSubjectLabel((string) $subject['policyType']),
'subjectDescriptor' => $subject['subjectDescriptor'] ?? $this->subjectDescriptor((string) $subject['policyType']),
'displayName' => $subject['displayName'], 'displayName' => $subject['displayName'],
'baselineExternalId' => $subject['baselineExternalId'], 'baselineExternalId' => $subject['baselineExternalId'],
'deviationBreadth' => $this->countStates($cells, ['differ', 'missing']), 'deviationBreadth' => $this->countStates($cells, ['differ', 'missing']),
@ -809,8 +814,13 @@ private function sortRows(array $rows, string $sort): array
$rightSubject = $right['subject'] ?? []; $rightSubject = $right['subject'] ?? [];
return match ($sort) { return match ($sort) {
'policy_type' => [(string) ($leftSubject['policyType'] ?? ''), Str::lower((string) ($leftSubject['displayName'] ?? ''))] 'policy_type' => [
<=> [(string) ($rightSubject['policyType'] ?? ''), Str::lower((string) ($rightSubject['displayName'] ?? ''))], Str::lower((string) ($leftSubject['governedSubjectLabel'] ?? $leftSubject['policyType'] ?? '')),
Str::lower((string) ($leftSubject['displayName'] ?? '')),
] <=> [
Str::lower((string) ($rightSubject['governedSubjectLabel'] ?? $rightSubject['policyType'] ?? '')),
Str::lower((string) ($rightSubject['displayName'] ?? '')),
],
'display_name' => [Str::lower((string) ($leftSubject['displayName'] ?? '')), (string) ($leftSubject['subjectKey'] ?? '')] 'display_name' => [Str::lower((string) ($leftSubject['displayName'] ?? '')), (string) ($leftSubject['subjectKey'] ?? '')]
<=> [Str::lower((string) ($rightSubject['displayName'] ?? '')), (string) ($rightSubject['subjectKey'] ?? '')], <=> [Str::lower((string) ($rightSubject['displayName'] ?? '')), (string) ($rightSubject['subjectKey'] ?? '')],
default => [ default => [
@ -914,6 +924,8 @@ private function compactResults(array $rows, array $tenantSummaries): array
'subjectKey' => (string) ($subject['subjectKey'] ?? ''), 'subjectKey' => (string) ($subject['subjectKey'] ?? ''),
'displayName' => $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject', 'displayName' => $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject',
'policyType' => (string) ($subject['policyType'] ?? ''), 'policyType' => (string) ($subject['policyType'] ?? ''),
'governedSubjectLabel' => (string) ($subject['governedSubjectLabel'] ?? $this->governedSubjectLabel((string) ($subject['policyType'] ?? ''))),
'subjectDescriptor' => $subject['subjectDescriptor'] ?? $this->subjectDescriptor((string) ($subject['policyType'] ?? '')),
'baselineExternalId' => $subject['baselineExternalId'] ?? null, 'baselineExternalId' => $subject['baselineExternalId'] ?? null,
'deviationBreadth' => (int) ($subject['deviationBreadth'] ?? 0), 'deviationBreadth' => (int) ($subject['deviationBreadth'] ?? 0),
'missingBreadth' => (int) ($subject['missingBreadth'] ?? 0), 'missingBreadth' => (int) ($subject['missingBreadth'] ?? 0),
@ -974,7 +986,7 @@ private function emptyState(
if ($renderedRowsCount === 0) { if ($renderedRowsCount === 0) {
return [ return [
'title' => 'No rows match the current filters', 'title' => 'No rows match the current filters',
'body' => 'Adjust the policy type, state, or severity filters to broaden the matrix view.', 'body' => 'Adjust the governed subject, state, or severity filters to broaden the matrix view.',
]; ];
} }
@ -1002,4 +1014,31 @@ static function (string $value) use ($domain): array {
$values, $values,
); );
} }
private function governedSubjectLabel(string $policyType): string
{
return (string) data_get(
$this->subjectDescriptor($policyType),
'display_label',
InventoryPolicyTypeMeta::label($policyType) ?? Str::headline($policyType),
);
}
/**
* @return array<string, mixed>
*/
private function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_compare_matrix');
return $cache[$policyType] = $result->descriptor->toArray();
}
} }

View File

@ -7,6 +7,7 @@
use App\Support\Governance\GovernanceDomainKey; use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass; use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use InvalidArgumentException; use InvalidArgumentException;
@ -303,6 +304,16 @@ public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = nul
return $groups; return $groups;
} }
/**
* @return list<array<string, mixed>>
*/
public function subjectDescriptors(?PlatformSubjectDescriptorNormalizer $normalizer = null): array
{
$normalizer ??= app(PlatformSubjectDescriptorNormalizer::class);
return $normalizer->descriptorsForScopeEntries($this->entries);
}
/** /**
* Effective scope payload for OperationRun.context. * Effective scope payload for OperationRun.context.
* *
@ -321,6 +332,7 @@ public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard =
'all_types' => $allTypes, 'all_types' => $allTypes,
'selected_type_keys' => $allTypes, 'selected_type_keys' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [], 'foundations_included' => $expanded->foundationTypes !== [],
'governed_subjects' => $expanded->subjectDescriptors(),
]; ];
if (! is_string($operation) || $operation === '') { if (! is_string($operation) || $operation === '') {

View File

@ -10,6 +10,7 @@ final class CompareSubjectProjection
{ {
/** /**
* @param array<string, string> $additionalLabels * @param array<string, string> $additionalLabels
* @param array<string, mixed>|null $subjectDescriptor
*/ */
public function __construct( public function __construct(
public readonly string $platformSubjectClass, public readonly string $platformSubjectClass,
@ -18,6 +19,7 @@ public function __construct(
public readonly string $operatorLabel, public readonly string $operatorLabel,
public readonly ?string $summaryKind = null, public readonly ?string $summaryKind = null,
public readonly array $additionalLabels = [], public readonly array $additionalLabels = [],
public readonly ?array $subjectDescriptor = null,
) { ) {
if (trim($this->platformSubjectClass) === '' || trim($this->domainKey) === '' || trim($this->subjectTypeKey) === '' || trim($this->operatorLabel) === '') { if (trim($this->platformSubjectClass) === '' || trim($this->domainKey) === '' || trim($this->subjectTypeKey) === '' || trim($this->operatorLabel) === '') {
throw new InvalidArgumentException('Compare subject projections require non-empty platform subject class, domain key, subject type key, and operator label values.'); throw new InvalidArgumentException('Compare subject projections require non-empty platform subject class, domain key, subject type key, and operator label values.');
@ -31,7 +33,8 @@ public function __construct(
* subject_type_key: string, * subject_type_key: string,
* operator_label: string, * operator_label: string,
* summary_kind: ?string, * summary_kind: ?string,
* additional_labels: array<string, string> * additional_labels: array<string, string>,
* subject_descriptor: ?array<string, mixed>
* } * }
*/ */
public function toArray(): array public function toArray(): array
@ -43,6 +46,7 @@ public function toArray(): array
'operator_label' => $this->operatorLabel, 'operator_label' => $this->operatorLabel,
'summary_kind' => $this->summaryKind, 'summary_kind' => $this->summaryKind,
'additional_labels' => $this->additionalLabels, 'additional_labels' => $this->additionalLabels,
'subject_descriptor' => $this->subjectDescriptor,
]; ];
} }
} }

View File

@ -23,6 +23,7 @@
use App\Support\Baselines\SubjectResolver; use App\Support\Baselines\SubjectResolver;
use App\Support\Governance\GovernanceDomainKey; use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass; use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
@ -557,10 +558,30 @@ private function subjectProjection(string $policyType, string $operatorLabel, ?s
summaryKind: $summaryKind, summaryKind: $summaryKind,
additionalLabels: [ additionalLabels: [
'policy_type_label' => InventoryPolicyTypeMeta::baselineCompareLabel($policyType) ?? $policyType, 'policy_type_label' => InventoryPolicyTypeMeta::baselineCompareLabel($policyType) ?? $policyType,
'governed_subject_label' => (string) data_get($this->subjectDescriptor($policyType), 'display_label', $policyType),
], ],
subjectDescriptor: $this->subjectDescriptor($policyType),
); );
} }
/**
* @return array<string, mixed>
*/
private function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_compare');
return $cache[$policyType] = $result->descriptor->toArray();
}
private function domainKeyFor(string $policyType): string private function domainKeyFor(string $policyType): string
{ {
return InventoryPolicyTypeMeta::isFoundation($policyType) return InventoryPolicyTypeMeta::isFoundation($policyType)

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Support;
final readonly class CanonicalOperationType
{
public function __construct(
public string $canonicalCode,
public ?string $domainKey,
public ?string $artifactFamily,
public string $displayLabel,
public bool $supportsOperatorExplanation = false,
public ?int $expectedDurationSeconds = null,
) {}
/**
* @return array{
* canonical_code: string,
* domain_key: ?string,
* artifact_family: ?string,
* display_label: string,
* supports_operator_explanation: bool,
* expected_duration_seconds: ?int
* }
*/
public function toArray(): array
{
return [
'canonical_code' => $this->canonicalCode,
'domain_key' => $this->domainKey,
'artifact_family' => $this->artifactFamily,
'display_label' => $this->displayLabel,
'supports_operator_explanation' => $this->supportsOperatorExplanation,
'expected_duration_seconds' => $this->expectedDurationSeconds,
];
}
}

View File

@ -203,17 +203,7 @@ public static function findingGovernanceAttentionStates(): array
*/ */
public static function operationTypes(?iterable $types = null): array public static function operationTypes(?iterable $types = null): array
{ {
$values = collect($types ?? array_keys(OperationCatalog::labels())) return OperationCatalog::filterOptions($types);
->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->map(fn (string $type): string => trim($type))
->unique()
->sort()
->values();
return $values
->mapWithKeys(fn (string $type): array => [$type => OperationCatalog::label($type)])
->sort()
->all();
} }
/** /**

View File

@ -76,6 +76,50 @@ public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubj
return null; return null;
} }
public function findBySubjectTypeKey(string $subjectTypeKey, ?string $legacyBucket = null): ?GovernanceSubjectType
{
$subjectTypeKey = trim($subjectTypeKey);
$legacyBucket = is_string($legacyBucket) ? trim($legacyBucket) : null;
foreach ($this->all() as $subjectType) {
if ($subjectType->subjectTypeKey !== $subjectTypeKey) {
continue;
}
if ($legacyBucket !== null && $subjectType->legacyBucket !== $legacyBucket) {
continue;
}
return $subjectType;
}
return null;
}
/**
* @return list<string>
*/
public function canonicalNouns(): array
{
return ['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label'];
}
public function ownershipDescriptor(?PlatformVocabularyGlossary $glossary = null): RegistryOwnershipDescriptor
{
$glossary ??= app(PlatformVocabularyGlossary::class);
return $glossary->registry('governance_subject_taxonomy_registry')
?? RegistryOwnershipDescriptor::fromArray([
'registry_key' => 'governance_subject_taxonomy_registry',
'boundary_classification' => PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => PlatformVocabularyGlossary::OWNER_PLATFORM_CORE,
'source_class_or_file' => self::class,
'canonical_nouns' => $this->canonicalNouns(),
'allowed_consumers' => ['baseline_scope', 'compare', 'snapshot', 'review'],
'compatibility_notes' => 'Governed-subject registry lookups remain the canonical bridge from legacy policy-type payloads to platform-safe descriptors.',
]);
}
public function isKnownDomain(string $domainKey): bool public function isKnownDomain(string $domainKey): bool
{ {
return array_key_exists(trim($domainKey), self::DOMAIN_CLASSES); return array_key_exists(trim($domainKey), self::DOMAIN_CLASSES);

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use InvalidArgumentException;
final readonly class PlatformSubjectDescriptor
{
public function __construct(
public string $domainKey,
public string $subjectClass,
public string $subjectTypeKey,
public string $subjectTypeLabel,
public string $platformNoun,
public string $displayLabel,
public ?string $legacyPolicyType = null,
public string $ownerLayer = PlatformVocabularyGlossary::OWNER_PLATFORM_CORE,
) {
foreach ([
'domain key' => $this->domainKey,
'subject class' => $this->subjectClass,
'subject type key' => $this->subjectTypeKey,
'subject type label' => $this->subjectTypeLabel,
'platform noun' => $this->platformNoun,
'display label' => $this->displayLabel,
] as $label => $value) {
if (trim($value) === '') {
throw new InvalidArgumentException(sprintf('Platform subject descriptors require a non-empty %s.', $label));
}
}
}
/**
* @return array{
* domain_key: string,
* subject_class: string,
* subject_type_key: string,
* subject_type_label: string,
* platform_noun: string,
* display_label: string,
* legacy_policy_type: ?string,
* owner_layer: string
* }
*/
public function toArray(): array
{
return [
'domain_key' => $this->domainKey,
'subject_class' => $this->subjectClass,
'subject_type_key' => $this->subjectTypeKey,
'subject_type_label' => $this->subjectTypeLabel,
'platform_noun' => $this->platformNoun,
'display_label' => $this->displayLabel,
'legacy_policy_type' => $this->legacyPolicyType,
'owner_layer' => $this->ownerLayer,
];
}
}

View File

@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use Illuminate\Support\Str;
final class PlatformSubjectDescriptorNormalizer
{
public function __construct(
private readonly GovernanceSubjectTaxonomyRegistry $registry,
private readonly PlatformVocabularyGlossary $glossary,
) {}
/**
* @param array<string, mixed> $payload
*/
public function fromArray(array $payload, string $sourceSurface = 'runtime'): SubjectDescriptorNormalizationResult
{
$usedLegacySource = ! isset($payload['subject_type_key'])
&& (isset($payload['policy_type']) || isset($payload['subject_type']));
$subjectTypeKey = $this->stringValue(
$payload['subject_type_key']
?? $payload['policy_type']
?? $payload['subject_type']
?? null,
);
$result = $this->normalize(
subjectTypeKey: $subjectTypeKey,
domainKey: $this->stringValue($payload['domain_key'] ?? null),
subjectClass: $this->stringValue($payload['subject_class'] ?? null),
legacyPolicyType: $this->stringValue($payload['policy_type'] ?? null),
sourceSurface: $sourceSurface,
);
if (! $usedLegacySource || $result->usedLegacyAlias) {
return $result;
}
return new SubjectDescriptorNormalizationResult(
descriptor: $result->descriptor,
sourceSurface: $result->sourceSurface,
usedLegacyAlias: true,
warnings: array_values(array_unique(array_merge(
['Resolved a compatibility-only policy_type payload through governed-subject normalization.'],
$result->warnings,
))),
);
}
public function fromLegacyBucket(string $legacyBucket, string $subjectTypeKey, string $sourceSurface = 'runtime'): SubjectDescriptorNormalizationResult
{
$subjectType = $this->registry->findBySubjectTypeKey($subjectTypeKey, $legacyBucket);
return $this->buildResult(
subjectType: $subjectType,
sourceSurface: $sourceSurface,
legacyPolicyType: $subjectTypeKey,
usedLegacyAlias: true,
);
}
public function normalize(
?string $subjectTypeKey,
?string $domainKey = null,
?string $subjectClass = null,
?string $legacyPolicyType = null,
string $sourceSurface = 'runtime',
): SubjectDescriptorNormalizationResult {
$subjectType = null;
if (is_string($subjectTypeKey) && trim($subjectTypeKey) !== '') {
$subjectType = $domainKey !== null
? $this->registry->find($domainKey, $subjectTypeKey)
: $this->registry->findBySubjectTypeKey($subjectTypeKey);
}
if ($subjectType === null && is_string($legacyPolicyType) && trim($legacyPolicyType) !== '') {
return $this->fromLegacyBucket('policy_types', $legacyPolicyType, $sourceSurface);
}
return $this->buildResult(
subjectType: $subjectType,
sourceSurface: $sourceSurface,
explicitDomainKey: $domainKey,
explicitSubjectClass: $subjectClass,
legacyPolicyType: $legacyPolicyType,
usedLegacyAlias: false,
);
}
/**
* @param list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}> $entries
* @return list<array<string, mixed>>
*/
public function descriptorsForScopeEntries(array $entries): array
{
$descriptors = [];
foreach ($entries as $entry) {
foreach ($entry['subject_type_keys'] as $subjectTypeKey) {
$result = $this->normalize(
subjectTypeKey: $subjectTypeKey,
domainKey: $entry['domain_key'],
subjectClass: $entry['subject_class'],
legacyPolicyType: $subjectTypeKey,
sourceSurface: 'baseline_scope',
);
$descriptors[] = $result->toArray();
}
}
return $descriptors;
}
private function buildResult(
?GovernanceSubjectType $subjectType,
string $sourceSurface,
?string $explicitDomainKey = null,
?string $explicitSubjectClass = null,
?string $legacyPolicyType = null,
bool $usedLegacyAlias = false,
): SubjectDescriptorNormalizationResult {
$platformNoun = $this->glossary->term('governed_subject')?->canonicalLabel ?? 'Governed subject';
$warnings = [];
if (! $subjectType instanceof GovernanceSubjectType) {
$warnings[] = 'Governed-subject descriptor fell back to compatibility-only naming because the subject type could not be resolved in the taxonomy registry.';
return new SubjectDescriptorNormalizationResult(
descriptor: new PlatformSubjectDescriptor(
domainKey: $explicitDomainKey ?? GovernanceDomainKey::Intune->value,
subjectClass: $explicitSubjectClass ?? GovernanceSubjectClass::Policy->value,
subjectTypeKey: $legacyPolicyType ?? 'unknown_subject',
subjectTypeLabel: Str::headline($legacyPolicyType ?? 'Unknown subject'),
platformNoun: $platformNoun,
displayLabel: Str::headline($legacyPolicyType ?? 'Unknown subject'),
legacyPolicyType: $legacyPolicyType,
),
sourceSurface: $sourceSurface,
usedLegacyAlias: true,
warnings: $warnings,
);
}
if ($usedLegacyAlias) {
$warnings[] = sprintf(
'Resolved legacy subject alias "%s" through the governed-subject taxonomy registry for %s.',
$legacyPolicyType ?? $subjectType->subjectTypeKey,
$sourceSurface,
);
}
return new SubjectDescriptorNormalizationResult(
descriptor: new PlatformSubjectDescriptor(
domainKey: $subjectType->domainKey->value,
subjectClass: $subjectType->subjectClass->value,
subjectTypeKey: $subjectType->subjectTypeKey,
subjectTypeLabel: $subjectType->label,
platformNoun: $platformNoun,
displayLabel: $subjectType->label,
legacyPolicyType: $legacyPolicyType,
),
sourceSurface: $sourceSurface,
usedLegacyAlias: $usedLegacyAlias,
warnings: $warnings,
);
}
private function stringValue(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
}

View File

@ -0,0 +1,570 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationCatalog;
use App\Support\Providers\ProviderReasonCodes;
final class PlatformVocabularyGlossary
{
public const string BOUNDARY_PLATFORM_CORE = 'platform_core';
public const string BOUNDARY_CROSS_DOMAIN_GOVERNANCE = 'cross_domain_governance';
public const string BOUNDARY_INTUNE_SPECIFIC = 'intune_specific';
public const string OWNER_PLATFORM_CORE = 'platform_core';
public const string OWNER_DOMAIN_OWNED = 'domain_owned';
public const string OWNER_PROVIDER_OWNED = 'provider_owned';
public const string OWNER_COMPATIBILITY_ALIAS = 'compatibility_alias';
public const string OWNER_COMPATIBILITY_ONLY = 'compatibility_only';
/**
* @return list<PlatformVocabularyTerm>
*/
public function terms(): array
{
return array_values(array_map(
static fn (array $term): PlatformVocabularyTerm => PlatformVocabularyTerm::fromArray($term),
$this->configuredTerms(),
));
}
public function term(string $term): ?PlatformVocabularyTerm
{
$normalized = trim(mb_strtolower($term));
foreach ($this->terms() as $candidate) {
if (trim(mb_strtolower($candidate->termKey)) === $normalized) {
return $candidate;
}
}
return $this->resolveAlias($term);
}
public function resolveAlias(string $term, ?string $context = null): ?PlatformVocabularyTerm
{
$normalized = trim(mb_strtolower($term));
$normalizedContext = is_string($context) ? trim(mb_strtolower($context)) : null;
foreach ($this->terms() as $candidate) {
$aliases = array_map(
static fn (string $alias): string => trim(mb_strtolower($alias)),
$candidate->legacyAliases,
);
if (! in_array($normalized, $aliases, true)) {
continue;
}
if ($normalizedContext !== null && $candidate->allowedContexts !== []) {
$contexts = array_map(
static fn (string $allowedContext): string => trim(mb_strtolower($allowedContext)),
$candidate->allowedContexts,
);
if (! in_array($normalizedContext, $contexts, true)) {
continue;
}
}
return $candidate;
}
return null;
}
public function canonicalName(string $term): ?string
{
return $this->term($term)?->termKey;
}
public function isCanonical(string $term): bool
{
$resolved = $this->term($term);
if (! $resolved instanceof PlatformVocabularyTerm) {
return false;
}
return trim(mb_strtolower($term)) === trim(mb_strtolower($resolved->termKey));
}
public function ownership(string $term): ?string
{
return $this->term($term)?->ownerLayer;
}
/**
* @return list<string>
*/
public function canonicalTerms(): array
{
return array_values(array_map(
static fn (PlatformVocabularyTerm $term): string => $term->termKey,
$this->terms(),
));
}
/**
* @return array<string, array{
* term_key: string,
* canonical_label: string,
* canonical_description: string,
* boundary_classification: string,
* owner_layer: string,
* allowed_contexts: list<string>,
* legacy_aliases: list<string>,
* alias_retirement_path: ?string,
* forbidden_platform_aliases: list<string>
* }>
*/
public function termInventory(): array
{
return collect($this->terms())
->mapWithKeys(static fn (PlatformVocabularyTerm $term): array => [
$term->termKey => $term->toArray(),
])
->all();
}
/**
* @return array<string, list<string>>
*/
public function legacyAliases(): array
{
return collect($this->terms())
->filter(static fn (PlatformVocabularyTerm $term): bool => $term->legacyAliases !== [])
->mapWithKeys(static fn (PlatformVocabularyTerm $term): array => [
$term->termKey => $term->legacyAliases,
])
->all();
}
/**
* @return array<string, array{
* canonical_name: string,
* legacy_aliases: list<string>,
* retirement_path: ?string,
* forbidden_platform_aliases: list<string>
* }>
*/
public function aliasRetirementInventory(): array
{
return collect($this->terms())
->filter(static fn (PlatformVocabularyTerm $term): bool => $term->legacyAliases !== [])
->mapWithKeys(static fn (PlatformVocabularyTerm $term): array => [
$term->termKey => [
'canonical_name' => $term->termKey,
'legacy_aliases' => $term->legacyAliases,
'retirement_path' => $term->aliasRetirementPath,
'forbidden_platform_aliases' => $term->forbiddenPlatformAliases,
],
])
->all();
}
public function boundaryClassification(string $term): ?string
{
return $this->term($term)?->boundaryClassification;
}
/**
* @return list<string>
*/
public function allowedBoundaryClassifications(): array
{
return [
self::BOUNDARY_PLATFORM_CORE,
self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
self::BOUNDARY_INTUNE_SPECIFIC,
];
}
/**
* @return list<RegistryOwnershipDescriptor>
*/
public function registries(): array
{
return array_values(array_map(
static fn (array $descriptor): RegistryOwnershipDescriptor => RegistryOwnershipDescriptor::fromArray($descriptor),
$this->configuredRegistries(),
));
}
public function registry(string $registryKey): ?RegistryOwnershipDescriptor
{
$normalized = trim(mb_strtolower($registryKey));
foreach ($this->registries() as $descriptor) {
if (trim(mb_strtolower($descriptor->registryKey)) === $normalized) {
return $descriptor;
}
}
return null;
}
/**
* @return array<string, array{
* registry_key: string,
* boundary_classification: string,
* owner_layer: string,
* source_class_or_file: string,
* canonical_nouns: list<string>,
* allowed_consumers: list<string>,
* compatibility_notes: ?string
* }>
*/
public function registryInventory(): array
{
return collect($this->registries())
->mapWithKeys(static fn (RegistryOwnershipDescriptor $descriptor): array => [
$descriptor->registryKey => $descriptor->toArray(),
])
->all();
}
/**
* @return array<string, array{
* owner_namespace: string,
* boundary_classification: string,
* owner_layer: string,
* compatibility_notes: ?string
* }>
*/
public function reasonNamespaceInventory(): array
{
return $this->configuredReasonNamespaces();
}
/**
* @return array{
* owner_namespace: string,
* boundary_classification: string,
* owner_layer: string,
* compatibility_notes: ?string
* }|null
*/
public function reasonNamespace(string $ownerNamespace): ?array
{
$normalized = trim(mb_strtolower($ownerNamespace));
foreach ($this->reasonNamespaceInventory() as $descriptor) {
$candidate = is_string($descriptor['owner_namespace'] ?? null)
? trim(mb_strtolower((string) $descriptor['owner_namespace']))
: null;
if ($candidate === $normalized) {
return $descriptor;
}
}
return null;
}
public function classifyReasonNamespace(string $ownerNamespace): ?string
{
return $this->reasonNamespace($ownerNamespace)['boundary_classification'] ?? null;
}
public function classifyOperationType(string $operationType): ?string
{
if (trim($operationType) === '') {
return null;
}
return $this->registry('operation_catalog')?->boundaryClassification;
}
/**
* @return array<string, array<string, mixed>>
*/
private function configuredTerms(): array
{
$configured = config('tenantpilot.platform_vocabulary.terms');
if (is_array($configured) && $configured !== []) {
return $configured;
}
return [
'governed_subject' => [
'term_key' => 'governed_subject',
'canonical_label' => 'Governed subject',
'canonical_description' => 'The platform-facing noun for a governed object across compare, snapshot, evidence, and review surfaces.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['compare', 'snapshot', 'evidence', 'review', 'reporting'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Retire false-universal policy_type wording from platform-owned summaries and descriptors while preserving Intune-owned storage.',
'forbidden_platform_aliases' => ['policy_type'],
],
'domain_key' => [
'term_key' => 'domain_key',
'canonical_label' => 'Governance domain',
'canonical_description' => 'The canonical domain discriminator for cross-domain and platform-near contracts.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_class' => [
'term_key' => 'subject_class',
'canonical_label' => 'Subject class',
'canonical_description' => 'The canonical subject-class discriminator for governed-subject contracts.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'compare', 'snapshot'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_type_key' => [
'term_key' => 'subject_type_key',
'canonical_label' => 'Governed subject key',
'canonical_description' => 'The domain-owned subject-family key used by platform-near descriptors.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'evidence'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Prefer subject_type_key on platform-near payloads and keep policy_type only where the owning model is Intune-specific.',
'forbidden_platform_aliases' => ['policy_type'],
],
'subject_type_label' => [
'term_key' => 'subject_type_label',
'canonical_label' => 'Governed subject label',
'canonical_description' => 'The operator-facing label for a governed subject family.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['snapshot', 'evidence', 'compare', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'resource_type' => [
'term_key' => 'resource_type',
'canonical_label' => 'Resource type',
'canonical_description' => 'Optional resource-shaped noun for platform-facing summaries when a governed subject also needs a resource family label.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reporting', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'operation_type' => [
'term_key' => 'operation_type',
'canonical_label' => 'Operation type',
'canonical_description' => 'The canonical platform operation identifier shown on monitoring and launch surfaces.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['monitoring', 'reporting', 'launch_surfaces'],
'legacy_aliases' => ['type'],
'alias_retirement_path' => 'Expose canonical operation_type on read models while operation_runs.type remains a compatibility storage seam during rollout.',
'forbidden_platform_aliases' => [],
],
'platform_reason_family' => [
'term_key' => 'platform_reason_family',
'canonical_label' => 'Platform reason family',
'canonical_description' => 'The cross-domain platform family that explains the top-level cause category without erasing domain ownership.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reason_translation', 'monitoring', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_owner.owner_namespace' => [
'term_key' => 'reason_owner.owner_namespace',
'canonical_label' => 'Reason owner namespace',
'canonical_description' => 'The explicit namespace that marks whether a translated reason is provider-owned, governance-owned, access-owned, or runtime-owned.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reason_translation', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_code' => [
'term_key' => 'reason_code',
'canonical_label' => 'Reason code',
'canonical_description' => 'The original domain-owned or provider-owned reason identifier preserved for diagnostics and translation lookup.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reason_translation', 'diagnostics'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'registry_key' => [
'term_key' => 'registry_key',
'canonical_label' => 'Registry key',
'canonical_description' => 'The stable identifier for a contributor-facing registry or catalog.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'boundary_classification' => [
'term_key' => 'boundary_classification',
'canonical_label' => 'Boundary classification',
'canonical_description' => 'The explicit classification that marks a term or registry as platform_core, cross_domain_governance, or intune_specific.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'policy_type' => [
'term_key' => 'policy_type',
'canonical_label' => 'Intune policy type',
'canonical_description' => 'The Intune-specific discriminator that remains valid on adapter-owned or Intune-owned models but must not be treated as a universal platform noun.',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'allowed_contexts' => ['intune_adapter', 'intune_inventory', 'intune_backup'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => ['governed_subject'],
],
];
}
/**
* @return array<string, array<string, mixed>>
*/
private function configuredRegistries(): array
{
$configured = config('tenantpilot.platform_vocabulary.registries');
if (is_array($configured) && $configured !== []) {
return $configured;
}
return [
'governance_subject_taxonomy_registry' => [
'registry_key' => 'governance_subject_taxonomy_registry',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'source_class_or_file' => GovernanceSubjectTaxonomyRegistry::class,
'canonical_nouns' => ['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label'],
'allowed_consumers' => ['baseline_scope', 'compare', 'snapshot', 'review'],
'compatibility_notes' => 'Provides the governed-subject source of truth, resolves legacy policy-type payloads, and preserves inactive or future-domain entries without exposing them as active operator choices.',
],
'operation_catalog' => [
'registry_key' => 'operation_catalog',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'source_class_or_file' => OperationCatalog::class,
'canonical_nouns' => ['operation_type'],
'allowed_consumers' => ['monitoring', 'reporting', 'launch_surfaces', 'audit'],
'compatibility_notes' => 'Resolves canonical operation meaning from historical storage values without treating every stored raw string as equally canonical.',
],
'provider_reason_codes' => [
'registry_key' => 'provider_reason_codes',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_PROVIDER_OWNED,
'source_class_or_file' => ProviderReasonCodes::class,
'canonical_nouns' => ['reason_code'],
'allowed_consumers' => ['reason_translation'],
'compatibility_notes' => 'Provider-owned reason codes remain namespaced domain details and become platform-safe only through the reason-translation boundary.',
],
'inventory_policy_type_catalog' => [
'registry_key' => 'inventory_policy_type_catalog',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'source_class_or_file' => InventoryPolicyTypeMeta::class,
'canonical_nouns' => ['policy_type'],
'allowed_consumers' => ['intune_adapter', 'inventory', 'backup'],
'compatibility_notes' => 'The supported Intune policy-type list is not a universal platform registry and must be wrapped before reuse on platform-near summaries.',
],
];
}
/**
* @return array<string, array{
* owner_namespace: string,
* boundary_classification: string,
* owner_layer: string,
* compatibility_notes: ?string
* }>
*/
private function configuredReasonNamespaces(): array
{
$configured = config('tenantpilot.platform_vocabulary.reason_namespaces');
if (is_array($configured) && $configured !== []) {
return $configured;
}
return [
'tenant_operability' => [
'owner_namespace' => 'tenant_operability',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Tenant operability reasons are platform-core guardrails for workspace and tenant context.',
],
'execution_denial' => [
'owner_namespace' => 'execution_denial',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Execution denial reasons remain platform-core run-legitimacy semantics.',
],
'operation_lifecycle' => [
'owner_namespace' => 'operation_lifecycle',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Lifecycle reconciliation reasons remain platform-core monitoring semantics.',
],
'governance.baseline_compare' => [
'owner_namespace' => 'governance.baseline_compare',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'compatibility_notes' => 'Baseline-compare reason codes are governance-owned details translated through the platform boundary.',
],
'governance.artifact_truth' => [
'owner_namespace' => 'governance.artifact_truth',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'compatibility_notes' => 'Artifact-truth reason codes remain governance-owned and are surfaced through platform-safe summaries.',
],
'provider.microsoft_graph' => [
'owner_namespace' => 'provider.microsoft_graph',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_PROVIDER_OWNED,
'compatibility_notes' => 'Microsoft Graph provider reasons remain provider-owned and Intune-specific.',
],
'provider.intune_rbac' => [
'owner_namespace' => 'provider.intune_rbac',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_PROVIDER_OWNED,
'compatibility_notes' => 'Provider-owned Intune RBAC reasons remain Intune-specific.',
],
'rbac.intune' => [
'owner_namespace' => 'rbac.intune',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'compatibility_notes' => 'RBAC detail remains Intune-specific domain context.',
],
'reason_translation.fallback' => [
'owner_namespace' => 'reason_translation.fallback',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Fallback translation remains a platform-core compatibility seam until a domain owner is known.',
],
];
}
}

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use InvalidArgumentException;
final readonly class PlatformVocabularyTerm
{
/**
* @param list<string> $allowedContexts
* @param list<string> $legacyAliases
* @param list<string> $forbiddenPlatformAliases
*/
public function __construct(
public string $termKey,
public string $canonicalLabel,
public string $canonicalDescription,
public string $boundaryClassification,
public string $ownerLayer,
public array $allowedContexts = [],
public array $legacyAliases = [],
public ?string $aliasRetirementPath = null,
public array $forbiddenPlatformAliases = [],
) {
if (trim($this->termKey) === '' || trim($this->canonicalLabel) === '' || trim($this->canonicalDescription) === '') {
throw new InvalidArgumentException('Platform vocabulary terms require a key, label, and description.');
}
if ($this->legacyAliases !== [] && blank($this->aliasRetirementPath)) {
throw new InvalidArgumentException('Platform vocabulary terms with legacy aliases must declare an alias retirement path.');
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
termKey: (string) ($data['term_key'] ?? ''),
canonicalLabel: (string) ($data['canonical_label'] ?? ''),
canonicalDescription: (string) ($data['canonical_description'] ?? ''),
boundaryClassification: (string) ($data['boundary_classification'] ?? ''),
ownerLayer: (string) ($data['owner_layer'] ?? ''),
allowedContexts: self::stringList($data['allowed_contexts'] ?? []),
legacyAliases: self::stringList($data['legacy_aliases'] ?? []),
aliasRetirementPath: is_string($data['alias_retirement_path'] ?? null)
? trim((string) $data['alias_retirement_path'])
: null,
forbiddenPlatformAliases: self::stringList($data['forbidden_platform_aliases'] ?? []),
);
}
public function matches(string $term): bool
{
$normalized = trim(mb_strtolower($term));
if ($normalized === '') {
return false;
}
return in_array($normalized, array_map(
static fn (string $candidate): string => trim(mb_strtolower($candidate)),
array_merge([$this->termKey], $this->legacyAliases),
), true);
}
/**
* @return array{
* term_key: string,
* canonical_label: string,
* canonical_description: string,
* boundary_classification: string,
* owner_layer: string,
* allowed_contexts: list<string>,
* legacy_aliases: list<string>,
* alias_retirement_path: ?string,
* forbidden_platform_aliases: list<string>
* }
*/
public function toArray(): array
{
return [
'term_key' => $this->termKey,
'canonical_label' => $this->canonicalLabel,
'canonical_description' => $this->canonicalDescription,
'boundary_classification' => $this->boundaryClassification,
'owner_layer' => $this->ownerLayer,
'allowed_contexts' => $this->allowedContexts,
'legacy_aliases' => $this->legacyAliases,
'alias_retirement_path' => $this->aliasRetirementPath,
'forbidden_platform_aliases' => $this->forbiddenPlatformAliases,
];
}
/**
* @param iterable<mixed> $values
* @return list<string>
*/
private static function stringList(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): string => trim($value))
->values()
->all();
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use InvalidArgumentException;
final readonly class RegistryOwnershipDescriptor
{
/**
* @param list<string> $canonicalNouns
* @param list<string> $allowedConsumers
*/
public function __construct(
public string $registryKey,
public string $boundaryClassification,
public string $ownerLayer,
public string $sourceClassOrFile,
public array $canonicalNouns,
public array $allowedConsumers,
public ?string $compatibilityNotes = null,
) {
if (trim($this->registryKey) === '' || trim($this->sourceClassOrFile) === '') {
throw new InvalidArgumentException('Registry ownership descriptors require a registry key and source reference.');
}
if ($this->canonicalNouns === [] || $this->allowedConsumers === []) {
throw new InvalidArgumentException('Registry ownership descriptors require canonical nouns and allowed consumers.');
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
registryKey: (string) ($data['registry_key'] ?? ''),
boundaryClassification: (string) ($data['boundary_classification'] ?? ''),
ownerLayer: (string) ($data['owner_layer'] ?? ''),
sourceClassOrFile: (string) ($data['source_class_or_file'] ?? ''),
canonicalNouns: self::stringList($data['canonical_nouns'] ?? []),
allowedConsumers: self::stringList($data['allowed_consumers'] ?? []),
compatibilityNotes: is_string($data['compatibility_notes'] ?? null)
? trim((string) $data['compatibility_notes'])
: null,
);
}
/**
* @return array{
* registry_key: string,
* boundary_classification: string,
* owner_layer: string,
* source_class_or_file: string,
* canonical_nouns: list<string>,
* allowed_consumers: list<string>,
* compatibility_notes: ?string
* }
*/
public function toArray(): array
{
return [
'registry_key' => $this->registryKey,
'boundary_classification' => $this->boundaryClassification,
'owner_layer' => $this->ownerLayer,
'source_class_or_file' => $this->sourceClassOrFile,
'canonical_nouns' => $this->canonicalNouns,
'allowed_consumers' => $this->allowedConsumers,
'compatibility_notes' => $this->compatibilityNotes,
];
}
/**
* @param iterable<mixed> $values
* @return list<string>
*/
private static function stringList(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): string => trim($value))
->values()
->all();
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
final readonly class SubjectDescriptorNormalizationResult
{
/**
* @param list<string> $warnings
*/
public function __construct(
public PlatformSubjectDescriptor $descriptor,
public string $sourceSurface,
public bool $usedLegacyAlias = false,
public array $warnings = [],
) {}
/**
* @return array{
* descriptor: array{
* domain_key: string,
* subject_class: string,
* subject_type_key: string,
* subject_type_label: string,
* platform_noun: string,
* display_label: string,
* legacy_policy_type: ?string,
* owner_layer: string
* },
* source_surface: string,
* used_legacy_alias: bool,
* warnings: list<string>
* }
*/
public function toArray(): array
{
return [
'descriptor' => $this->descriptor->toArray(),
'source_surface' => $this->sourceSurface,
'used_legacy_alias' => $this->usedLegacyAlias,
'warnings' => $this->warnings,
];
}
}

View File

@ -1,7 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace App\Support; namespace App\Support;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Governance\RegistryOwnershipDescriptor;
use App\Support\OpsUx\OperationSummaryKeys; use App\Support\OpsUx\OperationSummaryKeys;
final class OperationCatalog final class OperationCatalog
@ -13,51 +17,34 @@ final class OperationCatalog
*/ */
public static function labels(): array public static function labels(): array
{ {
return [ $labels = [];
'policy.sync' => 'Policy sync',
'policy.sync_one' => 'Policy sync', foreach (self::operationAliases() as $alias) {
'policy.capture_snapshot' => 'Policy snapshot', $labels[$alias->rawValue] = self::canonicalDefinitions()[$alias->canonicalCode]->displayLabel;
'policy.delete' => 'Delete policies', }
'policy.unignore' => 'Restore policies',
'policy.export' => 'Export policies to backup', return $labels;
'provider.connection.check' => 'Provider connection check', }
'inventory_sync' => 'Inventory sync',
'compliance.snapshot' => 'Compliance snapshot', /**
'provider.inventory.sync' => 'Inventory sync', * @return array<string, array{
'provider.compliance.snapshot' => 'Compliance snapshot', * canonical_code: string,
'entra_group_sync' => 'Directory groups sync', * domain_key: ?string,
'backup_set.add_policies' => 'Backup set update', * artifact_family: ?string,
'backup_set.remove_policies' => 'Backup set update', * display_label: string,
'backup_set.delete' => 'Archive backup sets', * supports_operator_explanation: bool,
'backup_set.restore' => 'Restore backup sets', * expected_duration_seconds: ?int
'backup_set.force_delete' => 'Delete backup sets', * }>
'backup_schedule_run' => 'Backup schedule run', */
'backup_schedule_retention' => 'Backup schedule retention', public static function canonicalInventory(): array
'backup_schedule_purge' => 'Backup schedule purge', {
'restore.execute' => 'Restore execution', $inventory = [];
'assignments.fetch' => 'Assignment fetch',
'assignments.restore' => 'Assignment restore', foreach (self::canonicalDefinitions() as $canonicalCode => $definition) {
'ops.reconcile_adapter_runs' => 'Reconcile adapter runs', $inventory[$canonicalCode] = $definition->toArray();
'directory_role_definitions.sync' => 'Role definitions sync', }
'restore_run.delete' => 'Delete restore runs',
'restore_run.restore' => 'Restore restore runs', return $inventory;
'restore_run.force_delete' => 'Force delete restore runs',
'tenant.sync' => 'Tenant sync',
'policy_version.prune' => 'Prune policy versions',
'policy_version.restore' => 'Restore policy versions',
'policy_version.force_delete' => 'Delete policy versions',
'alerts.evaluate' => 'Alerts evaluation',
'alerts.deliver' => 'Alerts delivery',
'baseline_capture' => 'Baseline capture',
'baseline_compare' => 'Baseline compare',
'permission_posture_check' => 'Permission posture check',
'entra.admin_roles.scan' => 'Entra admin roles scan',
'tenant.review_pack.generate' => 'Review pack generation',
'tenant.review.compose' => 'Review composition',
'tenant.evidence.snapshot.generate' => 'Evidence snapshot generation',
'rbac.health_check' => 'RBAC health check',
'findings.lifecycle.backfill' => 'Findings lifecycle backfill',
];
} }
public static function label(string $operationType): string public static function label(string $operationType): string
@ -68,34 +55,12 @@ public static function label(string $operationType): string
return 'Operation'; return 'Operation';
} }
return self::labels()[$operationType] ?? 'Unknown operation'; return self::resolve($operationType)->canonical->displayLabel;
} }
public static function expectedDurationSeconds(string $operationType): ?int public static function expectedDurationSeconds(string $operationType): ?int
{ {
return match (trim($operationType)) { return self::resolve($operationType)->canonical->expectedDurationSeconds;
'policy.sync', 'policy.sync_one' => 90,
'provider.connection.check' => 30,
'policy.export' => 120,
'inventory_sync' => 180,
'compliance.snapshot' => 180,
'provider.inventory.sync' => 180,
'provider.compliance.snapshot' => 180,
'entra_group_sync' => 120,
'assignments.fetch', 'assignments.restore' => 60,
'ops.reconcile_adapter_runs' => 120,
'alerts.evaluate', 'alerts.deliver' => 120,
'baseline_capture' => 120,
'baseline_compare' => 120,
'permission_posture_check' => 30,
'entra.admin_roles.scan' => 60,
'tenant.review_pack.generate' => 60,
'tenant.review.compose' => 60,
'tenant.evidence.snapshot.generate' => 120,
'rbac.health_check' => 30,
'findings.lifecycle.backfill' => 300,
default => null,
};
} }
/** /**
@ -108,13 +73,7 @@ public static function allowedSummaryKeys(): array
public static function governanceArtifactFamily(string $operationType): ?string public static function governanceArtifactFamily(string $operationType): ?string
{ {
return match (trim($operationType)) { return self::resolve($operationType)->canonical->artifactFamily;
'baseline_capture' => 'baseline_snapshot',
'tenant.evidence.snapshot.generate' => 'evidence_snapshot',
'tenant.review.compose' => 'tenant_review',
'tenant.review_pack.generate' => 'review_pack',
default => null,
};
} }
public static function isGovernanceArtifactOperation(string $operationType): bool public static function isGovernanceArtifactOperation(string $operationType): bool
@ -124,9 +83,227 @@ public static function isGovernanceArtifactOperation(string $operationType): boo
public static function supportsOperatorExplanation(string $operationType): bool public static function supportsOperatorExplanation(string $operationType): bool
{ {
$operationType = trim($operationType); return self::resolve($operationType)->canonical->supportsOperatorExplanation;
}
return self::isGovernanceArtifactOperation($operationType) public static function canonicalCode(string $operationType): string
|| $operationType === 'baseline_compare'; {
return self::resolve($operationType)->canonical->canonicalCode;
}
/**
* @return list<string>
*/
public static function canonicalNouns(): array
{
return ['operation_type'];
}
public static function ownershipDescriptor(?PlatformVocabularyGlossary $glossary = null): RegistryOwnershipDescriptor
{
$glossary ??= app(PlatformVocabularyGlossary::class);
return $glossary->registry('operation_catalog')
?? RegistryOwnershipDescriptor::fromArray([
'registry_key' => 'operation_catalog',
'boundary_classification' => PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE,
'owner_layer' => PlatformVocabularyGlossary::OWNER_PLATFORM_CORE,
'source_class_or_file' => self::class,
'canonical_nouns' => self::canonicalNouns(),
'allowed_consumers' => ['monitoring', 'reporting', 'launch_surfaces', 'audit'],
'compatibility_notes' => 'Resolves canonical operation meaning from historical storage values without treating every stored raw string as equally canonical.',
]);
}
public static function boundaryClassification(?PlatformVocabularyGlossary $glossary = null): string
{
return self::ownershipDescriptor($glossary)->boundaryClassification;
}
/**
* @return list<string>
*/
public static function rawValuesForCanonical(string $canonicalCode): array
{
return array_values(array_map(
static fn (OperationTypeAlias $alias): string => $alias->rawValue,
array_filter(
self::operationAliases(),
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === trim($canonicalCode),
),
));
}
/**
* @param iterable<mixed>|null $types
* @return array<string, string>
*/
public static function filterOptions(?iterable $types = null): array
{
$values = collect($types ?? array_keys(self::labels()))
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->map(static fn (string $type): string => trim($type))
->mapWithKeys(static fn (string $type): array => [self::canonicalCode($type) => self::label($type)])
->sortBy(static fn (string $label): string => $label)
->all();
return $values;
}
/**
* @return array<string, array{
* raw_value: string,
* canonical_name: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }>
*/
public static function aliasInventory(): array
{
$inventory = [];
foreach (self::operationAliases() as $alias) {
$inventory[$alias->rawValue] = $alias->retirementMetadata();
}
return $inventory;
}
public static function resolve(string $operationType): OperationTypeResolution
{
$operationType = trim($operationType);
$aliases = self::operationAliases();
$matchedAlias = collect($aliases)
->first(static fn (OperationTypeAlias $alias): bool => $alias->rawValue === $operationType);
if ($matchedAlias instanceof OperationTypeAlias) {
return new OperationTypeResolution(
rawValue: $operationType,
canonical: self::canonicalDefinitions()[$matchedAlias->canonicalCode],
aliasesConsidered: array_values(array_filter(
$aliases,
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === $matchedAlias->canonicalCode,
)),
aliasStatus: $matchedAlias->aliasStatus,
wasLegacyAlias: $matchedAlias->aliasStatus !== 'canonical',
);
}
return new OperationTypeResolution(
rawValue: $operationType,
canonical: new CanonicalOperationType(
canonicalCode: $operationType,
domainKey: null,
artifactFamily: null,
displayLabel: 'Unknown operation',
supportsOperatorExplanation: false,
expectedDurationSeconds: null,
),
aliasesConsidered: [],
aliasStatus: 'unknown',
wasLegacyAlias: false,
);
}
/**
* @return array<string, CanonicalOperationType>
*/
private static function canonicalDefinitions(): array
{
return [
'policy.sync' => new CanonicalOperationType('policy.sync', 'intune', null, 'Policy sync', false, 90),
'policy.snapshot' => new CanonicalOperationType('policy.snapshot', 'intune', null, 'Policy snapshot', false, 120),
'policy.delete' => new CanonicalOperationType('policy.delete', 'intune', null, 'Delete policies'),
'policy.restore' => new CanonicalOperationType('policy.restore', 'intune', null, 'Restore policies'),
'policy.export' => new CanonicalOperationType('policy.export', 'intune', null, 'Export policies to backup', false, 120),
'provider.connection.check' => new CanonicalOperationType('provider.connection.check', 'intune', null, 'Provider connection check', false, 30),
'inventory.sync' => new CanonicalOperationType('inventory.sync', 'intune', null, 'Inventory sync', false, 180),
'compliance.snapshot' => new CanonicalOperationType('compliance.snapshot', 'intune', null, 'Compliance snapshot', false, 180),
'directory.groups.sync' => new CanonicalOperationType('directory.groups.sync', 'entra', null, 'Directory groups sync', false, 120),
'backup_set.update' => new CanonicalOperationType('backup_set.update', 'intune', null, 'Backup set update'),
'backup_set.archive' => new CanonicalOperationType('backup_set.archive', 'intune', null, 'Archive backup sets'),
'backup_set.restore' => new CanonicalOperationType('backup_set.restore', 'intune', null, 'Restore backup sets'),
'backup_set.delete' => new CanonicalOperationType('backup_set.delete', 'intune', null, 'Delete backup sets'),
'backup.schedule.execute' => new CanonicalOperationType('backup.schedule.execute', 'intune', null, 'Backup schedule run'),
'backup.schedule.retention' => new CanonicalOperationType('backup.schedule.retention', 'intune', null, 'Backup schedule retention'),
'backup.schedule.purge' => new CanonicalOperationType('backup.schedule.purge', 'intune', null, 'Backup schedule purge'),
'restore.execute' => new CanonicalOperationType('restore.execute', 'intune', null, 'Restore execution'),
'assignments.fetch' => new CanonicalOperationType('assignments.fetch', 'intune', null, 'Assignment fetch', false, 60),
'assignments.restore' => new CanonicalOperationType('assignments.restore', 'intune', null, 'Assignment restore', false, 60),
'ops.reconcile_adapter_runs' => new CanonicalOperationType('ops.reconcile_adapter_runs', 'platform_foundation', null, 'Reconcile adapter runs', false, 120),
'directory.role_definitions.sync' => new CanonicalOperationType('directory.role_definitions.sync', 'entra', null, 'Role definitions sync'),
'restore_run.delete' => new CanonicalOperationType('restore_run.delete', 'intune', null, 'Delete restore runs'),
'restore_run.restore' => new CanonicalOperationType('restore_run.restore', 'intune', null, 'Restore restore runs'),
'restore_run.force_delete' => new CanonicalOperationType('restore_run.force_delete', 'intune', null, 'Force delete restore runs'),
'tenant.sync' => new CanonicalOperationType('tenant.sync', 'platform_foundation', null, 'Tenant sync'),
'policy_version.prune' => new CanonicalOperationType('policy_version.prune', 'intune', null, 'Prune policy versions'),
'policy_version.restore' => new CanonicalOperationType('policy_version.restore', 'intune', null, 'Restore policy versions'),
'policy_version.force_delete' => new CanonicalOperationType('policy_version.force_delete', 'intune', null, 'Delete policy versions'),
'alerts.evaluate' => new CanonicalOperationType('alerts.evaluate', 'platform_foundation', null, 'Alerts evaluation', false, 120),
'alerts.deliver' => new CanonicalOperationType('alerts.deliver', 'platform_foundation', null, 'Alerts delivery', false, 120),
'baseline.capture' => new CanonicalOperationType('baseline.capture', 'platform_foundation', 'baseline_snapshot', 'Baseline capture', true, 120),
'baseline.compare' => new CanonicalOperationType('baseline.compare', 'platform_foundation', null, 'Baseline compare', true, 120),
'permission.posture.check' => new CanonicalOperationType('permission.posture.check', 'platform_foundation', null, 'Permission posture check', false, 30),
'entra.admin_roles.scan' => new CanonicalOperationType('entra.admin_roles.scan', 'entra', null, 'Entra admin roles scan', false, 60),
'tenant.review_pack.generate' => new CanonicalOperationType('tenant.review_pack.generate', 'platform_foundation', 'review_pack', 'Review pack generation', true, 60),
'tenant.review.compose' => new CanonicalOperationType('tenant.review.compose', 'platform_foundation', 'tenant_review', 'Review composition', true, 60),
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
'findings.lifecycle.backfill' => new CanonicalOperationType('findings.lifecycle.backfill', 'platform_foundation', null, 'Findings lifecycle backfill', false, 300),
];
}
/**
* @return list<OperationTypeAlias>
*/
private static function operationAliases(): array
{
return [
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true),
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', true, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true),
new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true),
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', true, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true),
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true),
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', true, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'),
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', true, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
new OperationTypeAlias('backup_set.add_policies', 'backup_set.update', 'canonical', true),
new OperationTypeAlias('backup_set.remove_policies', 'backup_set.update', 'legacy_alias', true, 'Removal and addition both resolve to the same backup-set update operator meaning.', 'Use backup_set.update for canonical reporting buckets.'),
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', true, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', true, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', true, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', true, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', true, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true),
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
new OperationTypeAlias('restore_run.force_delete', 'restore_run.force_delete', 'canonical', true),
new OperationTypeAlias('tenant.sync', 'tenant.sync', 'canonical', true),
new OperationTypeAlias('policy_version.prune', 'policy_version.prune', 'canonical', true),
new OperationTypeAlias('policy_version.restore', 'policy_version.restore', 'canonical', true),
new OperationTypeAlias('policy_version.force_delete', 'policy_version.force_delete', 'canonical', true),
new OperationTypeAlias('alerts.evaluate', 'alerts.evaluate', 'canonical', true),
new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true),
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', true, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', true, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', true, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true),
new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true),
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
new OperationTypeAlias('findings.lifecycle.backfill', 'findings.lifecycle.backfill', 'canonical', true),
];
} }
} }

View File

@ -27,4 +27,26 @@ public static function values(): array
{ {
return array_map(static fn (self $case): string => $case->value, self::cases()); return array_map(static fn (self $case): string => $case->value, self::cases());
} }
public function canonicalCode(): string
{
return match ($this) {
self::BaselineCapture => 'baseline.capture',
self::BaselineCompare => 'baseline.compare',
self::InventorySync => 'inventory.sync',
self::PolicySync, self::PolicySyncOne => 'policy.sync',
self::DirectoryGroupsSync => 'directory.groups.sync',
self::BackupScheduleExecute => 'backup.schedule.execute',
self::BackupScheduleRetention => 'backup.schedule.retention',
self::BackupSchedulePurge => 'backup.schedule.purge',
self::DirectoryRoleDefinitionsSync => 'directory.role_definitions.sync',
self::RestoreExecute => 'restore.execute',
self::EntraAdminRolesScan => 'entra.admin_roles.scan',
self::ReviewPackGenerate => 'tenant.review_pack.generate',
self::TenantReviewCompose => 'tenant.review.compose',
self::EvidenceSnapshotGenerate => 'tenant.evidence.snapshot.generate',
self::RbacHealthCheck => 'rbac.health_check',
default => $this->value,
};
}
} }

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Support;
use InvalidArgumentException;
final readonly class OperationTypeAlias
{
public function __construct(
public string $rawValue,
public string $canonicalCode,
public string $aliasStatus,
public bool $writeAllowed,
public ?string $deprecationNote = null,
public ?string $retirementPath = null,
) {
if (trim($this->rawValue) === '' || trim($this->canonicalCode) === '') {
throw new InvalidArgumentException('Operation type aliases require a raw value and canonical code.');
}
}
/**
* @return array{
* raw_value: string,
* canonical_code: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }
*/
public function toArray(): array
{
return [
'raw_value' => $this->rawValue,
'canonical_code' => $this->canonicalCode,
'alias_status' => $this->aliasStatus,
'write_allowed' => $this->writeAllowed,
'deprecation_note' => $this->deprecationNote,
'retirement_path' => $this->retirementPath,
];
}
public function canonicalName(): string
{
return $this->canonicalCode;
}
/**
* @return array{
* raw_value: string,
* canonical_name: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }
*/
public function retirementMetadata(): array
{
return [
'raw_value' => $this->rawValue,
'canonical_name' => $this->canonicalName(),
'alias_status' => $this->aliasStatus,
'write_allowed' => $this->writeAllowed,
'deprecation_note' => $this->deprecationNote,
'retirement_path' => $this->retirementPath,
];
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Support;
final readonly class OperationTypeResolution
{
/**
* @param list<OperationTypeAlias> $aliasesConsidered
*/
public function __construct(
public string $rawValue,
public CanonicalOperationType $canonical,
public array $aliasesConsidered,
public string $aliasStatus,
public bool $wasLegacyAlias,
) {}
/**
* @return array{
* raw_value: string,
* canonical: array{
* canonical_code: string,
* domain_key: ?string,
* artifact_family: ?string,
* display_label: string,
* supports_operator_explanation: bool,
* expected_duration_seconds: ?int
* },
* aliases_considered: list<array{
* raw_value: string,
* canonical_code: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }>,
* alias_status: string,
* was_legacy_alias: bool
* }
*/
public function toArray(): array
{
return [
'raw_value' => $this->rawValue,
'canonical' => $this->canonical->toArray(),
'aliases_considered' => array_map(
static fn (OperationTypeAlias $alias): array => $alias->toArray(),
$this->aliasesConsidered,
),
'alias_status' => $this->aliasStatus,
'was_legacy_alias' => $this->wasLegacyAlias,
];
}
}

View File

@ -5,6 +5,8 @@
namespace App\Support\Operations; namespace App\Support\Operations;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum ExecutionDenialReasonCode: string enum ExecutionDenialReasonCode: string
@ -125,6 +127,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(), nextSteps: $this->nextSteps(),
showNoActionNeeded: false, showNoActionNeeded: false,
diagnosticCodeLabel: $this->value, diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'execution_denial',
reasonCode: $this->value,
platformReasonFamily: PlatformReasonFamily::Authorization,
),
); );
} }
} }

View File

@ -5,6 +5,8 @@
namespace App\Support\Operations; namespace App\Support\Operations;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum LifecycleReconciliationReason: string enum LifecycleReconciliationReason: string
@ -78,6 +80,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(), nextSteps: $this->nextSteps(),
showNoActionNeeded: false, showNoActionNeeded: false,
diagnosticCodeLabel: $this->value, diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'operation_lifecycle',
reasonCode: $this->value,
platformReasonFamily: PlatformReasonFamily::Execution,
),
); );
} }
} }

View File

@ -2,6 +2,10 @@
namespace App\Support\Providers; namespace App\Support\Providers;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
final class ProviderReasonCodes final class ProviderReasonCodes
{ {
public const string ProviderConnectionMissing = 'provider_connection_missing'; public const string ProviderConnectionMissing = 'provider_connection_missing';
@ -92,4 +96,65 @@ public static function isKnown(string $reasonCode): bool
{ {
return in_array($reasonCode, self::all(), true) || str_starts_with($reasonCode, 'ext.'); return in_array($reasonCode, self::all(), true) || str_starts_with($reasonCode, 'ext.');
} }
public static function registryKey(): string
{
return 'provider_reason_codes';
}
/**
* @return list<string>
*/
public static function canonicalNouns(): array
{
return ['reason_code'];
}
public static function ownerLayer(string $reasonCode): string
{
return PlatformVocabularyGlossary::OWNER_PROVIDER_OWNED;
}
public static function boundaryClassification(string $reasonCode): string
{
return PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC;
}
public static function ownerNamespace(string $reasonCode): string
{
return str_starts_with($reasonCode, 'intune_rbac.')
? 'provider.intune_rbac'
: 'provider.microsoft_graph';
}
public static function platformReasonFamily(string $reasonCode): PlatformReasonFamily
{
return match ($reasonCode) {
self::ProviderPermissionMissing,
self::ProviderPermissionDenied,
self::IntuneRbacPermissionMissing => PlatformReasonFamily::Authorization,
self::NetworkUnreachable,
self::RateLimited,
self::ProviderPermissionRefreshFailed,
self::ProviderAuthFailed => PlatformReasonFamily::Availability,
self::ProviderConnectionTypeInvalid,
self::TenantTargetMismatch,
self::ProviderConnectionReviewRequired => PlatformReasonFamily::Compatibility,
default => PlatformReasonFamily::Prerequisite,
};
}
public static function ownershipDescriptor(string $reasonCode): ?ReasonOwnershipDescriptor
{
if (! self::isKnown($reasonCode)) {
return null;
}
return new ReasonOwnershipDescriptor(
ownerLayer: self::ownerLayer($reasonCode),
ownerNamespace: self::ownerNamespace($reasonCode),
reasonCode: $reasonCode,
platformReasonFamily: self::platformReasonFamily($reasonCode),
);
}
} }

View File

@ -248,6 +248,7 @@ private function envelope(
nextSteps: $nextSteps, nextSteps: $nextSteps,
showNoActionNeeded: false, showNoActionNeeded: false,
diagnosticCodeLabel: $reasonCode, diagnosticCodeLabel: $reasonCode,
reasonOwnership: ProviderReasonCodes::ownershipDescriptor($reasonCode),
); );
} }

View File

@ -2,7 +2,10 @@
namespace App\Support; namespace App\Support;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum RbacReason: string enum RbacReason: string
@ -60,6 +63,26 @@ public function actionability(): string
}; };
} }
public function ownerLayer(): string
{
return PlatformVocabularyGlossary::OWNER_DOMAIN_OWNED;
}
public function ownerNamespace(): string
{
return 'rbac.intune';
}
public function platformReasonFamily(): PlatformReasonFamily
{
return PlatformReasonFamily::Authorization;
}
public function boundaryClassification(): string
{
return PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC;
}
/** /**
* @return array<int, NextStepOption> * @return array<int, NextStepOption>
*/ */
@ -92,6 +115,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(), nextSteps: $this->nextSteps(),
showNoActionNeeded: $this->actionability() === 'non_actionable', showNoActionNeeded: $this->actionability() === 'non_actionable',
diagnosticCodeLabel: $this->value, diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: $this->ownerLayer(),
ownerNamespace: $this->ownerNamespace(),
reasonCode: $this->value,
platformReasonFamily: $this->platformReasonFamily(),
),
); );
} }
} }

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Support\ReasonTranslation;
enum PlatformReasonFamily: string
{
case Authorization = 'authorization';
case Prerequisite = 'prerequisite';
case Compatibility = 'compatibility';
case Coverage = 'coverage';
case Availability = 'availability';
case Execution = 'execution';
public function label(): string
{
return match ($this) {
self::Authorization => 'Authorization',
self::Prerequisite => 'Prerequisite',
self::Compatibility => 'Compatibility',
self::Coverage => 'Coverage',
self::Availability => 'Availability',
self::Execution => 'Execution',
};
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Support\ReasonTranslation;
final readonly class ReasonOwnershipDescriptor
{
public function __construct(
public string $ownerLayer,
public string $ownerNamespace,
public string $reasonCode,
public PlatformReasonFamily $platformReasonFamily,
) {}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): ?self
{
$family = is_string($data['platform_reason_family'] ?? null)
? PlatformReasonFamily::tryFrom((string) $data['platform_reason_family'])
: null;
if (! $family instanceof PlatformReasonFamily) {
return null;
}
$ownerLayer = is_string($data['owner_layer'] ?? null) ? trim((string) $data['owner_layer']) : '';
$ownerNamespace = is_string($data['owner_namespace'] ?? null) ? trim((string) $data['owner_namespace']) : '';
$reasonCode = is_string($data['reason_code'] ?? null) ? trim((string) $data['reason_code']) : '';
if ($ownerLayer === '' || $ownerNamespace === '' || $reasonCode === '') {
return null;
}
return new self(
ownerLayer: $ownerLayer,
ownerNamespace: $ownerNamespace,
reasonCode: $reasonCode,
platformReasonFamily: $family,
);
}
/**
* @return array{
* owner_layer: string,
* owner_namespace: string,
* reason_code: string,
* platform_reason_family: string
* }
*/
public function toArray(): array
{
return [
'owner_layer' => $this->ownerLayer,
'owner_namespace' => $this->ownerNamespace,
'reason_code' => $this->reasonCode,
'platform_reason_family' => $this->platformReasonFamily->value,
];
}
}

View File

@ -13,6 +13,7 @@
use App\Support\Providers\ProviderReasonTranslator; use App\Support\Providers\ProviderReasonTranslator;
use App\Support\RbacReason; use App\Support\RbacReason;
use App\Support\Tenants\TenantOperabilityReasonCode; use App\Support\Tenants\TenantOperabilityReasonCode;
use Illuminate\Support\Str;
final class ReasonPresenter final class ReasonPresenter
{ {
@ -209,6 +210,93 @@ public function trustImpact(?ReasonResolutionEnvelope $envelope): ?string
return $envelope?->trustImpact; return $envelope?->trustImpact;
} }
public function ownerLayer(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->ownerLayer();
}
public function ownerNamespace(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->ownerNamespace();
}
public function platformReasonFamily(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->platformReasonFamily();
}
public function platformReasonFamilyLabel(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->platformReasonFamilyEnum()?->label();
}
public function ownerLabel(?ReasonResolutionEnvelope $envelope): ?string
{
$ownerNamespace = $envelope?->ownerNamespace();
if (! is_string($ownerNamespace) || trim($ownerNamespace) === '') {
return null;
}
return match (true) {
str_starts_with($ownerNamespace, 'provider.') => 'Provider-owned detail',
str_starts_with($ownerNamespace, 'governance.') => 'Governance detail',
$ownerNamespace === 'rbac.intune' => 'Intune RBAC detail',
$ownerNamespace === 'tenant_operability',
$ownerNamespace === 'execution_denial',
$ownerNamespace === 'operation_lifecycle',
$ownerNamespace === 'reason_translation.fallback' => 'Platform core',
default => match ($envelope?->ownerLayer()) {
'provider_owned' => 'Provider-owned detail',
'domain_owned' => 'Domain-owned detail',
'platform_core' => 'Platform core',
'compatibility_alias' => 'Compatibility alias',
'compatibility_only' => 'Compatibility-only detail',
default => Str::of((string) $envelope?->ownerLayer())->replace('_', ' ')->headline()->toString(),
},
};
}
/**
* @return array{
* owner_label: string,
* owner_layer: string,
* owner_namespace: string,
* boundary_classification: ?string,
* boundary_label: ?string,
* family: string,
* family_label: string,
* diagnostic_code: string
* }|null
*/
public function semantics(?ReasonResolutionEnvelope $envelope): ?array
{
if (! $envelope instanceof ReasonResolutionEnvelope) {
return null;
}
$boundary = $this->reasonTranslator->boundaryClassificationForEnvelope($envelope);
$family = $envelope->platformReasonFamilyEnum();
$ownerLabel = $this->ownerLabel($envelope);
if (! is_string($ownerLabel) || $family === null) {
return null;
}
return [
'owner_label' => $ownerLabel,
'owner_layer' => (string) $envelope->ownerLayer(),
'owner_namespace' => (string) $envelope->ownerNamespace(),
'boundary_classification' => $boundary,
'boundary_label' => is_string($boundary)
? Str::of($boundary)->replace('_', ' ')->headline()->toString()
: null,
'family' => $family->value,
'family_label' => $family->label(),
'diagnostic_code' => $envelope->diagnosticCode(),
];
}
public function absencePattern(?ReasonResolutionEnvelope $envelope): ?string public function absencePattern(?ReasonResolutionEnvelope $envelope): ?string
{ {
return $envelope?->absencePattern; return $envelope?->absencePattern;

View File

@ -22,6 +22,7 @@ public function __construct(
public ?string $diagnosticCodeLabel = null, public ?string $diagnosticCodeLabel = null,
public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value, public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value,
public ?string $absencePattern = null, public ?string $absencePattern = null,
public ?ReasonOwnershipDescriptor $reasonOwnership = null,
) { ) {
if (trim($this->internalCode) === '') { if (trim($this->internalCode) === '') {
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.'); throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
@ -97,6 +98,14 @@ public static function fromArray(array $data): ?self
$absencePattern = is_string($data['absence_pattern'] ?? null) $absencePattern = is_string($data['absence_pattern'] ?? null)
? trim((string) $data['absence_pattern']) ? trim((string) $data['absence_pattern'])
: (is_string($data['absencePattern'] ?? null) ? trim((string) $data['absencePattern']) : null); : (is_string($data['absencePattern'] ?? null) ? trim((string) $data['absencePattern']) : null);
$reasonOwnership = is_array($data['reason_owner'] ?? null)
? ReasonOwnershipDescriptor::fromArray($data['reason_owner'])
: (is_array($data['reasonOwnership'] ?? null) ? ReasonOwnershipDescriptor::fromArray($data['reasonOwnership']) : ReasonOwnershipDescriptor::fromArray([
'owner_layer' => $data['owner_layer'] ?? $data['ownerLayer'] ?? null,
'owner_namespace' => $data['owner_namespace'] ?? $data['ownerNamespace'] ?? null,
'reason_code' => $data['reason_code'] ?? $internalCode,
'platform_reason_family' => $data['platform_reason_family'] ?? $data['platformReasonFamily'] ?? null,
]));
if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') { if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
return null; return null;
@ -112,6 +121,7 @@ public static function fromArray(array $data): ?self
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null, diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value, trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value,
absencePattern: $absencePattern !== '' ? $absencePattern : null, absencePattern: $absencePattern !== '' ? $absencePattern : null,
reasonOwnership: $reasonOwnership,
); );
} }
@ -130,6 +140,23 @@ public function withNextSteps(array $nextSteps): self
diagnosticCodeLabel: $this->diagnosticCodeLabel, diagnosticCodeLabel: $this->diagnosticCodeLabel,
trustImpact: $this->trustImpact, trustImpact: $this->trustImpact,
absencePattern: $this->absencePattern, absencePattern: $this->absencePattern,
reasonOwnership: $this->reasonOwnership,
);
}
public function withReasonOwnership(?ReasonOwnershipDescriptor $reasonOwnership): self
{
return new self(
internalCode: $this->internalCode,
operatorLabel: $this->operatorLabel,
shortExplanation: $this->shortExplanation,
actionability: $this->actionability,
nextSteps: $this->nextSteps,
showNoActionNeeded: $this->showNoActionNeeded,
diagnosticCodeLabel: $this->diagnosticCodeLabel,
trustImpact: $this->trustImpact,
absencePattern: $this->absencePattern,
reasonOwnership: $reasonOwnership,
); );
} }
@ -181,6 +208,26 @@ public function diagnosticCode(): string
: $this->internalCode; : $this->internalCode;
} }
public function ownerLayer(): ?string
{
return $this->reasonOwnership?->ownerLayer;
}
public function ownerNamespace(): ?string
{
return $this->reasonOwnership?->ownerNamespace;
}
public function platformReasonFamily(): ?string
{
return $this->reasonOwnership?->platformReasonFamily->value;
}
public function platformReasonFamilyEnum(): ?PlatformReasonFamily
{
return $this->reasonOwnership?->platformReasonFamily;
}
/** /**
* @return array<int, array{label: string, url: string}> * @return array<int, array{label: string, url: string}>
*/ */
@ -209,9 +256,18 @@ public function toLegacyNextSteps(): array
* scope: string * scope: string
* }>, * }>,
* show_no_action_needed: bool, * show_no_action_needed: bool,
* diagnostic_code_label: string * diagnostic_code_label: string,
* trust_impact: string, * trust_impact: string,
* absence_pattern: ?string * absence_pattern: ?string,
* reason_owner: ?array{
* owner_layer: string,
* owner_namespace: string,
* reason_code: string,
* platform_reason_family: string
* },
* owner_layer: ?string,
* owner_namespace: ?string,
* platform_reason_family: ?string
* } * }
*/ */
public function toArray(): array public function toArray(): array
@ -229,6 +285,10 @@ public function toArray(): array
'diagnostic_code_label' => $this->diagnosticCode(), 'diagnostic_code_label' => $this->diagnosticCode(),
'trust_impact' => $this->trustImpact, 'trust_impact' => $this->trustImpact,
'absence_pattern' => $this->absencePattern, 'absence_pattern' => $this->absencePattern,
'reason_owner' => $this->reasonOwnership?->toArray(),
'owner_layer' => $this->ownerLayer(),
'owner_namespace' => $this->ownerNamespace(),
'platform_reason_family' => $this->platformReasonFamily(),
]; ];
} }
} }

View File

@ -6,6 +6,7 @@
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason; use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -27,6 +28,7 @@ final class ReasonTranslator
public function __construct( public function __construct(
private readonly ProviderReasonTranslator $providerReasonTranslator, private readonly ProviderReasonTranslator $providerReasonTranslator,
private readonly FallbackReasonTranslator $fallbackReasonTranslator, private readonly FallbackReasonTranslator $fallbackReasonTranslator,
private readonly PlatformVocabularyGlossary $glossary,
) {} ) {}
/** /**
@ -44,7 +46,7 @@ public function translate(
return null; return null;
} }
return match (true) { $envelope = match (true) {
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY, $artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context), $artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode), $artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
@ -62,6 +64,36 @@ public function translate(
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context), $artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context), default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context),
}; };
return $this->withOwnership($envelope, $reasonCode, $artifactKey);
}
/**
* @param array<string, mixed> $context
*/
public function boundaryClassification(
?string $reasonCode,
?string $artifactKey = null,
string $surface = 'detail',
array $context = [],
): ?string {
return $this->boundaryClassificationForEnvelope(
$this->translate($reasonCode, $artifactKey, $surface, $context),
);
}
public function boundaryClassificationForEnvelope(?ReasonResolutionEnvelope $envelope): ?string
{
return $this->boundaryClassificationForNamespace($envelope?->ownerNamespace());
}
public function boundaryClassificationForNamespace(?string $ownerNamespace): ?string
{
if (! is_string($ownerNamespace) || trim($ownerNamespace) === '') {
return null;
}
return $this->glossary->classifyReasonNamespace($ownerNamespace);
} }
/** /**
@ -305,4 +337,101 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
absencePattern: $enum->absencePattern(), absencePattern: $enum->absencePattern(),
); );
} }
private function withOwnership(
?ReasonResolutionEnvelope $envelope,
string $reasonCode,
?string $artifactKey,
): ?ReasonResolutionEnvelope {
if (! $envelope instanceof ReasonResolutionEnvelope) {
return null;
}
if ($envelope->reasonOwnership instanceof ReasonOwnershipDescriptor) {
return $envelope;
}
$ownership = match (true) {
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => ProviderReasonCodes::ownershipDescriptor($reasonCode),
$artifactKey === self::RBAC_ARTIFACT,
$artifactKey === null && RbacReason::tryFrom($reasonCode) instanceof RbacReason => new ReasonOwnershipDescriptor(
ownerLayer: 'domain_owned',
ownerNamespace: 'rbac.intune',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Authorization,
),
$artifactKey === self::TENANT_OPERABILITY_ARTIFACT,
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'tenant_operability',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Availability,
),
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'execution_denial',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Authorization,
),
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'operation_lifecycle',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Execution,
),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode,
$artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => new ReasonOwnershipDescriptor(
ownerLayer: 'domain_owned',
ownerNamespace: 'governance.baseline_compare',
reasonCode: $reasonCode,
platformReasonFamily: $this->baselineCompareFamily($reasonCode),
),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode),
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => new ReasonOwnershipDescriptor(
ownerLayer: 'domain_owned',
ownerNamespace: 'governance.artifact_truth',
reasonCode: $reasonCode,
platformReasonFamily: $this->baselineReasonFamily($reasonCode),
),
default => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'reason_translation.fallback',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Compatibility,
),
};
return $envelope->withReasonOwnership($ownership);
}
private function baselineCompareFamily(string $reasonCode): PlatformReasonFamily
{
return match (BaselineCompareReasonCode::tryFrom($reasonCode)) {
BaselineCompareReasonCode::CoverageUnproven,
BaselineCompareReasonCode::EvidenceCaptureIncomplete,
BaselineCompareReasonCode::UnsupportedSubjects,
BaselineCompareReasonCode::AmbiguousSubjects,
BaselineCompareReasonCode::NoSubjectsInScope,
BaselineCompareReasonCode::NoDriftDetected => PlatformReasonFamily::Coverage,
BaselineCompareReasonCode::StrategyFailed => PlatformReasonFamily::Execution,
BaselineCompareReasonCode::RolloutDisabled => PlatformReasonFamily::Compatibility,
BaselineCompareReasonCode::OverdueFindingsRemain,
BaselineCompareReasonCode::GovernanceExpiring,
BaselineCompareReasonCode::GovernanceLapsed => PlatformReasonFamily::Prerequisite,
default => PlatformReasonFamily::Compatibility,
};
}
private function baselineReasonFamily(string $reasonCode): PlatformReasonFamily
{
return match ($reasonCode) {
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED,
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => PlatformReasonFamily::Compatibility,
BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED => PlatformReasonFamily::Execution,
default => PlatformReasonFamily::Prerequisite,
};
}
} }

View File

@ -4,7 +4,10 @@
namespace App\Support\Tenants; namespace App\Support\Tenants;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum TenantOperabilityReasonCode: string enum TenantOperabilityReasonCode: string
@ -61,6 +64,26 @@ public function actionability(): string
}; };
} }
public function ownerLayer(): string
{
return PlatformVocabularyGlossary::OWNER_PLATFORM_CORE;
}
public function ownerNamespace(): string
{
return 'tenant_operability';
}
public function platformReasonFamily(): PlatformReasonFamily
{
return PlatformReasonFamily::Availability;
}
public function boundaryClassification(): string
{
return PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE;
}
/** /**
* @return array<int, NextStepOption> * @return array<int, NextStepOption>
*/ */
@ -102,6 +125,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(), nextSteps: $this->nextSteps(),
showNoActionNeeded: $this->actionability() === 'non_actionable', showNoActionNeeded: $this->actionability() === 'non_actionable',
diagnosticCodeLabel: $this->value, diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: $this->ownerLayer(),
ownerNamespace: $this->ownerNamespace(),
reasonCode: $this->value,
platformReasonFamily: $this->platformReasonFamily(),
),
); );
} }
} }

View File

@ -5,6 +5,8 @@
namespace App\Support\Ui\GovernanceArtifactTruth; namespace App\Support\Ui\GovernanceArtifactTruth;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
@ -22,6 +24,9 @@ public function __construct(
public string $trustImpact, public string $trustImpact,
public ?string $absencePattern, public ?string $absencePattern,
public array $nextSteps = [], public array $nextSteps = [],
public ?string $ownerLayer = null,
public ?string $ownerNamespace = null,
public ?string $platformReasonFamily = null,
) {} ) {}
public static function fromReasonResolutionEnvelope( public static function fromReasonResolutionEnvelope(
@ -44,11 +49,32 @@ public static function fromReasonResolutionEnvelope(
static fn (NextStepOption $nextStep): string => $nextStep->label, static fn (NextStepOption $nextStep): string => $nextStep->label,
$reason->nextSteps, $reason->nextSteps,
)), )),
ownerLayer: $reason->ownerLayer(),
ownerNamespace: $reason->ownerNamespace(),
platformReasonFamily: $reason->platformReasonFamily(),
); );
} }
public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
{ {
$reasonOwnership = null;
$family = is_string($this->platformReasonFamily)
? PlatformReasonFamily::tryFrom($this->platformReasonFamily)
: null;
if (is_string($this->ownerLayer)
&& trim($this->ownerLayer) !== ''
&& is_string($this->ownerNamespace)
&& trim($this->ownerNamespace) !== ''
&& $family instanceof PlatformReasonFamily) {
$reasonOwnership = new ReasonOwnershipDescriptor(
ownerLayer: trim($this->ownerLayer),
ownerNamespace: trim($this->ownerNamespace),
reasonCode: $this->reasonCode ?? 'artifact_truth_reason',
platformReasonFamily: $family,
);
}
return new ReasonResolutionEnvelope( return new ReasonResolutionEnvelope(
internalCode: $this->reasonCode ?? 'artifact_truth_reason', internalCode: $this->reasonCode ?? 'artifact_truth_reason',
operatorLabel: $this->operatorLabel ?? 'Operator attention required', operatorLabel: $this->operatorLabel ?? 'Operator attention required',
@ -61,6 +87,7 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
diagnosticCodeLabel: $this->diagnosticCode, diagnosticCodeLabel: $this->diagnosticCode,
trustImpact: $this->trustImpact !== '' ? $this->trustImpact : TrustworthinessLevel::LimitedConfidence->value, trustImpact: $this->trustImpact !== '' ? $this->trustImpact : TrustworthinessLevel::LimitedConfidence->value,
absencePattern: $this->absencePattern, absencePattern: $this->absencePattern,
reasonOwnership: $reasonOwnership,
); );
} }
@ -73,7 +100,10 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
* diagnosticCode: ?string, * diagnosticCode: ?string,
* trustImpact: string, * trustImpact: string,
* absencePattern: ?string, * absencePattern: ?string,
* nextSteps: array<int, string> * nextSteps: array<int, string>,
* ownerLayer: ?string,
* ownerNamespace: ?string,
* platformReasonFamily: ?string
* } * }
*/ */
public function toArray(): array public function toArray(): array
@ -87,6 +117,9 @@ public function toArray(): array
'trustImpact' => $this->trustImpact, 'trustImpact' => $this->trustImpact,
'absencePattern' => $this->absencePattern, 'absencePattern' => $this->absencePattern,
'nextSteps' => $this->nextSteps, 'nextSteps' => $this->nextSteps,
'ownerLayer' => $this->ownerLayer,
'ownerNamespace' => $this->ownerNamespace,
'platformReasonFamily' => $this->platformReasonFamily,
]; ];
} }
} }

View File

@ -597,6 +597,248 @@
], ],
], ],
'platform_vocabulary' => [
'terms' => [
'governed_subject' => [
'term_key' => 'governed_subject',
'canonical_label' => 'Governed subject',
'canonical_description' => 'The platform-facing noun for a governed object across compare, snapshot, evidence, and review surfaces.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['compare', 'snapshot', 'evidence', 'review', 'reporting'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Retire false-universal policy_type wording from platform-owned summaries and descriptors while preserving Intune-owned storage.',
'forbidden_platform_aliases' => ['policy_type'],
],
'domain_key' => [
'term_key' => 'domain_key',
'canonical_label' => 'Governance domain',
'canonical_description' => 'The canonical domain discriminator for cross-domain and platform-near contracts.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_class' => [
'term_key' => 'subject_class',
'canonical_label' => 'Subject class',
'canonical_description' => 'The canonical subject-class discriminator for governed-subject contracts.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'compare', 'snapshot'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_type_key' => [
'term_key' => 'subject_type_key',
'canonical_label' => 'Governed subject key',
'canonical_description' => 'The domain-owned subject-family key used by platform-near descriptors.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'evidence'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Prefer subject_type_key on platform-near payloads and keep policy_type only where the owning model is Intune-specific.',
'forbidden_platform_aliases' => ['policy_type'],
],
'subject_type_label' => [
'term_key' => 'subject_type_label',
'canonical_label' => 'Governed subject label',
'canonical_description' => 'The operator-facing label for a governed subject family.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['snapshot', 'evidence', 'compare', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'resource_type' => [
'term_key' => 'resource_type',
'canonical_label' => 'Resource type',
'canonical_description' => 'Optional resource-shaped noun for platform-facing summaries when a governed subject also needs a resource family label.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reporting', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'operation_type' => [
'term_key' => 'operation_type',
'canonical_label' => 'Operation type',
'canonical_description' => 'The canonical platform operation identifier shown on monitoring and launch surfaces.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['monitoring', 'reporting', 'launch_surfaces'],
'legacy_aliases' => ['type'],
'alias_retirement_path' => 'Expose canonical operation_type on read models while operation_runs.type remains a compatibility storage seam during rollout.',
'forbidden_platform_aliases' => [],
],
'platform_reason_family' => [
'term_key' => 'platform_reason_family',
'canonical_label' => 'Platform reason family',
'canonical_description' => 'The cross-domain platform family that explains the top-level cause category without erasing domain ownership.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reason_translation', 'monitoring', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_owner.owner_namespace' => [
'term_key' => 'reason_owner.owner_namespace',
'canonical_label' => 'Reason owner namespace',
'canonical_description' => 'The explicit namespace that marks whether a translated reason is provider-owned, governance-owned, access-owned, or runtime-owned.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reason_translation', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_code' => [
'term_key' => 'reason_code',
'canonical_label' => 'Reason code',
'canonical_description' => 'The original domain-owned or provider-owned reason identifier preserved for diagnostics and translation lookup.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reason_translation', 'diagnostics'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'registry_key' => [
'term_key' => 'registry_key',
'canonical_label' => 'Registry key',
'canonical_description' => 'The stable identifier for a contributor-facing registry or catalog.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'boundary_classification' => [
'term_key' => 'boundary_classification',
'canonical_label' => 'Boundary classification',
'canonical_description' => 'The explicit classification that marks a term or registry as platform_core, cross_domain_governance, or intune_specific.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'policy_type' => [
'term_key' => 'policy_type',
'canonical_label' => 'Intune policy type',
'canonical_description' => 'The Intune-specific discriminator that remains valid on adapter-owned or Intune-owned models but must not be treated as a universal platform noun.',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'domain_owned',
'allowed_contexts' => ['intune_adapter', 'intune_inventory', 'intune_backup'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => ['governed_subject'],
],
],
'reason_namespaces' => [
'tenant_operability' => [
'owner_namespace' => 'tenant_operability',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Tenant operability reasons are platform-core guardrails for workspace and tenant context.',
],
'execution_denial' => [
'owner_namespace' => 'execution_denial',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Execution denial reasons remain platform-core run-legitimacy semantics.',
],
'operation_lifecycle' => [
'owner_namespace' => 'operation_lifecycle',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Lifecycle reconciliation reasons remain platform-core monitoring semantics.',
],
'governance.baseline_compare' => [
'owner_namespace' => 'governance.baseline_compare',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'domain_owned',
'compatibility_notes' => 'Baseline-compare reason codes are governance-owned details translated through the platform boundary.',
],
'governance.artifact_truth' => [
'owner_namespace' => 'governance.artifact_truth',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'domain_owned',
'compatibility_notes' => 'Artifact-truth reason codes remain governance-owned and are surfaced through platform-safe summaries.',
],
'provider.microsoft_graph' => [
'owner_namespace' => 'provider.microsoft_graph',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'provider_owned',
'compatibility_notes' => 'Microsoft Graph provider reasons remain provider-owned and Intune-specific.',
],
'provider.intune_rbac' => [
'owner_namespace' => 'provider.intune_rbac',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'provider_owned',
'compatibility_notes' => 'Provider-owned Intune RBAC reasons remain Intune-specific.',
],
'rbac.intune' => [
'owner_namespace' => 'rbac.intune',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'domain_owned',
'compatibility_notes' => 'RBAC detail remains Intune-specific domain context.',
],
'reason_translation.fallback' => [
'owner_namespace' => 'reason_translation.fallback',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Fallback translation remains a platform-core compatibility seam until a domain owner is known.',
],
],
'registries' => [
'governance_subject_taxonomy_registry' => [
'registry_key' => 'governance_subject_taxonomy_registry',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'source_class_or_file' => App\Support\Governance\GovernanceSubjectTaxonomyRegistry::class,
'canonical_nouns' => ['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label'],
'allowed_consumers' => ['baseline_scope', 'compare', 'snapshot', 'review'],
'compatibility_notes' => 'Provides the governed-subject source of truth, resolves legacy policy-type payloads, and preserves inactive or future-domain entries without exposing them as active operator choices.',
],
'operation_catalog' => [
'registry_key' => 'operation_catalog',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'source_class_or_file' => App\Support\OperationCatalog::class,
'canonical_nouns' => ['operation_type'],
'allowed_consumers' => ['monitoring', 'reporting', 'launch_surfaces', 'audit'],
'compatibility_notes' => 'Resolves canonical operation meaning from historical storage values without treating every stored raw string as equally canonical.',
],
'provider_reason_codes' => [
'registry_key' => 'provider_reason_codes',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'provider_owned',
'source_class_or_file' => App\Support\Providers\ProviderReasonCodes::class,
'canonical_nouns' => ['reason_code'],
'allowed_consumers' => ['reason_translation'],
'compatibility_notes' => 'Provider-owned reason codes remain namespaced domain details and become platform-safe only through the reason-translation boundary.',
],
'inventory_policy_type_catalog' => [
'registry_key' => 'inventory_policy_type_catalog',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'domain_owned',
'source_class_or_file' => App\Support\Inventory\InventoryPolicyTypeMeta::class,
'canonical_nouns' => ['policy_type'],
'allowed_consumers' => ['intune_adapter', 'inventory', 'backup'],
'compatibility_notes' => 'The supported Intune policy-type list is not a universal platform registry and must be wrapped before reuse on platform-near summaries.',
],
],
],
'hardening' => [ 'hardening' => [
'intune_write_gate' => [ 'intune_write_gate' => [
'enabled' => (bool) env('TENANTPILOT_INTUNE_WRITE_GATE_ENABLED', true), 'enabled' => (bool) env('TENANTPILOT_INTUNE_WRITE_GATE_ENABLED', true),

View File

@ -30,10 +30,10 @@
'badge_evidence_gaps' => 'Evidence gaps: :count', 'badge_evidence_gaps' => 'Evidence gaps: :count',
'evidence_gaps_tooltip' => 'Top gaps: :summary', 'evidence_gaps_tooltip' => 'Top gaps: :summary',
'evidence_gap_details_heading' => 'Evidence gap details', 'evidence_gap_details_heading' => 'Evidence gap details',
'evidence_gap_details_description' => 'Search recorded gap subjects by reason, policy type, subject class, outcome, next action, or subject key before falling back to raw diagnostics.', 'evidence_gap_details_description' => 'Search recorded gap subjects by reason, governed subject, subject class, outcome, next action, or subject key before falling back to raw diagnostics.',
'evidence_gap_search_label' => 'Search gap details', 'evidence_gap_search_label' => 'Search gap details',
'evidence_gap_search_placeholder' => 'Search by reason, type, class, outcome, action, or subject key', 'evidence_gap_search_placeholder' => 'Search by reason, type, class, outcome, action, or subject key',
'evidence_gap_search_help' => 'Filter matches across reason, policy type, subject class, outcome, next action, and subject key.', 'evidence_gap_search_help' => 'Filter matches across reason, governed subject, subject class, outcome, next action, and subject key.',
'evidence_gap_bucket_help_ambiguous_match' => 'Multiple inventory records matched the same policy subject — inspect the mapping to confirm the correct pairing.', 'evidence_gap_bucket_help_ambiguous_match' => 'Multiple inventory records matched the same policy subject — inspect the mapping to confirm the correct pairing.',
'evidence_gap_bucket_help_policy_record_missing' => 'The expected policy record was not found in the baseline snapshot — verify the policy still exists in the tenant.', 'evidence_gap_bucket_help_policy_record_missing' => 'The expected policy record was not found in the baseline snapshot — verify the policy still exists in the tenant.',
'evidence_gap_bucket_help_inventory_record_missing' => 'No inventory record could be matched for these subjects — confirm the inventory sync is current.', 'evidence_gap_bucket_help_inventory_record_missing' => 'No inventory record could be matched for these subjects — confirm the inventory sync is current.',
@ -57,7 +57,7 @@
'evidence_gap_legacy_body' => 'This run still uses the retired broad reason shape. Regenerate the run or purge stale local development payloads before treating the gap details as operator-safe.', 'evidence_gap_legacy_body' => 'This run still uses the retired broad reason shape. Regenerate the run or purge stale local development payloads before treating the gap details as operator-safe.',
'evidence_gap_diagnostics_heading' => 'Baseline compare evidence', 'evidence_gap_diagnostics_heading' => 'Baseline compare evidence',
'evidence_gap_diagnostics_description' => 'Raw diagnostics remain available for support and deep troubleshooting after the operator summary and searchable detail.', 'evidence_gap_diagnostics_description' => 'Raw diagnostics remain available for support and deep troubleshooting after the operator summary and searchable detail.',
'evidence_gap_policy_type' => 'Policy type', 'evidence_gap_policy_type' => 'Governed subject',
'evidence_gap_subject_class' => 'Subject class', 'evidence_gap_subject_class' => 'Subject class',
'evidence_gap_outcome' => 'Outcome', 'evidence_gap_outcome' => 'Outcome',
'evidence_gap_next_action' => 'Next action', 'evidence_gap_next_action' => 'Next action',

View File

@ -33,7 +33,7 @@
@endphp @endphp
<x-filament::section <x-filament::section
:heading="$group['label'] ?? ($group['policyType'] ?? 'Policy type')" :heading="$group['subjectDescriptor']['display_label'] ?? ($group['label'] ?? ($group['policyType'] ?? 'Governed subject'))"
:description="$group['coverageHint'] ?? null" :description="$group['coverageHint'] ?? null"
collapsible collapsible
:collapsed="(bool) ($group['initiallyCollapsed'] ?? true)" :collapsed="(bool) ($group['initiallyCollapsed'] ?? true)"
@ -84,7 +84,7 @@
{{ $item['label'] ?? 'Snapshot item' }} {{ $item['label'] ?? 'Snapshot item' }}
</div> </div>
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
{{ $item['typeLabel'] ?? 'Policy type' }} {{ $item['typeLabel'] ?? 'Governed subject family' }}
</div> </div>
</div> </div>

View File

@ -22,14 +22,14 @@
<div class="space-y-3"> <div class="space-y-3">
@if ($rows === []) @if ($rows === [])
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300"> <div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300">
No captured policy types are available in this snapshot. No captured governed subjects are available in this snapshot.
</div> </div>
@else @else
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700"> <div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-left text-sm dark:divide-gray-700"> <table class="min-w-full divide-y divide-gray-200 text-left text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/50"> <thead class="bg-gray-50 dark:bg-gray-900/50">
<tr class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400"> <tr class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Policy type</th> <th class="px-4 py-3">Governed subject</th>
<th class="px-4 py-3">Items</th> <th class="px-4 py-3">Items</th>
<th class="px-4 py-3">Fidelity</th> <th class="px-4 py-3">Fidelity</th>
<th class="px-4 py-3">Coverage state</th> <th class="px-4 py-3">Coverage state</th>
@ -47,7 +47,7 @@
<tr class="align-top"> <tr class="align-top">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white"> <td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ $row['label'] ?? ($row['policyType'] ?? 'Policy type') }} {{ $row['governedSubjectLabel'] ?? ($row['label'] ?? ($row['policyType'] ?? 'Governed subject')) }}
</td> </td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-200"> <td class="px-4 py-3 text-gray-700 dark:text-gray-200">
{{ (int) ($row['itemCount'] ?? 0) }} {{ (int) ($row['itemCount'] ?? 0) }}

View File

@ -17,7 +17,7 @@
<div class="space-y-3"> <div class="space-y-3">
@foreach ($groupPayloads as $group) @foreach ($groupPayloads as $group)
@php @php
$label = $group['label'] ?? 'Policy type'; $label = $group['payload']['subject_descriptor']['display_label'] ?? ($group['label'] ?? 'Governed subject');
$payload = is_array($group['payload'] ?? null) ? $group['payload'] : []; $payload = is_array($group['payload'] ?? null) ? $group['payload'] : [];
@endphp @endphp

View File

@ -8,6 +8,7 @@
$contextLinks = is_array($state['context_links'] ?? null) ? $state['context_links'] : []; $contextLinks = is_array($state['context_links'] ?? null) ? $state['context_links'] : [];
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : []; $publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : []; $operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
$reasonSemantics = is_array($state['reason_semantics'] ?? null) ? $state['reason_semantics'] : [];
@endphp @endphp
<div class="space-y-4"> <div class="space-y-4">
@ -31,6 +32,20 @@
</div> </div>
@endif @endif
@if ($reasonSemantics !== [])
<dl class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}</dd>
</div>
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}</dd>
</div>
</dl>
@endif
<dl class="grid grid-cols-2 gap-3 xl:grid-cols-4"> <dl class="grid grid-cols-2 gap-3 xl:grid-cols-4">
@foreach ($metrics as $metric) @foreach ($metrics as $metric)
@php @php

View File

@ -8,6 +8,7 @@
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0); $duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0); $duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null; $explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
$reasonSemantics = is_array($reasonSemantics ?? null) ? $reasonSemantics : null;
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null; $summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
$matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null; $matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null;
$arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix'); $arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix');
@ -117,6 +118,24 @@
@endif @endif
</div> </div>
@if ($reasonSemantics !== null)
<dl class="grid gap-3 md:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}
</dd>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}
</dd>
</div>
</dl>
@endif
<dl class="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <dl class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50"> <div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Execution outcome</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Execution outcome</dt>

View File

@ -340,7 +340,7 @@
@if ($policyTypeOptions !== []) @if ($policyTypeOptions !== [])
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm"> <x-filament::badge color="gray" size="sm">
{{ count($policyTypeOptions) }} searchable policy types {{ count($policyTypeOptions) }} searchable governed subjects
</x-filament::badge> </x-filament::badge>
@if ($hiddenAssignedTenantCount > 0) @if ($hiddenAssignedTenantCount > 0)
<x-filament::badge color="gray" size="sm"> <x-filament::badge color="gray" size="sm">
@ -507,7 +507,7 @@
{{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }} {{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }}
</div> </div>
<div class="text-sm text-gray-500 dark:text-gray-400"> <div class="text-sm text-gray-500 dark:text-gray-400">
{{ $result['policyType'] ?? 'Unknown policy type' }} {{ $result['governedSubjectLabel'] ?? ($result['policyType'] ?? 'Unknown governed subject') }}
</div> </div>
@if (filled($result['baselineExternalId'] ?? null)) @if (filled($result['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
@ -715,7 +715,7 @@
{{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }} {{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }}
</div> </div>
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
{{ $subject['policyType'] ?? 'Unknown policy type' }} {{ $subject['governedSubjectLabel'] ?? ($subject['policyType'] ?? 'Unknown governed subject') }}
</div> </div>
@if (filled($subject['baselineExternalId'] ?? null)) @if (filled($subject['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">

View File

@ -11,6 +11,8 @@
/** @var bool $canManage */ /** @var bool $canManage */
/** @var ?string $downloadUrl */ /** @var ?string $downloadUrl */
/** @var ?string $failedReason */ /** @var ?string $failedReason */
/** @var ?string $failedReasonDetail */
/** @var ?array<string, mixed> $failedReasonSemantics */
/** @var ?string $reviewUrl */ /** @var ?string $reviewUrl */
$badgeSpec = $statusEnum ? BadgeCatalog::spec(BadgeDomain::ReviewPackStatus, $statusEnum->value) : null; $badgeSpec = $statusEnum ? BadgeCatalog::spec(BadgeDomain::ReviewPackStatus, $statusEnum->value) : null;
@ -133,11 +135,25 @@
</div> </div>
@if ($failedReason) @if ($failedReason)
<div class="text-sm text-danger-600 dark:text-danger-400"> <div class="text-sm font-medium text-danger-600 dark:text-danger-400">
{{ $failedReason }} {{ $failedReason }}
</div> </div>
@endif @endif
@if ($failedReasonDetail)
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $failedReasonDetail }}
</div>
@endif
@if (is_array($failedReasonSemantics ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
Reason owner: {{ $failedReasonSemantics['owner_label'] ?? 'Platform core' }}
&middot;
Platform reason family: {{ $failedReasonSemantics['family_label'] ?? 'Compatibility' }}
</div>
@endif
<div class="text-xs text-gray-400 dark:text-gray-500"> <div class="text-xs text-gray-400 dark:text-gray-500">
{{ $pack->updated_at?->diffForHumans() ?? '—' }} {{ $pack->updated_at?->diffForHumans() ?? '—' }}
</div> </div>

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\OperationCatalog;
it('keeps touched registry ownership metadata inside the allowed three-way boundary classification', function (): void {
$classifications = collect(app(PlatformVocabularyGlossary::class)->registries())
->map(static fn ($descriptor): string => $descriptor->boundaryClassification)
->unique()
->values()
->all();
expect($classifications)->toEqualCanonicalizing([
PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE,
PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC,
]);
});
it('guards the false-universal policy_type alias behind explicit context-aware vocabulary helpers', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
expect($glossary->term('policy_type')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC)
->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject')
->and(OperationCatalog::canonicalCode('baseline_capture'))->toBe('baseline.capture');
});

View File

@ -2,6 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\RbacReason; use App\Support\RbacReason;
@ -32,3 +34,24 @@
->and(TenantOperabilityReasonCode::TenantAlreadyArchived->toReasonResolutionEnvelope()->guidanceText())->toBe('No action needed.') ->and(TenantOperabilityReasonCode::TenantAlreadyArchived->toReasonResolutionEnvelope()->guidanceText())->toBe('No action needed.')
->and(RbacReason::ManualAssignmentRequired->toReasonResolutionEnvelope()->operatorLabel)->toBe('Manual role assignment required'); ->and(RbacReason::ManualAssignmentRequired->toReasonResolutionEnvelope()->operatorLabel)->toBe('Manual role assignment required');
}); });
it('keeps primary-surface reason ownership inside the allowed three-way boundary classification', function (): void {
$translator = app(ReasonTranslator::class);
$classifications = collect([
$translator->boundaryClassification(ExecutionDenialReasonCode::MissingCapability->value, ReasonTranslator::EXECUTION_DENIAL_ARTIFACT),
$translator->boundaryClassification(ProviderReasonCodes::ProviderConsentMissing),
$translator->boundaryClassification(TenantOperabilityReasonCode::RememberedContextStale->value, ReasonTranslator::TENANT_OPERABILITY_ARTIFACT),
$translator->boundaryClassification(RbacReason::ManualAssignmentRequired->value, ReasonTranslator::RBAC_ARTIFACT),
$translator->boundaryClassification(BaselineCompareReasonCode::CoverageUnproven->value, ReasonTranslator::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
])
->filter()
->unique()
->values()
->all();
expect($classifications)->toEqualCanonicalizing([
PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE,
PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC,
]);
});

View File

@ -42,7 +42,9 @@
->assertSuccessful() ->assertSuccessful()
->assertSee('Permission required') ->assertSee('Permission required')
->assertSee('The initiating actor no longer has the capability required for this queued run.') ->assertSee('The initiating actor no longer has the capability required for this queued run.')
->assertSee('Review workspace or tenant access before retrying.'); ->assertSee('Review workspace or tenant access before retrying.')
->assertDontSee('execution_denial')
->assertDontSee('platform_core');
}); });
it('returns not found before any translated guidance can leak to non-members', function (): void { it('returns not found before any translated guidance can leak to non-members', function (): void {

View File

@ -215,7 +215,7 @@
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match') ->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared') ->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended') ->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Policy type coverage was not proven') ->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Governed subject coverage was not proven')
->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result') ->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result')
->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale') ->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale')
->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence') ->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence')

View File

@ -80,7 +80,7 @@ function baselineCompareEvidenceGapBuckets(): array
expect($table->isSearchable())->toBeTrue(); expect($table->isSearchable())->toBeTrue();
expect($table->getDefaultSortColumn())->toBe('reason_label'); expect($table->getDefaultSortColumn())->toBe('reason_label');
expect($table->getColumn('reason_label')?->isSearchable())->toBeTrue(); expect($table->getColumn('reason_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('policy_type')?->isSearchable())->toBeTrue(); expect($table->getColumn('governed_subject_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('subject_class_label')?->isSearchable())->toBeTrue(); expect($table->getColumn('subject_class_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('resolution_outcome_label')?->isSearchable())->toBeTrue(); expect($table->getColumn('resolution_outcome_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('operator_action_category_label')?->isSearchable())->toBeTrue(); expect($table->getColumn('operator_action_category_label')?->isSearchable())->toBeTrue();
@ -90,7 +90,7 @@ function baselineCompareEvidenceGapBuckets(): array
->assertSee('WiFi-Corp-Profile') ->assertSee('WiFi-Corp-Profile')
->assertSee('Deleted-Policy-ABC') ->assertSee('Deleted-Policy-ABC')
->assertSee('Reason') ->assertSee('Reason')
->assertSee('Policy type') ->assertSee('Governed subject')
->assertSee('Subject class') ->assertSee('Subject class')
->assertSee('Outcome') ->assertSee('Outcome')
->assertSee('Next action') ->assertSee('Next action')
@ -119,6 +119,7 @@ function baselineCompareEvidenceGapBuckets(): array
->assertSee('Retired-Compliance-Policy') ->assertSee('Retired-Compliance-Policy')
->assertDontSee('VPN-Always-On') ->assertDontSee('VPN-Always-On')
->filterTable('policy_type', 'deviceCompliancePolicy') ->filterTable('policy_type', 'deviceCompliancePolicy')
->assertSee('Device Compliance')
->assertSee('Retired-Compliance-Policy') ->assertSee('Retired-Compliance-Policy')
->assertDontSee('Deleted-Policy-ABC') ->assertDontSee('Deleted-Policy-ABC')
->filterTable('operator_action_category', 'run_policy_sync_or_backup') ->filterTable('operator_action_category', 'run_policy_sync_or_backup')

View File

@ -9,6 +9,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineCompareStats; use App\Support\Baselines\BaselineCompareStats;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\ExplanationFamily; use App\Support\Ui\OperatorExplanation\ExplanationFamily;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Livewire\Livewire; use Livewire\Livewire;
@ -74,14 +75,22 @@
$stats = BaselineCompareStats::forTenant($tenant); $stats = BaselineCompareStats::forTenant($tenant);
$explanation = $stats->operatorExplanation(); $explanation = $stats->operatorExplanation();
$summary = $stats->summaryAssessment(); $summary = $stats->summaryAssessment();
$reasonSemantics = app(ReasonPresenter::class)->semantics(
app(ReasonPresenter::class)->forArtifactTruth(BaselineCompareReasonCode::CoverageUnproven->value, 'artifact_truth'),
);
expect($explanation->family)->toBe(ExplanationFamily::SuppressedOutput); expect($explanation->family)->toBe(ExplanationFamily::SuppressedOutput);
expect($reasonSemantics)->not->toBeNull();
Livewire::actingAs($user) Livewire::actingAs($user)
->test(BaselineCompareLanding::class) ->test(BaselineCompareLanding::class)
->assertSee($summary->headline) ->assertSee($summary->headline)
->assertSee($explanation->trustworthinessLabel()) ->assertSee($explanation->trustworthinessLabel())
->assertSee($summary->nextActionLabel()) ->assertSee($summary->nextActionLabel())
->assertSee('Reason owner')
->assertSee($reasonSemantics['owner_label'])
->assertSee('Platform reason family')
->assertSee($reasonSemantics['family_label'])
->assertSee('Findings shown') ->assertSee('Findings shown')
->assertSee('Evidence gaps'); ->assertSee('Evidence gaps');
}); });

View File

@ -48,7 +48,7 @@
$this->actingAs($user) $this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin')) ->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk() ->assertOk()
->assertSeeInOrder(['Artifact truth', 'Coverage summary', 'Captured policy types', 'Technical detail']) ->assertSeeInOrder(['Artifact truth', 'Coverage summary', 'Captured governed subjects', 'Technical detail'])
->assertSee('Reference only') ->assertSee('Reference only')
->assertSee('Inventory metadata') ->assertSee('Inventory metadata')
->assertSee('Metadata-only evidence was captured for this item.') ->assertSee('Metadata-only evidence was captured for this item.')
@ -111,7 +111,7 @@ public function render(BaselineSnapshotItem $item): RenderedSnapshotItem
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin')) ->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk() ->assertOk()
->assertSee('Technical detail') ->assertSee('Technical detail')
->assertSee('Structured rendering failed for this policy type. Fallback metadata is shown instead.') ->assertSee('Structured rendering failed for this governed subject family. Fallback metadata is shown instead.')
->assertSee('Bitlocker Require') ->assertSee('Bitlocker Require')
->assertSee('A fallback renderer is being used for this item.'); ->assertSee('A fallback renderer is being used for this item.');
}); });

View File

@ -8,7 +8,7 @@
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem; use App\Models\BaselineSnapshotItem;
it('renders the baseline snapshot detail page as summary-first with grouped policy browsing', function (): void { it('renders the baseline snapshot detail page as summary-first with grouped governed-subject browsing', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
@ -93,13 +93,14 @@
->assertSee('Capture timing') ->assertSee('Capture timing')
->assertSee('Related context') ->assertSee('Related context')
->assertSee(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), false) ->assertSee(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), false)
->assertSeeInOrder(['Security Baseline', 'Coverage summary', 'Captured policy types', 'Technical detail']) ->assertSeeInOrder(['Security Baseline', 'Coverage summary', 'Captured governed subjects', 'Technical detail'])
->assertSee('Security Reader') ->assertSee('Security Reader')
->assertSee('Bitlocker Require') ->assertSee('Bitlocker Require')
->assertSee('Mystery Policy') ->assertSee('Mystery Policy')
->assertSee('Intune RBAC Role Definition') ->assertSee('Intune RBAC Role Definition')
->assertSee('Device Compliance') ->assertSee('Device Compliance')
->assertSee('Mystery Policy Type') ->assertSee('Mystery Policy Type')
->assertSee('Governed subject')
->assertDontSee('Intune RBAC Role Definition References'); ->assertDontSee('Intune RBAC Role Definition References');
$this->actingAs($user) $this->actingAs($user)

View File

@ -77,7 +77,7 @@
$this->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin')) $this->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk() ->assertOk()
->assertSeeInOrder(['Security Baseline', 'Captured policy types', 'Technical detail']); ->assertSeeInOrder(['Security Baseline', 'Captured governed subjects', 'Technical detail']);
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant)) $this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
->assertOk() ->assertOk()

View File

@ -9,8 +9,10 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload; use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Testing\TestResponse; use Illuminate\Testing\TestResponse;
@ -303,6 +305,47 @@ function baselineCompareGapContext(array $overrides = []): array
->assertSee('Adapter reconciler'); ->assertSee('Adapter reconciler');
}); });
it('renders explicit reason-owner and platform-family semantics for blocked runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'reason_code' => ExecutionDenialReasonCode::MissingCapability->value,
'execution_legitimacy' => [
'reason_code' => ExecutionDenialReasonCode::MissingCapability->value,
],
],
'failure_summary' => [[
'code' => 'operation.blocked',
'reason_code' => ExecutionDenialReasonCode::MissingCapability->value,
'message' => 'Operation blocked because the initiating actor no longer has the required capability.',
]],
]);
$reasonSemantics = app(ReasonPresenter::class)->semantics(
app(ReasonPresenter::class)->forOperationRun($run, 'run_detail'),
);
expect($reasonSemantics)->not->toBeNull();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Explanation semantics')
->assertSee('Reason owner')
->assertSee($reasonSemantics['owner_label'])
->assertSee('Platform reason family')
->assertSee($reasonSemantics['family_label']);
});
it('renders evidence gap details section for baseline compare runs with gap subjects', function (): void { it('renders evidence gap details section for baseline compare runs with gap subjects', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -340,7 +383,7 @@ function baselineCompareGapContext(array $overrides = []): array
->assertSee('2 affected') ->assertSee('2 affected')
->assertSee('WiFi-Corp-Profile') ->assertSee('WiFi-Corp-Profile')
->assertSee('Deleted-Policy-ABC') ->assertSee('Deleted-Policy-ABC')
->assertSee('Policy type') ->assertSee('Governed subject')
->assertSee('Subject class') ->assertSee('Subject class')
->assertSee('Outcome') ->assertSee('Outcome')
->assertSee('Next action') ->assertSee('Next action')

View File

@ -160,11 +160,49 @@ function operationRunFilterIndicatorLabels($component): array
expect($filter)->not->toBeNull(); expect($filter)->not->toBeNull();
expect($filter?->getOptions())->toBe([ expect($filter?->getOptions())->toBe([
'inventory_sync' => 'Inventory sync', 'inventory.sync' => 'Inventory sync',
'policy.sync' => 'Policy sync', 'policy.sync' => 'Policy sync',
]); ]);
}); });
it('filters legacy and provider-prefixed inventory runs through one canonical operation selection', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$legacyRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$providerPrefixedRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.inventory.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$otherRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant($tenant, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(Operations::class)
->filterTable('type', 'inventory.sync')
->assertCanSeeTableRecords([$legacyRun, $providerPrefixedRun])
->assertCanNotSeeTableRecords([$otherRun]);
});
it('clears tenant-sensitive persisted filters when the canonical tenant context changes', function (): void { it('clears tenant-sensitive persisted filters when the canonical tenant context changes', function (): void {
$tenantA = Tenant::factory()->create(); $tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');

View File

@ -61,3 +61,21 @@
->assertSee('Back to Operations') ->assertSee('Back to Operations')
->assertDontSee('← Back to Archived Tenant'); ->assertDontSee('← Back to Archived Tenant');
}); });
it('resolves legacy operation values to canonical operation metadata on read paths', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_schedule_run',
]);
expect($run->canonicalOperationType())->toBe('backup.schedule.execute');
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(OperationRunLinks::tenantlessView($run))
->assertOk()
->assertSee('Backup schedule run')
->assertDontSee('backup_schedule_run');
});

View File

@ -4,6 +4,7 @@
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
@ -40,3 +41,15 @@
'missing_input', 'missing_input',
], ],
]); ]);
it('keeps governance reason ownership and family semantics explicit', function (): void {
$presenter = app(ReasonPresenter::class);
$semantics = $presenter->semantics(
$presenter->forArtifactTruth(BaselineCompareReasonCode::CoverageUnproven->value, 'artifact_truth'),
);
expect($semantics)->not->toBeNull()
->and($semantics['owner_label'] ?? null)->toBe('Governance detail')
->and($semantics['family_label'] ?? null)->toBe('Coverage')
->and($semantics['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE);
});

View File

@ -11,6 +11,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Evidence\EvidenceSnapshotService; use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\ReasonTranslation\ReasonPresenter;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Livewire; use Livewire\Livewire;
@ -184,6 +186,51 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
->assertDontSee('wire:poll.10s', escape: false); ->assertDontSee('wire:poll.10s', escape: false);
}); });
it('renders translated reason semantics for failed review-pack runs when available', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'tenant.review_pack.generate',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
],
'failure_summary' => [[
'code' => 'operation.failed',
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'message' => 'The provider app is missing a required permission.',
]],
]);
ReviewPack::factory()->failed()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
'operation_run_id' => (int) $run->getKey(),
]);
$reasonSemantics = app(ReasonPresenter::class)->semantics(
app(ReasonPresenter::class)->forOperationRun($run, 'review_pack_widget'),
);
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($run, 'review_pack_widget');
expect($reasonEnvelope)->not->toBeNull()
->and($reasonSemantics)->not->toBeNull();
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee($reasonEnvelope->operatorLabel)
->assertSee($reasonEnvelope->shortExplanation)
->assertSee($reasonSemantics['owner_label'])
->assertSee($reasonSemantics['family_label']);
});
// ─── Expired State ─────────────────────────────────────────── // ─── Expired State ───────────────────────────────────────────
it('shows generate action for an expired pack', function (): void { it('shows generate action for an expired pack', function (): void {

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Reviews\ReviewRegister; use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire; use Livewire\Livewire;
@ -32,6 +33,9 @@
$truth = app(ArtifactTruthPresenter::class)->forTenantReview($review); $truth = app(ArtifactTruthPresenter::class)->forTenantReview($review);
$explanation = $truth->operatorExplanation; $explanation = $truth->operatorExplanation;
$reasonSemantics = app(ReasonPresenter::class)->semantics($truth->reason?->toReasonResolutionEnvelope());
expect($reasonSemantics)->not->toBeNull();
setTenantPanelContext($tenant); setTenantPanelContext($tenant);
@ -39,7 +43,11 @@
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)) ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->assertOk() ->assertOk()
->assertSee($explanation?->headline ?? '') ->assertSee($explanation?->headline ?? '')
->assertSee($explanation?->nextActionText ?? ''); ->assertSee($explanation?->nextActionText ?? '')
->assertSee('Reason owner')
->assertSee($reasonSemantics['owner_label'])
->assertSee('Platform reason family')
->assertSee($reasonSemantics['family_label']);
setAdminPanelContext(); setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);

View File

@ -43,3 +43,11 @@
static fn ($subjectType): bool => $subjectType->domainKey === GovernanceDomainKey::Entra, static fn ($subjectType): bool => $subjectType->domainKey === GovernanceDomainKey::Entra,
))->toBeFalse(); ))->toBeFalse();
}); });
it('finds subject types by subject key across legacy buckets and exposes contributor ownership metadata', function (): void {
$registry = app(GovernanceSubjectTaxonomyRegistry::class);
expect($registry->findBySubjectTypeKey('deviceConfiguration')?->domainKey)->toBe(GovernanceDomainKey::Intune)
->and($registry->findBySubjectTypeKey('assignmentFilter', 'foundation_types')?->subjectClass)->toBe(GovernanceSubjectClass::ConfigurationResource)
->and($registry->ownershipDescriptor()->canonicalNouns)->toBe(['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label']);
});

View File

@ -108,6 +108,7 @@
->and(data_get($rendered, 'snapshot.overallFidelity'))->toBe('partial') ->and(data_get($rendered, 'snapshot.overallFidelity'))->toBe('partial')
->and(data_get($rendered, 'snapshot.overallGapCount'))->toBe(1) ->and(data_get($rendered, 'snapshot.overallGapCount'))->toBe(1)
->and($rendered['summaryRows'])->toHaveCount(3) ->and($rendered['summaryRows'])->toHaveCount(3)
->and(data_get($rendered, 'summaryRows.0.subjectDescriptor.platform_noun'))->toBe('Governed subject')
->and(collect($rendered['groups'])->pluck('label')->all()) ->and(collect($rendered['groups'])->pluck('label')->all())
->toContain('Intune RBAC Role Definition', 'Device Compliance', 'Mystery Policy Type') ->toContain('Intune RBAC Role Definition', 'Device Compliance', 'Mystery Policy Type')
->and(data_get($rendered, 'technicalDetail.defaultCollapsed'))->toBeTrue(); ->and(data_get($rendered, 'technicalDetail.defaultCollapsed'))->toBeTrue();

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
it('normalizes legacy policy_type payloads into governed-subject descriptors', function (): void {
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => 'deviceConfiguration',
], 'baseline_compare');
expect($result->usedLegacyAlias)->toBeTrue()
->and($result->descriptor->domainKey)->toBe(GovernanceDomainKey::Intune->value)
->and($result->descriptor->subjectClass)->toBe(GovernanceSubjectClass::Policy->value)
->and($result->descriptor->subjectTypeKey)->toBe('deviceConfiguration')
->and($result->descriptor->platformNoun)->toBe('Governed subject');
});
it('builds governed-subject descriptors for canonical baseline scope entries', function (): void {
$descriptors = app(PlatformSubjectDescriptorNormalizer::class)->descriptorsForScopeEntries([
[
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
'subject_type_keys' => ['assignmentFilter'],
'filters' => [],
],
]);
expect($descriptors)->toHaveCount(1)
->and(data_get($descriptors, '0.descriptor.subject_type_key'))->toBe('assignmentFilter')
->and(data_get($descriptors, '0.descriptor.domain_key'))->toBe(GovernanceDomainKey::PlatformFoundation->value)
->and(data_get($descriptors, '0.source_surface'))->toBe('baseline_scope');
});
it('falls back to compatibility-only descriptors for unknown subject types', function (): void {
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => 'mysterySubject',
], 'snapshot');
expect($result->usedLegacyAlias)->toBeTrue()
->and($result->descriptor->subjectTypeKey)->toBe('mysterySubject')
->and($result->warnings)->not->toBeEmpty();
});

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Support\Governance\PlatformVocabularyGlossary;
it('publishes canonical term inventory with boundary classifications and retirement metadata', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
expect($glossary->canonicalTerms())
->toContain('governed_subject', 'operation_type', 'policy_type')
->and($glossary->term('governed_subject')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE)
->and($glossary->term('governed_subject')?->legacyAliases)->toContain('policy_type')
->and($glossary->term('governed_subject')?->aliasRetirementPath)->toContain('policy_type');
});
it('prefers exact Intune-specific terms while still resolving platform aliases by context', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
expect($glossary->term('policy_type')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC)
->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject')
->and($glossary->resolveAlias('policy_type', 'intune_adapter'))->toBeNull();
});
it('publishes contributor-facing registry, alias, and reason-namespace inventories', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
$termInventory = $glossary->termInventory();
$aliasInventory = $glossary->aliasRetirementInventory();
$registryInventory = $glossary->registryInventory();
$reasonNamespaceInventory = $glossary->reasonNamespaceInventory();
expect($termInventory['operation_type']['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE)
->and($aliasInventory['governed_subject']['canonical_name'] ?? null)->toBe('governed_subject')
->and($aliasInventory['governed_subject']['legacy_aliases'] ?? [])->toContain('policy_type')
->and($registryInventory['operation_catalog']['canonical_nouns'] ?? [])->toContain('operation_type')
->and($reasonNamespaceInventory['governance.baseline_compare']['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE)
->and($reasonNamespaceInventory['rbac.intune']['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC);
});

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Governance\PlatformVocabularyGlossary;
it('resolves registry ownership descriptors from glossary metadata', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
$descriptor = $glossary->registry('governance_subject_taxonomy_registry');
expect($descriptor)->not->toBeNull()
->and($descriptor?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE)
->and($descriptor?->ownerLayer)->toBe(PlatformVocabularyGlossary::OWNER_PLATFORM_CORE)
->and($descriptor?->sourceClassOrFile)->toBe(GovernanceSubjectTaxonomyRegistry::class)
->and($descriptor?->canonicalNouns)->toBe(['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label']);
});
it('exposes contributor-facing ownership metadata from the taxonomy registry seam', function (): void {
$descriptor = app(GovernanceSubjectTaxonomyRegistry::class)->ownershipDescriptor();
expect($descriptor->registryKey)->toBe('governance_subject_taxonomy_registry')
->and($descriptor->allowedConsumers)->toContain('compare', 'snapshot', 'review')
->and($descriptor->compatibilityNotes)->toContain('legacy policy-type payloads');
});

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
it('resolves legacy operation aliases to a canonical operator meaning', function (): void {
$resolution = OperationCatalog::resolve('inventory_sync');
expect($resolution->canonical->canonicalCode)->toBe('inventory.sync')
->and($resolution->canonical->displayLabel)->toBe('Inventory sync')
->and($resolution->aliasStatus)->toBe('legacy_alias')
->and($resolution->wasLegacyAlias)->toBeTrue()
->and(array_map(static fn ($alias): string => $alias->rawValue, $resolution->aliasesConsidered))
->toContain('inventory_sync', 'provider.inventory.sync');
});
it('deduplicates filter options by canonical operation code while preserving raw aliases for queries', function (): void {
expect(OperationCatalog::filterOptions(['inventory_sync', 'provider.inventory.sync', 'policy.sync']))->toBe([
'inventory.sync' => 'Inventory sync',
'policy.sync' => 'Policy sync',
])->and(OperationCatalog::rawValuesForCanonical('inventory.sync'))
->toContain('inventory_sync', 'provider.inventory.sync');
});
it('maps enum-backed storage values to canonical operation codes', function (): void {
expect(OperationRunType::BaselineCompare->canonicalCode())->toBe('baseline.compare')
->and(OperationRunType::DirectoryGroupsSync->canonicalCode())->toBe('directory.groups.sync');
});
it('publishes contributor-facing operation inventories and alias retirement metadata', function (): void {
$descriptor = OperationCatalog::ownershipDescriptor();
$canonicalInventory = OperationCatalog::canonicalInventory();
$aliasInventory = OperationCatalog::aliasInventory();
expect($descriptor->registryKey)->toBe('operation_catalog')
->and($descriptor->boundaryClassification)->toBe('platform_core')
->and($canonicalInventory['baseline.compare']['display_label'] ?? null)->toBe('Baseline compare')
->and($aliasInventory['baseline_compare']['canonical_name'] ?? null)->toBe('baseline.compare')
->and($aliasInventory['baseline_compare']['retirement_path'] ?? null)->toContain('baseline.compare');
});

View File

@ -29,6 +29,10 @@
expect($envelope)->not->toBeNull() expect($envelope)->not->toBeNull()
->and($envelope?->operatorLabel)->toBe('Dedicated credentials required') ->and($envelope?->operatorLabel)->toBe('Dedicated credentials required')
->and($envelope?->shortExplanation)->toContain('dedicated credentials are configured') ->and($envelope?->shortExplanation)->toContain('dedicated credentials are configured')
->and($envelope?->ownerLayer())->toBe('provider_owned')
->and($envelope?->ownerNamespace())->toBe('provider.microsoft_graph')
->and($envelope?->platformReasonFamily())->toBe('prerequisite')
->and(ProviderReasonCodes::boundaryClassification(ProviderReasonCodes::DedicatedCredentialMissing))->toBe('intune_specific')
->and($envelope?->toLegacyNextSteps()[0]['label'] ?? null)->toBe('Manage dedicated connection') ->and($envelope?->toLegacyNextSteps()[0]['label'] ?? null)->toBe('Manage dedicated connection')
->and($envelope?->toLegacyNextSteps()[0]['url'] ?? null)->toContain('/provider-connections/'); ->and($envelope?->toLegacyNextSteps()[0]['url'] ?? null)->toContain('/provider-connections/');
}); });
@ -39,5 +43,6 @@
expect($envelope)->not->toBeNull() expect($envelope)->not->toBeNull()
->and($envelope?->operatorLabel)->toBe('Provider configuration needs review') ->and($envelope?->operatorLabel)->toBe('Provider configuration needs review')
->and($envelope?->diagnosticCode())->toBe('ext.multiple_defaults_detected') ->and($envelope?->diagnosticCode())->toBe('ext.multiple_defaults_detected')
->and($envelope?->ownerLayer())->toBe('provider_owned')
->and($envelope?->guidanceText())->toBe('Next step: Review the provider connection before retrying.'); ->and($envelope?->guidanceText())->toBe('Next step: Review the provider connection before retrying.');
}); });

View File

@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\RbacReason; use App\Support\RbacReason;
it('translates manual RBAC assignment reasons into operator guidance', function (): void { it('translates manual RBAC assignment reasons into operator guidance', function (): void {
@ -10,6 +11,10 @@
expect($envelope->operatorLabel)->toBe('Manual role assignment required') expect($envelope->operatorLabel)->toBe('Manual role assignment required')
->and($envelope->actionability)->toBe('prerequisite_missing') ->and($envelope->actionability)->toBe('prerequisite_missing')
->and($envelope->shortExplanation)->toContain('manual Intune RBAC role assignment') ->and($envelope->shortExplanation)->toContain('manual Intune RBAC role assignment')
->and($envelope->ownerLayer())->toBe('domain_owned')
->and($envelope->ownerNamespace())->toBe('rbac.intune')
->and($envelope->platformReasonFamily())->toBe('authorization')
->and(RbacReason::ManualAssignmentRequired->boundaryClassification())->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC)
->and($envelope->guidanceText())->toBe('Next step: Complete the Intune role assignment manually, then refresh RBAC status.'); ->and($envelope->guidanceText())->toBe('Next step: Complete the Intune role assignment manually, then refresh RBAC status.');
}); });
@ -18,5 +23,6 @@
expect($envelope->actionability)->toBe('non_actionable') expect($envelope->actionability)->toBe('non_actionable')
->and($envelope->operatorLabel)->toBe('RBAC API unsupported') ->and($envelope->operatorLabel)->toBe('RBAC API unsupported')
->and($envelope->ownerNamespace())->toBe('rbac.intune')
->and($envelope->guidanceText())->toBe('No action needed.'); ->and($envelope->guidanceText())->toBe('No action needed.');
}); });

View File

@ -4,6 +4,8 @@
use App\Support\ReasonTranslation\FallbackReasonTranslator; use App\Support\ReasonTranslation\FallbackReasonTranslator;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
it('renders body lines and legacy next steps from the shared envelope', function (): void { it('renders body lines and legacy next steps from the shared envelope', function (): void {
@ -38,3 +40,25 @@
->and($envelope?->shortExplanation)->toContain('transient dependency issue') ->and($envelope?->shortExplanation)->toContain('transient dependency issue')
->and($envelope?->guidanceText())->toBe('Next step: Retry after the dependency recovers.'); ->and($envelope?->guidanceText())->toBe('Next step: Retry after the dependency recovers.');
}); });
it('round-trips explicit reason ownership and platform-family metadata', function (): void {
$envelope = new ReasonResolutionEnvelope(
internalCode: 'provider_consent_missing',
operatorLabel: 'Admin consent required',
shortExplanation: 'The provider connection cannot continue until admin consent is granted.',
actionability: 'prerequisite_missing',
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: 'provider_owned',
ownerNamespace: 'provider.microsoft_graph',
reasonCode: 'provider_consent_missing',
platformReasonFamily: PlatformReasonFamily::Prerequisite,
),
);
$restored = ReasonResolutionEnvelope::fromArray($envelope->toArray());
expect($restored)->not->toBeNull()
->and($restored?->ownerLayer())->toBe('provider_owned')
->and($restored?->ownerNamespace())->toBe('provider.microsoft_graph')
->and($restored?->platformReasonFamily())->toBe(PlatformReasonFamily::Prerequisite->value);
});

View File

@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Tenants\TenantOperabilityReasonCode; use App\Support\Tenants\TenantOperabilityReasonCode;
it('marks already archived tenant states as non-actionable while preserving diagnostics', function (): void { it('marks already archived tenant states as non-actionable while preserving diagnostics', function (): void {
@ -18,5 +19,6 @@
expect($envelope->operatorLabel)->toBe('Permission required') expect($envelope->operatorLabel)->toBe('Permission required')
->and($envelope->shortExplanation)->toContain('missing the capability') ->and($envelope->shortExplanation)->toContain('missing the capability')
->and(TenantOperabilityReasonCode::MissingCapability->boundaryClassification())->toBe(PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE)
->and($envelope->guidanceText())->toBe('Next step: Ask a tenant Owner to grant the required capability.'); ->and($envelope->guidanceText())->toBe('Next step: Ask a tenant Owner to grant the required capability.');
}); });

View File

@ -0,0 +1,37 @@
# Specification Quality Checklist: Platform Core Vocabulary Hardening
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-13
**Feature**: [specs/204-platform-core-vocabulary-hardening/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation complete on 2026-04-13.
- No placeholders or [NEEDS CLARIFICATION] markers remain.
- The spec intentionally references internal platform concepts such as `OperationRun`, governed-subject discriminators, and registry ownership because they are required by the repository constitution; it does not prescribe implementation-specific code structure.
- Vocabulary hardening is explicitly bounded to platform-core and platform-near surfaces so the spec does not become a repo-wide rename sweep.

View File

@ -0,0 +1,536 @@
openapi: 3.1.0
info:
title: Platform Core Vocabulary Hardening Internal Contract
version: 0.1.0
summary: Internal logical contract for canonical platform vocabulary, operation type resolution, reason ownership translation, registry ownership lookup, and platform subject normalization
description: |
This contract is an internal planning artifact for Spec 204. The affected
surfaces continue to render through existing Laravel services, jobs,
presenters, Filament resources, pages, and widgets. The paths below are
logical boundary identifiers only; they do not imply new HTTP routes or
controllers. The consumer inventory below is an implementation-consumer
traceability list, not a second operator-surface declaration table. The
authoritative operator-facing route and surface inventory lives in
spec.md.
x-logical-artifact: true
x-platform-core-vocabulary-consumers:
- surface: governance.platform_vocabulary
sourceFiles:
- apps/platform/app/Support/Governance/GovernanceDomainKey.php
- apps/platform/app/Support/Governance/GovernanceSubjectClass.php
- apps/platform/app/Support/Governance/GovernanceSubjectTaxonomyRegistry.php
- apps/platform/app/Support/Baselines/BaselineScope.php
mustConsume:
- canonical_platform_terms
- explicit_ownership_boundaries
- forbidden_false_universal_aliases
- surface: operations.rendering
sourceFiles:
- apps/platform/app/Support/OperationCatalog.php
- apps/platform/app/Support/OperationRunType.php
- apps/platform/app/Filament/Resources/OperationRunResource.php
- apps/platform/app/Filament/System/Pages/Ops/Runs.php
mustConsume:
- canonical_operation_resolution
- legacy_alias_compatibility
- stable_filter_labels
- surface: operations.dashboard_widgets
sourceFiles:
- apps/platform/app/Filament/Pages/TenantDashboard.php
- apps/platform/app/Filament/Widgets/Dashboard/RecentOperations.php
- apps/platform/app/Filament/System/Pages/Dashboard.php
- apps/platform/app/Filament/System/Widgets/ControlTowerRecentFailures.php
- apps/platform/app/Filament/System/Widgets/ControlTowerTopOffenders.php
mustConsume:
- canonical_operation_resolution
- legacy_alias_compatibility
- stable_filter_labels
- surface: reasons.translation
sourceFiles:
- apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php
- apps/platform/app/Support/ReasonTranslation/ReasonResolutionEnvelope.php
- apps/platform/app/Support/Providers/ProviderReasonTranslator.php
- apps/platform/app/Filament/Pages/BaselineCompareLanding.php
- apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
- apps/platform/app/Filament/Resources/OperationRunResource.php
mustConsume:
- explicit_reason_owner
- platform_reason_family
- existing_operator_explanation_fields
- surface: baselines.platform_subjects
sourceFiles:
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
- apps/platform/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php
- apps/platform/app/Support/Baselines/Compare/CompareSubjectResult.php
- apps/platform/app/Support/Filament/FilterOptionCatalog.php
mustConsume:
- platform_subject_descriptor
- legacy_policy_type_fallback_only
- governed_subject_fields_first
- surface: evidence.rendering
sourceFiles:
- apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php
mustConsume:
- platform_subject_descriptor
- legacy_policy_type_fallback_only
- governed_subject_fields_first
- surface: registry.ownership
sourceFiles:
- apps/platform/app/Support/Governance/GovernanceSubjectTaxonomyRegistry.php
- apps/platform/app/Support/OperationCatalog.php
- apps/platform/app/Support/Providers/ProviderReasonCodes.php
- apps/platform/config/tenantpilot.php
mustConsume:
- registry_owner_layer
- canonical_noun_authority
- compatibility_only_markers
- surface: reporting.summaries
sourceFiles:
- apps/platform/app/Services/ReviewPackService.php
- apps/platform/app/Services/TenantReviews/TenantReviewService.php
- apps/platform/app/Filament/Resources/TenantReviewResource.php
- apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php
mustConsume:
- canonical_operation_resolution
- explicit_reason_owner
- platform_subject_descriptor
- surface: operations.launch_surfaces
sourceFiles:
- apps/platform/app/Filament/Resources/ProviderConnectionResource.php
- apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php
- apps/platform/app/Filament/Resources/BackupScheduleResource.php
- apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
mustConsume:
- canonical_operation_resolution
- legacy_alias_compatibility
- stable_filter_labels
paths:
/internal/governance/platform-vocabulary/terms/{termKey}:
get:
summary: Resolve one canonical platform term and its ownership boundary
operationId: getPlatformVocabularyTerm
parameters:
- name: termKey
in: path
required: true
schema:
type: string
responses:
'200':
description: Canonical platform term definition resolved successfully
content:
application/vnd.tenantatlas.platform-vocabulary-term+json:
schema:
$ref: '#/components/schemas/PlatformVocabularyTerm'
'404':
description: The term is unknown to the maintained platform glossary
/internal/governance/registries/{registryKey}/ownership:
get:
summary: Resolve ownership and canonical vocabulary authority for a registry or catalog
operationId: getRegistryOwnershipDescriptor
parameters:
- name: registryKey
in: path
required: true
schema:
type: string
responses:
'200':
description: Ownership and canonical usage rules for the registry
content:
application/vnd.tenantatlas.registry-ownership-descriptor+json:
schema:
$ref: '#/components/schemas/RegistryOwnershipDescriptor'
'404':
description: Unknown registry key
/internal/operations/types/resolve:
post:
summary: Resolve a raw stored operation type into one canonical operation vocabulary record
operationId: resolveCanonicalOperationType
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OperationTypeResolutionRequest'
responses:
'200':
description: Canonical operation type resolved successfully
content:
application/vnd.tenantatlas.operation-type-resolution+json:
schema:
$ref: '#/components/schemas/OperationTypeResolution'
'422':
description: Raw operation type is unknown or ambiguous
/internal/reasons/translate:
post:
summary: Translate a reason code into an operator-safe envelope with explicit ownership and platform family metadata
operationId: translateReasonWithOwnership
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ReasonTranslationRequest'
responses:
'200':
description: Reason translated successfully
content:
application/vnd.tenantatlas.translated-reason+json:
schema:
$ref: '#/components/schemas/TranslatedReasonEnvelope'
'422':
description: Reason owner or code is unknown to the translation boundary
/internal/platform-subjects/normalize:
post:
summary: Normalize a platform-near payload into a governed subject descriptor without assuming universal Intune nouns
operationId: normalizePlatformSubjectDescriptor
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PlatformSubjectNormalizationRequest'
responses:
'200':
description: Platform subject descriptor resolved successfully
content:
application/vnd.tenantatlas.platform-subject-descriptor+json:
schema:
$ref: '#/components/schemas/SubjectDescriptorNormalizationResult'
'422':
description: The payload cannot be normalized into a governed subject descriptor
components:
schemas:
PlatformVocabularyTermOwnerLayer:
type: string
enum:
- platform_core
- domain_owned
- provider_owned
- compatibility_alias
RegistryOwnershipOwnerLayer:
type: string
enum:
- platform_core
- domain_owned
- provider_owned
- compatibility_only
VocabularyBoundaryClassification:
type: string
enum:
- platform_core
- cross_domain_governance
- intune_specific
PlatformReasonFamily:
type: string
enum:
- authorization
- prerequisite
- compatibility
- coverage
- availability
- execution
PlatformVocabularyTerm:
type: object
additionalProperties: false
required:
- term_key
- canonical_label
- canonical_description
- boundary_classification
- owner_layer
- allowed_contexts
- legacy_aliases
- alias_retirement_path
- forbidden_platform_aliases
properties:
term_key:
type: string
canonical_label:
type: string
canonical_description:
type: string
boundary_classification:
$ref: '#/components/schemas/VocabularyBoundaryClassification'
owner_layer:
$ref: '#/components/schemas/PlatformVocabularyTermOwnerLayer'
allowed_contexts:
type: array
items:
type: string
legacy_aliases:
type: array
items:
type: string
alias_retirement_path:
type:
- string
- 'null'
forbidden_platform_aliases:
type: array
items:
type: string
RegistryOwnershipDescriptor:
type: object
additionalProperties: false
required:
- registry_key
- boundary_classification
- owner_layer
- source_class_or_file
- canonical_nouns
- allowed_consumers
properties:
registry_key:
type: string
boundary_classification:
$ref: '#/components/schemas/VocabularyBoundaryClassification'
owner_layer:
$ref: '#/components/schemas/RegistryOwnershipOwnerLayer'
source_class_or_file:
type: string
canonical_nouns:
type: array
items:
type: string
allowed_consumers:
type: array
items:
type: string
compatibility_notes:
type:
- string
- 'null'
CanonicalOperationType:
type: object
additionalProperties: false
required:
- canonical_code
- artifact_family
- display_label
- supports_operator_explanation
properties:
canonical_code:
type: string
domain_key:
type:
- string
- 'null'
artifact_family:
type: string
display_label:
type: string
supports_operator_explanation:
type: boolean
expected_duration_seconds:
type:
- integer
- 'null'
OperationTypeAlias:
type: object
additionalProperties: false
required:
- raw_value
- canonical_code
- alias_status
- write_allowed
properties:
raw_value:
type: string
canonical_code:
type: string
alias_status:
type: string
enum:
- canonical
- legacy_alias
- deprecated_alias
write_allowed:
type: boolean
deprecation_note:
type:
- string
- 'null'
retirement_path:
type:
- string
- 'null'
OperationTypeResolutionRequest:
type: object
additionalProperties: false
required:
- raw_value
- source_surface
properties:
raw_value:
type: string
source_surface:
type: string
context:
type: object
additionalProperties: true
OperationTypeResolution:
type: object
additionalProperties: false
required:
- raw_value
- canonical
- aliases_considered
- alias_status
- was_legacy_alias
properties:
raw_value:
type: string
canonical:
$ref: '#/components/schemas/CanonicalOperationType'
aliases_considered:
type: array
items:
$ref: '#/components/schemas/OperationTypeAlias'
alias_status:
type: string
enum:
- canonical
- legacy_alias
- deprecated_alias
was_legacy_alias:
type: boolean
ReasonOwnershipDescriptor:
type: object
additionalProperties: false
required:
- owner_layer
- owner_namespace
- reason_code
- platform_reason_family
properties:
owner_layer:
type: string
enum:
- platform_core
- domain_owned
- provider_owned
owner_namespace:
type: string
reason_code:
type: string
platform_reason_family:
$ref: '#/components/schemas/PlatformReasonFamily'
ReasonTranslationRequest:
type: object
additionalProperties: false
required:
- owner_namespace
- reason_code
properties:
owner_namespace:
type: string
reason_code:
type: string
operator_context:
type: object
additionalProperties: true
diagnostics_context:
type: object
additionalProperties: true
TranslatedReasonEnvelope:
type: object
additionalProperties: false
required:
- reason_owner
- operator_label
- explanation
- actionability
- next_steps
properties:
reason_owner:
$ref: '#/components/schemas/ReasonOwnershipDescriptor'
operator_label:
type: string
explanation:
type: string
actionability:
type: string
next_steps:
type: array
items:
type: string
diagnostic_label:
type:
- string
- 'null'
trust_impact:
type:
- string
- 'null'
absence_pattern:
type:
- string
- 'null'
PlatformSubjectDescriptor:
type: object
additionalProperties: false
required:
- domain_key
- subject_class
- subject_type_key
- subject_type_label
- platform_noun
- display_label
- owner_layer
properties:
domain_key:
type: string
subject_class:
type: string
subject_type_key:
type: string
subject_type_label:
type: string
platform_noun:
type: string
display_label:
type: string
legacy_policy_type:
type:
- string
- 'null'
owner_layer:
type: string
enum:
- platform_core
- domain_owned
- provider_owned
PlatformSubjectNormalizationRequest:
type: object
additionalProperties: false
required:
- source_surface
- raw_payload
properties:
source_surface:
type: string
raw_payload:
type: object
additionalProperties: true
SubjectDescriptorNormalizationResult:
type: object
additionalProperties: false
required:
- descriptor
- source_surface
- used_legacy_alias
- warnings
properties:
descriptor:
$ref: '#/components/schemas/PlatformSubjectDescriptor'
source_surface:
type: string
used_legacy_alias:
type: boolean
warnings:
type: array
description: Array of string warning messages emitted during normalization.
items:
type: string

View File

@ -0,0 +1,247 @@
# Data Model: Platform Core Vocabulary Hardening
## Overview
This feature introduces no new top-level persisted entity and no new mandatory database table. It formalizes a small set of internal platform contracts that clarify ownership, canonical naming, alias handling, and platform-facing subject descriptors across the existing governance, operation, and reason-translation seams.
## Existing Persisted Truth Reused Without Change
### Operation truth
- `operation_runs.type`
- `operation_runs.context`
- current run summary, monitoring, notification, and audit projections
These remain the persisted record of what ran. Spec 204 changes how platform code resolves and presents operation meaning, not the existence of those records.
### Governance and baseline truth
- `baseline_profiles.scope_jsonb`
- `baseline_snapshots`
- `baseline_snapshot_items`
- current findings and evidence payloads
- canonical Baseline Scope V2 from Spec 202
These remain the reference truth for platform-near compare and snapshot surfaces.
### Domain-owned provider and policy truth
- Intune policy records and policy versions
- backup and inventory items with Intune-native metadata
- Graph-facing provider payloads and config-backed Intune policy-type catalogs
These remain intentionally domain-owned and may continue to use Intune-native terminology such as `policy_type` where that ownership is explicit.
## New Internal Contracts
### VocabularyBoundaryClassification
**Type**: internal enum
**Purpose**: give contributors one explicit three-way classification for touched concepts
| Value | Meaning |
|------|---------|
| `platform_core` | Core product vocabulary owned by the platform itself |
| `cross_domain_governance` | Governance vocabulary shared across domains and workflows |
| `intune_specific` | Vocabulary that remains intentionally specific to the Intune domain |
### PlatformVocabularyTerm
**Type**: internal governance record
**Purpose**: define one canonical platform noun or phrase and its ownership boundary
| Field | Type | Notes |
|------|------|-------|
| `term_key` | string | Stable internal identifier for the term |
| `canonical_label` | string | Preferred operator-safe platform label |
| `canonical_description` | string | Maintained description of what the term means in platform context |
| `boundary_classification` | string | `platform_core`, `cross_domain_governance`, or `intune_specific` |
| `owner_layer` | string | `platform_core`, `domain_owned`, `provider_owned`, or `compatibility_alias` |
| `allowed_contexts` | array<string> | Surfaces or layers where the term is valid |
| `legacy_aliases` | array<string> | Historical names still recognized for compatibility |
| `alias_retirement_path` | string or `null` | Documented path for retiring any legacy alias once rollout stabilizes |
| `forbidden_platform_aliases` | array<string> | Names that must not be used as universal platform vocabulary |
### RegistryOwnershipDescriptor
**Type**: internal governance record
**Purpose**: describe whether a registry or catalog is canonical platform vocabulary, domain-owned vocabulary, or compatibility-only
| Field | Type | Notes |
|------|------|-------|
| `registry_key` | string | Stable internal identifier |
| `boundary_classification` | string | `platform_core`, `cross_domain_governance`, or `intune_specific` |
| `owner_layer` | string | `platform_core`, `domain_owned`, `provider_owned`, or `compatibility_only` |
| `source_class_or_file` | string | Class or config path that owns the registry |
| `canonical_nouns` | array<string> | Terms this registry defines authoritatively |
| `allowed_consumers` | array<string> | Surfaces allowed to consume the registry as-is |
| `compatibility_notes` | string or `null` | Transitional notes where legacy or domain-specific terms remain exposed |
### CanonicalOperationType
**Type**: internal operation catalog record
**Purpose**: describe one canonical platform operation code
| Field | Type | Notes |
|------|------|-------|
| `canonical_code` | string | Preferred platform operation code |
| `domain_key` | string or `null` | Optional domain grouping when the operation belongs to a specific subject family |
| `artifact_family` | string | Existing operation artifact grouping |
| `display_label` | string | Preferred operator-facing label |
| `supports_operator_explanation` | boolean | Mirrors existing catalog behavior |
| `expected_duration_seconds` | integer or `null` | Existing duration hint |
### OperationTypeAlias
**Type**: internal compatibility record
**Purpose**: map one stored or historical operation type value onto one canonical operation type
| Field | Type | Notes |
|------|------|-------|
| `raw_value` | string | Stored or historical operation type value |
| `canonical_code` | string | Target canonical operation code |
| `alias_status` | string | `canonical`, `legacy_alias`, or `deprecated_alias` |
| `write_allowed` | boolean | Whether new writes may still emit this raw value |
| `deprecation_note` | string or `null` | Optional explanation for reviewers or maintainers |
| `retirement_path` | string or `null` | Required rollout note describing how and when the alias stops being writable or supported |
### OperationTypeResolution
**Type**: internal derived value object
**Purpose**: represent resolved operation meaning for monitoring, filters, notifications, audit prose, and run detail surfaces
| Field | Type | Notes |
|------|------|-------|
| `raw_value` | string | Original stored value |
| `canonical` | `CanonicalOperationType` | Resolved canonical operation record |
| `aliases_considered` | array<`OperationTypeAlias`> | Alias records considered during normalization |
| `alias_status` | string | Current alias state |
| `was_legacy_alias` | boolean | Convenience flag for diagnostics and test assertions |
### PlatformReasonFamily
**Type**: internal enum
**Purpose**: classify translated reasons into one platform-owned family without changing domain-owned reason codes
| Value | Meaning |
|------|---------|
| `authorization` | Access or RBAC boundary prevented the action or view |
| `prerequisite` | Required tenant, workspace, or configuration precondition is missing |
| `compatibility` | The requested workflow or subject family is not supported together |
| `coverage` | Evidence or scope coverage is insufficient to make a trustworthy claim |
| `availability` | The referenced object, source, or provider data is absent or unavailable |
| `execution` | Runtime execution failed or degraded |
### ReasonOwnershipDescriptor
**Type**: internal derived record
**Purpose**: identify which layer owns the underlying reason code
| Field | Type | Notes |
|------|------|-------|
| `owner_layer` | string | `platform_core`, `domain_owned`, or `provider_owned` |
| `owner_namespace` | string | Stable namespace such as `provider.intune`, `governance.baseline_compare`, `access.rbac`, or `execution.runtime` |
| `reason_code` | string | Original reason code value |
| `platform_reason_family` | string | `PlatformReasonFamily` value |
### TranslatedReasonEnvelopeV2
**Type**: internal extension of the existing `ReasonResolutionEnvelope`
**Purpose**: provide one operator-safe explanation object with explicit ownership and family metadata
| Field | Type | Notes |
|------|------|-------|
| `reason_owner` | `ReasonOwnershipDescriptor` | New ownership metadata |
| `operator_label` | string | Existing translated label |
| `explanation` | string | Existing translated explanation |
| `actionability` | string | Existing actionability field |
| `next_steps` | array<string> | Existing or derived remediation hints |
| `diagnostic_label` | string or `null` | Existing technical summary |
| `trust_impact` | string or `null` | Existing trust impact summary |
| `absence_pattern` | string or `null` | Existing absence classification where relevant |
### PlatformSubjectDescriptor
**Type**: internal derived value object
**Purpose**: normalize platform-near subject meaning without assuming universal Intune policy nouns
| Field | Type | Notes |
|------|------|-------|
| `domain_key` | string | Canonical governance domain |
| `subject_class` | string | Canonical subject class |
| `subject_type_key` | string | Domain-owned subject family key |
| `subject_type_label` | string | Operator-facing subject family label |
| `platform_noun` | string | Preferred platform noun for the object |
| `display_label` | string | Subject label used on touched UI surfaces |
| `legacy_policy_type` | string or `null` | Optional legacy Intune discriminator retained only for compatibility or diagnostics |
| `owner_layer` | string | Usually `platform_core` for the descriptor itself even when the underlying subject is Intune-owned |
### SubjectDescriptorNormalizationResult
**Type**: internal derived record
**Purpose**: report how a platform-near raw payload was normalized into a `PlatformSubjectDescriptor`
| Field | Type | Notes |
|------|------|-------|
| `descriptor` | `PlatformSubjectDescriptor` | Required normalized descriptor |
| `source_surface` | string | Run detail, snapshot rendering, compare summary, evidence rendering, or similar |
| `used_legacy_alias` | boolean | Whether normalization had to fall back to `policy_type` or another legacy discriminator |
| `warnings` | array<string> | Non-fatal compatibility warnings for diagnostics |
## Relationships
- One `PlatformVocabularyTerm` may describe many `RegistryOwnershipDescriptor` or `PlatformSubjectDescriptor` contracts.
- One `CanonicalOperationType` may have many `OperationTypeAlias` records.
- One `OperationTypeResolution` is produced from exactly one raw operation type value and one resolved `CanonicalOperationType`.
- One `ReasonOwnershipDescriptor` classifies one underlying reason code and feeds one `TranslatedReasonEnvelopeV2`.
- One `SubjectDescriptorNormalizationResult` produces exactly one `PlatformSubjectDescriptor` for one touched platform-near payload.
- One registry or catalog should have exactly one `RegistryOwnershipDescriptor` to keep ownership explicit.
## Validation Rules
### Platform glossary and registry ownership
1. Every canonical platform term must have exactly one `owner_layer`.
2. Every canonical platform term must have exactly one explicit `boundary_classification`.
3. A term marked `compatibility_alias` cannot be the primary label on new platform surfaces.
4. A registry marked `domain_owned` or `provider_owned` cannot be treated as a universal platform glossary without an explicit wrapper or translation step.
5. Any term with one or more `legacy_aliases` must also define an `alias_retirement_path`.
### Operation vocabulary
1. Every stored operation type value consumed by touched platform surfaces must resolve to exactly one canonical operation code.
2. New writes on touched flows must not introduce a raw value marked `deprecated_alias`.
3. Monitoring, filters, notifications, and audit prose must render canonical labels through `OperationTypeResolution` rather than raw strings.
### Reason ownership
1. Every translated operator reason must carry explicit `reason_owner` metadata.
2. A `platform_reason_family` must be derived without renaming the original domain-owned code.
3. Platform surfaces may summarize by family, but diagnostics must preserve the original owner namespace and code.
### Platform subject descriptors
1. Every touched platform-near compare, snapshot, or review payload must provide a `PlatformSubjectDescriptor` or enough source data to derive one.
2. `legacy_policy_type` may be carried only as secondary compatibility data.
3. New or updated platform-facing summary labels must prefer `platform_noun`, `subject_type_label`, or `display_label` over raw `policy_type`.
## Transition Rules
### Operation type transition
1. Existing stored raw values remain readable.
2. Canonical resolution happens at read and presentation time for touched surfaces.
3. New or updated platform flows touched by this spec must emit canonical operation codes on new writes.
4. Legacy aliases remain documented and test-covered as read-only compatibility paths for historical data and untouched flows until intentionally retired.
### Reason translation transition
1. Existing domain reason codes remain unchanged.
2. Ownership and family metadata are added at translation time.
3. Existing operator-safe explanation fields remain the primary rendering contract.
### Platform-near subject transition
1. Existing persisted baseline and evidence truths remain intact.
2. Wrapper or presenter normalization adds `PlatformSubjectDescriptor` semantics first.
3. Legacy `policy_type` remains available only for compatibility, diagnostics, or Intune-owned surfaces.

View File

@ -0,0 +1,289 @@
# Implementation Plan: Platform Core Vocabulary Hardening
**Branch**: `204-platform-core-vocabulary-hardening` | **Date**: 2026-04-13 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/204-platform-core-vocabulary-hardening/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/204-platform-core-vocabulary-hardening/spec.md`
**Note**: This plan hardens platform-core and platform-near vocabulary by extending the existing governance, operation, and reason-translation layers already in the codebase, rather than introducing a parallel framework or broad rename sweep.
## Summary
Reuse the existing `App\Support\Governance`, `App\Support\OperationCatalog`, `App\Support\OperationRunType`, and `App\Support\ReasonTranslation` seams to define one maintained code-side platform vocabulary glossary, resolve mixed stored operation type codes through one canonical domain-aware operation vocabulary, classify platform reason families separately from domain-owned reason codes, and harden platform-near compare, snapshot, monitoring, review, and reporting contracts to prefer governed-subject descriptors over false-universal `policy_type` wording. Keep Intune-owned tables, Graph contracts, and adapter-local metadata intentionally Intune-specific, preserve historical run and filter compatibility through alias resolution instead of a mass rewrite, and add regression guards so touched platform surfaces cannot drift back into implicit Intune-as-universal semantics.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces
**Storage**: PostgreSQL via existing `operation_runs.type`, `operation_runs.context`, `baseline_profiles.scope_jsonb`, `baseline_snapshot_items`, findings, evidence payloads, and current config-backed registries; no new top-level tables planned
**Testing**: Pest unit, feature, architecture, and focused Filament Livewire tests run through Laravel Sail
**Target Platform**: Laravel monolith web application under `apps/platform` with queue-backed operations and Filament admin or operator surfaces
**Project Type**: web application in a monorepo (`apps/platform` plus `apps/website`)
**Performance Goals**: Keep operation label and reason translation resolution fully in-process, avoid new remote calls on monitoring or review pages, preserve current operation list and filter responsiveness, and avoid request-path schema rewrites or query fan-out caused by vocabulary hardening
**Constraints**: No repo-wide rename sweep, no fake generic rewrite of Intune adapters, no new panel or provider, no new assets, no break in historical run filtering or labeling, no change to current 404 or 403 semantics, keep compatibility explicitly transitional, and prefer wrapper or alias hardening over broad schema churn
**Scale/Scope**: One existing governance namespace, one existing operation vocabulary seam, one existing reason-translation seam, several monitoring or review surfaces, a small set of platform-near compare and snapshot contracts, optional targeted platform-owned persistence wrappers, and focused regression coverage across monitoring, reason translation, and baseline compare or snapshot surfaces
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing with narrow proportionality exceptions documented below.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | The feature hardens vocabulary only; inventory, snapshots, and external Microsoft truth sources remain unchanged. |
| Read/write separation | PASS | PASS | Most work is read-path and presentation-path hardening. Any touched persisted write path stays on existing model or service flows and keeps existing audit and authorization behavior. |
| Graph contract path | PASS | PASS | No new Microsoft Graph path or `graph_contracts.php` entry is introduced. Intune-owned Graph terminology remains in the adapter layer. |
| Deterministic capabilities | PASS | PASS | Existing taxonomy, compare strategy, and operation catalog seams remain deterministic and testable. Any new vocabulary mapping is code-side and snapshot-testable. |
| Workspace + tenant isolation | PASS | PASS | Monitoring, compare, and review surfaces keep current workspace and tenant scope enforcement. Vocabulary hardening must not expose hidden records through labels or explanations. |
| RBAC-UX authorization semantics | PASS | PASS | No new authorization plane or capability family is added. Non-members remain `404`, in-scope capability denials remain `403`, and no new raw capability strings are introduced. |
| Run observability / Ops-UX | PASS | PASS | Existing `OperationRun` lifecycle, summary counts, notifications, and DB-only monitoring rules remain unchanged. Operation type hardening is alias and presentation aware, not a run-lifecycle redesign. |
| Data minimization | PASS | PASS | No new persisted entity or external payload is introduced. Existing JSON context and derived presentation payloads are hardened in place. |
| Proportionality / anti-bloat | PASS | PASS | The plan extends existing governance, operation, and reason translation seams instead of introducing a second registry framework or presentation pipeline. |
| No premature abstraction | PASS | PASS | The glossary, alias map, and reason-family classification are narrow extensions to already-existing abstractions rather than brand-new plugin points. |
| Persisted truth / behavioral state | PASS | PASS | No new top-level persisted truth is added. Any touched reason-family or operation-type classification remains derived unless a platform-owned field cannot be safely wrapped. |
| UI semantics / few layers | PASS | PASS | Reason ownership and canonical labels are derived through the existing translation and catalog layers. The plan does not add a second operator-semantics framework. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched UI surfaces remain on existing Filament v5 + Livewire v4 resources, pages, widgets, and detail views. |
| Provider registration location | PASS | PASS | No panel or provider change is required. Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No new globally searchable resource is introduced. Touched resources continue their current search behavior and existing view-detail capability. |
| Destructive action safety | PASS | PASS | No new destructive action is introduced. Existing destructive actions remain unchanged and must keep confirmation plus authorization. |
| Asset strategy | PASS | PASS | No new global or on-demand assets are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged. |
## Filament-Specific Compliance Notes
- **Livewire v4.0+ compliance**: The touched monitoring and review surfaces stay on Filament v5 + Livewire v4 and the plan introduces no legacy API usage.
- **Provider registration location**: No new panel or provider is required; `bootstrap/providers.php` remains the only relevant provider registration location.
- **Global search**: No new globally searchable resource is added. `OperationRunResource` already has a detail surface, and touched baseline surfaces keep their existing view or search posture.
- **Destructive actions**: This feature adds no new destructive action. Existing destructive actions must retain `->requiresConfirmation()` and server-side authorization.
- **Asset strategy**: No asset registration changes are planned. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
- **Testing plan**: Extend unit coverage for glossary and alias resolution, extend reason-translation and architecture guard coverage for ownership boundaries, extend monitoring and Filament operation-run coverage for canonical labels and filter continuity, and extend baseline snapshot or compare coverage for platform-near subject vocabulary hardening.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/204-platform-core-vocabulary-hardening/research.md`.
Key decisions:
- Keep the maintained glossary inside the existing `App\Support\Governance` namespace rather than creating a second documentation-only or config-only source of truth.
- Treat existing stored operation type codes as compatibility aliases and resolve them through one canonical domain-aware operation vocabulary contract.
- Evolve `OperationCatalog` into the central canonical operation registry rather than introducing a parallel catalog.
- Keep `ProviderReasonCodes`, `RbacReason`, and `BaselineCompareReasonCode` domain-owned and derive platform reason families at the existing `ReasonTranslation` boundary.
- Prefer governed-subject descriptors built from `subject_type_key` and `subject_type_label` on platform-owned or platform-near payloads while preserving Intune-owned `policy_type` where the object is truly adapter-owned.
- Use wrapper-first and alias-first hardening for platform-owned JSON and presentation contracts before considering any schema change.
- Limit registry hardening to explicit ownership descriptors around current registries instead of building a universal plugin system.
- Add regression guards that prevent false-universal Intune vocabulary from reappearing on touched platform surfaces.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/204-platform-core-vocabulary-hardening/`:
- `research.md`: architecture decisions and rejected alternatives for vocabulary ownership, operation types, reason families, and platform-near discriminator hardening
- `data-model.md`: glossary, operation alias resolution, platform reason family, registry ownership, and platform subject descriptor contracts
- `contracts/platform-core-vocabulary-hardening.logical.openapi.yaml`: logical internal contract for glossary lookup, operation type resolution, reason translation, registry ownership lookup, and platform subject descriptor normalization
- `quickstart.md`: implementation and verification sequence for the feature
Design decisions:
- Reuse the current governance namespace for canonical term definitions and ownership descriptors instead of creating a second top-level framework.
- Keep `OperationRunType` and `OperationCatalog` as the existing operation vocabulary seam, but add canonical operation codes, domain grouping, and explicit legacy alias resolution.
- Extend `ReasonResolutionEnvelope` and `ReasonTranslator` so operator-facing explanations carry explicit ownership and platform reason-family metadata without renaming domain reason codes.
- Harden platform-near compare, snapshot, monitoring, and review payloads to prefer `domain_key`, `subject_class`, `subject_type_key`, and `subject_type_label` semantics rather than using `policy_type` as a universal noun.
- Preserve Intune-owned tables, config registries, Graph contracts, and adapter metadata as intentionally Intune-specific.
- Keep compatibility explicitly temporary and visible in code so old names are readable but not treated as equally canonical.
## Project Structure
### Documentation (this feature)
```text
specs/204-platform-core-vocabulary-hardening/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── spec.md
├── contracts/
│ └── platform-core-vocabulary-hardening.logical.openapi.yaml
└── checklists/
└── requirements.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── BaselineCompareLanding.php
│ │ │ └── BaselineCompareMatrix.php
│ │ ├── Resources/
│ │ │ ├── OperationRunResource.php
│ │ │ └── EvidenceSnapshotResource.php
│ │ ├── System/
│ │ │ └── Pages/
│ │ │ ├── Ops/
│ │ │ │ ├── Runs.php
│ │ │ │ ├── Failures.php
│ │ │ │ └── Stuck.php
│ │ │ └── Directory/
│ │ │ ├── ViewTenant.php
│ │ │ └── ViewWorkspace.php
│ │ │ └── Widgets/
│ │ │ ├── ControlTowerRecentFailures.php
│ │ │ └── ControlTowerTopOffenders.php
│ │ └── Widgets/
│ │ └── Dashboard/RecentOperations.php
│ ├── Jobs/
│ │ └── CompareBaselineToTenantJob.php
│ ├── Models/
│ │ └── OperationRun.php
│ ├── Services/
│ │ ├── Audit/
│ │ │ └── AuditEventBuilder.php
│ │ └── Baselines/
│ │ └── SnapshotRendering/
│ │ └── BaselineSnapshotPresenter.php
│ └── Support/
│ ├── Governance/
│ │ ├── GovernanceDomainKey.php
│ │ ├── GovernanceSubjectClass.php
│ │ ├── GovernanceSubjectTaxonomyRegistry.php
│ │ ├── GovernanceSubjectType.php
│ │ ├── PlatformVocabularyGlossary.php
│ │ ├── PlatformVocabularyTerm.php
│ │ ├── RegistryOwnershipDescriptor.php
│ │ ├── PlatformSubjectDescriptor.php
│ │ └── PlatformSubjectDescriptorNormalizer.php
│ ├── Providers/
│ │ ├── ProviderReasonCodes.php
│ │ └── ProviderReasonTranslator.php
│ ├── ReasonTranslation/
│ │ ├── Contracts/
│ │ ├── ReasonResolutionEnvelope.php
│ │ ├── ReasonTranslator.php
│ │ └── ReasonPresenter.php
│ ├── Baselines/
│ │ ├── BaselineCompareReasonCode.php
│ │ ├── BaselineScope.php
│ │ └── Compare/
│ │ ├── CompareSubjectProjection.php
│ │ └── CompareSubjectResult.php
│ ├── Filament/
│ │ └── FilterOptionCatalog.php
│ ├── OperationCatalog.php
│ └── OperationRunType.php
├── config/
│ └── tenantpilot.php
└── tests/
├── Architecture/
│ └── ReasonTranslationPrimarySurfaceGuardTest.php
├── Feature/
│ ├── Authorization/
│ │ └── ReasonTranslationScopeSafetyTest.php
│ ├── Baselines/
│ │ ├── BaselineCompareExecutionGuardTest.php
│ │ ├── BaselineCompareDriftEvidenceContractTest.php
│ │ └── BaselineCompareWhyNoFindingsReasonCodeTest.php
│ ├── Filament/
│ │ ├── OperationRunListFiltersTest.php
│ │ ├── OperationRunEnterpriseDetailPageTest.php
│ │ ├── OperationRunBaselineTruthSurfaceTest.php
│ │ ├── BaselineCompareLandingWhyNoFindingsTest.php
│ │ └── BaselineCompareSummaryConsistencyTest.php
│ ├── Monitoring/
│ │ └── OperationRunResolvedReferencePresentationTest.php
│ └── ReasonTranslation/
│ ├── GovernanceReasonPresentationTest.php
│ └── ReasonTranslationExplanationTest.php
└── Unit/
├── Baselines/
│ └── SnapshotRendering/BaselineSnapshotPresenterTest.php
└── Support/
├── Governance/
│ ├── PlatformVocabularyGlossaryTest.php
│ ├── RegistryOwnershipDescriptorTest.php
│ └── PlatformSubjectDescriptorNormalizerTest.php
├── OperationTypeResolutionTest.php
├── ReasonTranslation/
│ ├── ExecutionDenialReasonTranslationTest.php
│ ├── ProviderReasonTranslationTest.php
│ ├── RbacReasonTranslationTest.php
│ ├── ReasonResolutionEnvelopeTest.php
│ └── TenantOperabilityReasonTranslationTest.php
```
**Structure Decision**: Keep the work inside the existing governance, operation, baseline compare, and reason-translation seams. Add one narrow glossary and ownership layer under `app/Support/Governance` and small extensions to current catalogs or envelopes rather than introducing a second cross-cutting framework. The intended implementation surface is one glossary, one operation-resolution seam, one reason-owner extension, and one subject normalizer; any additional named contracts from the data model should be realized only as small value objects under those seams when keeping them inline would duplicate meaning across multiple touched surfaces.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Code-side platform vocabulary glossary and ownership descriptors | The codebase already has governance and compare vocabulary primitives, but it lacks one maintained source that explains canonical platform terms, registry ownership, and where Intune-native terms remain valid | A spec-only note or scattered inline comments would not keep code, tests, and review surfaces aligned once changes start landing |
| Canonical operation type alias resolution | Historical stored operation codes are mixed between canonical domain-aware names and older underscore names; the platform needs one future-safe vocabulary model without breaking current run history or filters | An immediate rewrite of all persisted operation types is too risky, while leaving the mixed names equally canonical defeats the purpose of the hardening |
| Platform reason-family classification at the translation boundary | Platform surfaces need to distinguish domain-neutral cause families from domain-owned causes without flattening Provider, RBAC, and baseline compare reasons into one misleading universal enum | Renaming every existing domain reason code into a platform-wide code family would add churn, blur ownership, and break current translation paths unnecessarily |
## Proportionality Review
- **Current operator problem**: Monitoring, compare, evidence, and review surfaces still expose platform meaning through a mix of Intune-shaped operation names, reason codes, and subject terms, which makes the product's architecture harder to understand and more error-prone for future work.
- **Existing structure is insufficient because**: Current governance taxonomy, compare strategy, operation catalog, and reason translation seams exist, but they do not yet define one canonical boundary for platform vocabulary ownership, alias handling, or subject descriptor hardening across touched platform surfaces.
- **Narrowest correct implementation**: Extend the existing governance namespace, operation catalog, and reason translation envelope with glossary, ownership, alias, and descriptor metadata; harden only the touched platform-near payloads and surfaces; preserve Intune-owned persistence and catalogs where they are legitimately domain-specific.
- **Implementation preference**: Extend existing seams first and add standalone support types only where they replace repeated array-shape ambiguity across multiple touched surfaces.
- **Ownership cost created**: Ongoing glossary maintenance, explicit alias retirement review, additional architecture and regression tests, and careful review of touched platform-near payloads to keep compatibility temporary.
- **Alternative intentionally rejected**: A broad repo-wide rename or a brand-new platform vocabulary framework. The rename sweep creates churn without enough boundary discipline, and the new framework would over-abstract a problem that current seams can solve more narrowly.
- **Release truth**: current-release platform-boundary clarification with compatibility support, not speculative multi-domain infrastructure
## Implementation Strategy
### Phase A - Canonical Glossary and Ownership Boundaries
- Add a narrow code-side glossary under `app/Support/Governance` that defines canonical platform terms and ownership layers.
- Make the contributor boundary explicit so touched concepts can be classified as `platform_core`, `cross_domain_governance`, or `intune_specific` without relying on historical naming.
- Classify current registries and catalogs as `platform_core`, `cross_domain_governance`, or `intune_specific`, starting with governance taxonomy, operation catalog, provider reason registries, and Intune policy type config.
- Document forbidden false-universal aliases for platform contexts, especially `policy_type` where the surface is not explicitly Intune-owned.
### Phase B - Operation Vocabulary Canonicalization
- Extend `OperationCatalog` and related helpers to resolve one canonical domain-aware operation code from stored or legacy operation type values.
- Update touched run-creating services, jobs, and launch surfaces to emit canonical operation codes on new writes wherever this spec changes the flow.
- Expose canonical `operation_type` on touched read models, summaries, filters, and exports even where persisted storage continues to use `operation_runs.type`.
- Keep `OperationRunType` readable for current services and jobs, but add alias and grouping metadata so monitoring, filters, audit prose, and run detail surfaces render canonical meaning consistently.
- Update touched monitoring and review surfaces to rely on canonical in-process resolution rather than raw stored strings, without introducing render-time external calls or query fan-out.
### Phase C - Reason Ownership and Platform Reason Families
- Extend `ReasonResolutionEnvelope` and `ReasonTranslator` so translated explanations carry explicit ownership and platform reason-family metadata.
- Keep `ProviderReasonCodes`, `RbacReason`, and `BaselineCompareReasonCode` domain-owned and namespaced.
- Ensure operator-facing surfaces show platform reason meaning first and domain-specific cause detail second when both are present.
### Phase D - Platform-Neutral Subject and Contract Hardening
- Harden platform-near compare, snapshot, monitoring, and review payloads to prefer `domain_key`, `subject_class`, `subject_type_key`, and `subject_type_label` descriptors where available.
- Audit platform-owned persisted payloads, especially `operation_runs.context`, compare subcontext payloads, and evidence payloads touched by this spec, and normalize them through wrapper or presenter contracts before any rename is considered.
- Reduce or remove false-universal `policy_type` usage from touched run context, compare payloads, snapshot rendering, and reporting summaries while keeping Intune-owned persistence untouched.
- Prefer wrapper or projection hardening for platform-owned JSON and presenter contracts before any schema change is considered.
### Phase E - Transition, Guardrails, and Verification
- Keep compatibility explicitly temporary with one canonical name per platform concept and documented legacy aliases.
- Add regression and architecture guards so touched platform surfaces cannot reintroduce raw mixed operation codes, false-universal Intune vocabulary, non-in-process resolution paths, or request-time query fan-out on monitoring and review reads.
- Validate no-regression Intune-first behavior across monitoring, compare, evidence, review, and snapshot surfaces touched by the hardening.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Canonical operation aliasing turns into permanent dual vocabulary | High | Medium | Define one canonical operation code per stored alias, make alias status explicit in code, and add tests that new writes on touched flows do not introduce new legacy names. |
| A platform surface still leaks `policy_type` after wrapper hardening | High | Medium | Audit touched compare, snapshot, run-context, and presenter contracts; add feature and architecture tests around those surfaces. |
| Reason-family hardening duplicates or conflicts with existing explanation semantics | Medium | Medium | Extend the current envelope and translator instead of adding a second explanation pipeline, and validate against existing reason-translation and baseline compare explanation tests. |
| Monitoring filters, labels, or audit prose break when operation types are canonicalized | Medium | Medium | Route filters and labels through canonical resolution and extend `OperationRunListFiltersTest`, monitoring presentation tests, and audit-event coverage. |
| The implementation drifts into Intune adapter rewrites or schema churn | Medium | Low | Keep ownership descriptors explicit, prefer wrapper-first hardening, and require a focused proportionality review before any schema change on a platform-owned field. |
## Test Strategy
- Add focused unit coverage for the platform glossary and operation vocabulary alias resolution.
- Extend `tests/Unit/Support/ReasonTranslation/ReasonResolutionEnvelopeTest.php`, `ProviderReasonTranslationTest.php`, and `RbacReasonTranslationTest.php` for ownership and platform reason-family metadata.
- Extend `tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php` so platform surfaces cannot bypass canonical translation or leak raw domain-owned codes as universal platform reasons.
- Extend `tests/Feature/Filament/OperationRunListFiltersTest.php`, `OperationRunEnterpriseDetailPageTest.php`, `OperationRunBaselineTruthSurfaceTest.php`, `RecentOperationsSummaryWidgetTest.php`, `ProviderConnectionsDbOnlyTest.php`, `InventoryItemResourceTest.php`, `BackupScheduleCrudTest.php`, and `OnboardingEntryPointTest.php` for canonical operation labels, grouping, launch-surface wording, and filter continuity.
- Extend `tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php` and `tests/Feature/System/Spec114/ControlTowerDashboardTest.php` so historical and canonical operation codes render the same operator meaning during transition across both monitoring pages and monitoring widgets.
- Extend `tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php` and `ReasonTranslationExplanationTest.php` so platform and domain explanation layering remains explicit.
- Extend `tests/Unit/Baselines/SnapshotRendering/BaselineSnapshotPresenterTest.php`, `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Baselines/BaselineCompareDriftEvidenceContractTest.php`, and `BaselineCompareWhyNoFindingsReasonCodeTest.php` for platform-near subject vocabulary hardening without Intune behavior regression.
- Extend `tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php`, `tests/Feature/TenantReview/TenantReviewExecutivePackTest.php`, `tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `tests/Feature/ReviewPack/ReviewPackGenerationTest.php`, and `tests/Feature/ReviewPack/ReviewPackWidgetTest.php` so reporting and executive-pack summaries honor the same canonical vocabulary and explanation ownership rules.
- The focused completion gate for this spec is the full quickstart verification pack, which includes every suite named in the story-level and continuity test tasks plus the final architecture and authorization guard suites.
- Keep existing compare start-surface, baseline summary, and RBAC-related coverage green so vocabulary hardening does not change authorization or workflow semantics.

View File

@ -0,0 +1,164 @@
# Quickstart: Platform Core Vocabulary Hardening
## Goal
Harden platform-core and platform-near vocabulary so monitoring, compare, snapshot, evidence, review, and reporting surfaces resolve canonical platform meaning through the existing governance, operation, and reason-translation seams while preserving legitimate Intune-owned terminology where ownership is explicit.
## Prerequisites
1. Work on branch `204-platform-core-vocabulary-hardening`.
2. Ensure the platform containers are available:
```bash
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail up -d
```
3. Keep Spec 202 governance taxonomy and Spec 203 compare strategy assumptions available because this feature extends those seams rather than replacing them.
## Recommended Implementation Order
### 1. Lock the current behavior with focused regression coverage
Run the current reason-translation, operation-run, and baseline presentation tests before changing vocabulary resolution:
```bash
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ReasonTranslation/ProviderReasonTranslationTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ReasonTranslation/RbacReasonTranslationTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunListFiltersTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Baselines/SnapshotRendering/BaselineSnapshotPresenterTest.php
```
Add any missing targeted tests for canonical operation aliasing, registry ownership lookup, and platform subject descriptor normalization before moving core resolution logic.
### 2. Introduce the maintained platform glossary and registry ownership descriptors
Add the narrow glossary and ownership descriptors under `app/Support/Governance`.
Cover:
- canonical platform nouns
- explicit three-way boundary classification for platform-core, cross-domain governance, and Intune-specific concepts
- explicit owner layers
- forbidden false-universal aliases
- registry ownership classification for governance taxonomy, operation catalog, provider reason registries, and domain-owned policy catalogs
Do not create a new table or a new top-level framework.
### 3. Canonicalize operation type resolution through the existing operation catalog
Extend `OperationCatalog` and the current operation helpers so touched surfaces resolve one canonical operation code from historical raw values.
Update touched run-creating services and launch surfaces to emit canonical operation codes on new writes wherever this feature changes the flow.
Focus on:
- `OperationRunType`
- `OperationCatalog`
- touched run producers such as compare, capture, evidence, review, inventory, schedule, and directory sync services
- `OperationRunResource`
- monitoring pages and widgets
- audit prose and any run reference presentation helpers
Preserve existing filters and historical run readability during the alias transition.
### 4. Enrich reason translation with explicit ownership and platform reason families
Extend `ReasonResolutionEnvelope` and `ReasonTranslator` so translated operator explanations carry:
- explicit owner layer
- stable owner namespace
- one platform reason family
- the existing explanation, actionability, next steps, and diagnostics fields
Do not rename current domain reason codes.
### 5. Harden platform-near subject descriptors and remove false-universal `policy_type` usage from touched surfaces
Update platform-near compare, snapshot, evidence, and run-context projections so they prefer:
- `domain_key`
- `subject_class`
- `subject_type_key`
- `subject_type_label`
- operator-safe subject labels
Keep `policy_type` only where the owning object is explicitly Intune-native or where a compatibility fallback is still required.
Audit platform-owned persisted payloads touched by this feature, especially `operation_runs.context`, compare subcontext payloads, and evidence payloads, and normalize them through wrappers or presenters before considering any rename.
### 6. Add guardrails for regression-prone surfaces
Extend architecture and feature coverage so touched surfaces cannot bypass canonical resolution or reintroduce false-universal Intune vocabulary.
Priority guard surfaces:
- reason translation primary surfaces
- operation run list and detail surfaces
- monitoring widgets or recent-run summaries
- evidence snapshot resource and snapshot presentation surfaces
- baseline compare explanation and evidence surfaces
- snapshot presentation and filter-option catalogs
- tenant review resource and review-pack widget surfaces
- provider connection, inventory item, backup schedule, and onboarding launch surfaces
## Focused Verification
Run the full spec-specific suites after each phase and before final sign-off:
```bash
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Governance/PlatformVocabularyGlossaryTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Governance/RegistryOwnershipDescriptorTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Baselines/GovernanceSubjectTaxonomyRegistryTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Architecture/PlatformVocabularyBoundaryGuardTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Authorization/ReasonTranslationScopeSafetyTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ReasonTranslation/ReasonResolutionEnvelopeTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ReasonTranslation/ProviderReasonTranslationTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ReasonTranslation/RbacReasonTranslationTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ReasonTranslation/TenantOperabilityReasonTranslationTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/ReasonTranslation/ExecutionDenialReasonTranslationTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunListFiltersTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/ControlTowerDashboardTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Baselines/SnapshotRendering/BaselineSnapshotPresenterTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotResolvedReferencePresentationTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareDriftEvidenceContractTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotFallbackRenderingTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewExecutivePackTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewUiContractTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackGenerationTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackWidgetTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/InventoryItemResourceTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling/BackupScheduleCrudTest.php
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingEntryPointTest.php
```
If any touched Filament explanation or run-detail surface changes materially, keep the existing UI-facing smoke coverage green before expanding scope.
## Final Validation
1. Run formatting:
```bash
cd /Users/ahmeddarrazi/Documents/projects/TenantAtlas/apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
2. Re-run the focused verification pack.
3. Confirm historical operation types still render current operator meaning while new or touched flows resolve canonical operation codes.
4. Confirm touched platform surfaces prefer governed-subject descriptors and no longer rely on false-universal `policy_type` labels.
5. Confirm domain-owned Intune models and Graph-facing adapters retain their intentional terminology.
6. Review touched list surfaces against `docs/product/standards/list-surface-review-checklist.md` before sign-off.

View File

@ -0,0 +1,103 @@
# Research: Platform Core Vocabulary Hardening
## Decision: Keep the maintained platform glossary inside `App\Support\Governance`
### Rationale
The codebase already has a real governance vocabulary seam through `GovernanceDomainKey`, `GovernanceSubjectClass`, `GovernanceSubjectType`, and `GovernanceSubjectTaxonomyRegistry`. Spec 204 needs one maintained code-side location for canonical platform nouns, ownership boundaries, and forbidden false-universal aliases. Extending the existing governance namespace keeps the glossary close to the actual platform vocabulary primitives instead of scattering meaning across docs, config comments, and presenter code.
### Alternatives considered
- Add glossary prose only in the spec artifacts: rejected because implementation code and regression tests would still lack a maintained source of truth.
- Put the glossary entirely in config: rejected because canonical vocabulary and ownership boundaries are platform semantics, not environment configuration.
- Create a new top-level vocabulary framework: rejected because current governance seams already cover the necessary ownership model.
## Decision: Resolve one canonical operation vocabulary from stored values through explicit legacy aliases
### Rationale
`OperationRunType` and `OperationCatalog` already anchor operation meaning across monitoring, notifications, audit prose, widgets, and Filament detail surfaces. The real gap is that historical stored values mix domain-aware dotted names and older underscore names. The narrowest safe hardening is to add canonical operation codes plus explicit alias resolution so old persisted values remain readable while the platform only treats one code as canonical for future-facing semantics.
### Alternatives considered
- Rewrite all historical `operation_runs.type` values immediately: rejected because it adds migration and compatibility risk without improving operator understanding faster.
- Keep all current values equally canonical: rejected because mixed-era names would remain a permanent source of ambiguity.
- Introduce a second operation registry beside `OperationCatalog`: rejected because current consumers already centralize on the existing catalog.
## Decision: Reuse `OperationCatalog` as the central operation registry rather than creating a parallel vocabulary service
### Rationale
Exploration showed that `OperationCatalog` is already the shared read path for labels, durations, artifact families, and operator explanation support. Spec 204 should strengthen that seam with canonicalization and domain grouping instead of duplicating registry logic elsewhere. This keeps all operation-facing UI surfaces on one consistent resolution path and minimizes the amount of code that has to know about alias compatibility.
### Alternatives considered
- Add a new platform vocabulary service that wraps `OperationCatalog`: rejected because it would create an unnecessary second layer for the same meaning.
- Resolve canonical labels ad hoc in each page or widget: rejected because that would scatter compatibility logic and make regressions likely.
## Decision: Keep domain-owned reason codes intact and derive platform reason families at the translation boundary
### Rationale
The codebase already has stable domain-owned reason families such as `ProviderReasonCodes`, `RbacReason`, `BaselineCompareReasonCode`, and execution-denial reasoning. `ReasonTranslator` and `ReasonResolutionEnvelope` are already the platform-owned boundary where those codes become operator-facing explanations. Spec 204 should extend that boundary with explicit ownership metadata and platform reason-family classification so platform surfaces can speak clearly without renaming every domain enum into an artificial universal namespace.
### Alternatives considered
- Rename all existing reason enums into one platform-wide code family: rejected because it would blur ownership and create large churn for little gain.
- Leave ownership implicit and only tweak labels: rejected because ambiguity about which layer owns a reason is part of the current problem.
- Add a second explanation system beside `ReasonTranslator`: rejected because the current translation seam already owns operator-safe explanation rendering.
## Decision: Treat `policy_type` as valid only in explicitly Intune-owned or adapter-owned contexts
### Rationale
Search results showed that many `policy_type` uses are still correct because they belong to Intune policy models, backup items, inventory records, and Graph-facing adapter logic. The misleading cases are platform-near compare, snapshot, review, and monitoring contracts that use `policy_type` as if it were the product's universal noun. The hardening should therefore preserve Intune-native usage where ownership is truly Intune-specific and replace only the false-universal platform-facing usage with governed-subject descriptors.
### Alternatives considered
- Ban `policy_type` everywhere: rejected because that would erase legitimate Intune-domain meaning and distort adapter boundaries.
- Leave platform-near `policy_type` untouched: rejected because it keeps leaking one domain's nouns into product-core semantics.
## Decision: Prefer wrapper-first hardening for platform-near JSON and presenter contracts
### Rationale
Baseline compare, baseline snapshots, operation run context, and evidence rendering already persist JSON and derived presentation payloads. The narrowest implementation is to harden projections, presenters, and platform-owned context wrappers so they expose `domain_key`, `subject_class`, `subject_type_key`, and `subject_type_label` first while preserving compatibility with any existing legacy keys as secondary detail. This avoids unnecessary schema churn and keeps the change proportional.
### Alternatives considered
- Add new tables for platform-neutral subject descriptors: rejected because the existing persisted truths are already sufficient.
- Rewrite all existing JSON shapes in one release: rejected because it increases migration and rollback complexity without being necessary for operator clarity.
## Decision: Add explicit ownership descriptors around existing registries rather than building a universal plugin system
### Rationale
The codebase already contains several registry-like seams with different ownership layers: governance taxonomy, operation catalog, provider reason codes, compare explanations, and Intune policy-type config. Spec 204 needs those ownership layers to be explicit so future changes do not treat all registries as equivalent platform vocabulary sources. A small ownership descriptor model is enough to encode which registries are canonical platform vocabulary, which are domain-owned, and which are compatibility-only.
### Alternatives considered
- Build a universal runtime registry framework: rejected because it adds infrastructure without solving a concrete current-release problem.
- Rely on naming conventions only: rejected because that keeps the rules implicit and hard to enforce with tests.
## Decision: Make compatibility explicit and temporary through regression guards
### Rationale
Spec 204 is intentionally transitional. Historical operation codes and legacy platform-near payload keys still have to render correctly, but they should no longer be treated as equally canonical. The safest way to enforce that boundary is to codify canonical resolution in tests for monitoring, reason translation, baseline compare, and snapshot presentation so new work cannot quietly reintroduce raw mixed vocabulary.
### Alternatives considered
- Leave compatibility informal and rely on code review: rejected because the current ambiguity already slipped through existing review.
- Remove all legacy aliases immediately: rejected because current persisted truth and current filters still depend on them.
## Decision: Keep the scope narrow and implementation-first
### Rationale
Most of the architecture required by this feature already exists from Specs 202 and 203. The correct plan is therefore a hardening pass over those seams, not a new platform-core subsystem. That keeps the feature proportional to the actual problem: clearer boundaries, clearer nouns, and safer future evolution.
### Alternatives considered
- Expand the spec into a broad multi-domain abstraction initiative: rejected because the repo does not yet need a generic framework beyond the seams already shipping.
- Defer until another provider exists: rejected because the current mixed vocabulary is already harming platform clarity today.

View File

@ -0,0 +1,402 @@
# Feature Specification: Platform Core Vocabulary Hardening
**Feature Branch**: `204-platform-core-vocabulary-hardening`
**Created**: 2026-04-13
**Status**: Draft
**Input**: User description: "Spec 204 - Platform Core Vocabulary Hardening"
- **Type**: Core vocabulary hardening / platform-boundary clarification
- **Priority**: High
- **Depends on**: Spec 202 - Governance Subject Taxonomy and Baseline Scope V2
- **Recommended after / alongside**: Spec 203 - Baseline Compare Engine Strategy Extraction
- **Blocks**: Clean multi-domain expansion without semantic drift in platform-core surfaces
- **Does not block**: Continued Intune-first operation during the transition period
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Platform-core contracts, operation typing, reason-code layers, and some registries still speak in Intune-shaped vocabulary as though every governed subject were an Intune policy, even after the platform started defining broader governance concepts.
- **Today's failure**: The codebase can become structurally more extensible through Specs 202 and 203 while still teaching contributors the wrong architecture. That creates semantic lock-in, ambiguous platform boundaries, and avoidable friction for future governance domains.
- **User-visible improvement**: Operators keep current Intune-first behavior, but platform-facing labels, run types, summaries, and reason semantics become clearer, more future-safe, and less misleading. Contributors can tell what is platform-core and what is intentionally Intune-specific without guessing.
- **Smallest enterprise-capable version**: Define one maintained platform vocabulary source of truth, harden only the platform-near discriminators and catalogs that currently leak false-universal Intune terminology, adopt canonical domain-aware operation types, separate platform and domain reason-code ownership, and preserve Intune-native naming where it is actually correct.
- **Explicit non-goals**: No new governance domain, no broad repo-wide rename sweep, no fake generic rewrite of Intune adapters, no new plugin system, no redesign of baseline scope beyond Spec 202, no replacement of compare strategy extraction from Spec 203, and no generic backup or restore engine.
- **Permanent complexity imported**: One canonical glossary or boundary note, one canonical operation-type naming model, one explicit platform-vs-domain reason-code boundary, targeted migration or mapping rules where needed, and focused regression coverage.
- **Why now**: Spec 202 establishes governed-subject vocabulary and Spec 203 extracts compare strategy boundaries. If platform-core vocabulary stays Intune-shaped now, future domains will inherit the wrong mental model and force more expensive cleanup later.
- **Why not local**: A few local renames would not solve the platform-wide ambiguity. The problem is not one page or one class; it is the product's implied architecture at platform-core boundaries.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Rename-sweep risk, future-domain preparation risk, and cross-domain taxonomy risk. Defense: this spec is intentionally narrow, preserves domain-native Intune terms, limits persistence churn to platform-near boundaries, and exists to correct current-release platform meaning rather than to build speculative infrastructure.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace, tenant, platform, canonical-view
- **Primary Routes** *(summary anchors only; the four surface-governance tables below are the authoritative route inventory for touched operator-facing surfaces)*:
- `/admin/operations`
- `/admin/operations/{run}`
- `/admin/t/{tenant}`
- `/system`
- `/admin/t/{tenant}/baseline-compare`
- `/admin/t/{tenant}/evidence`
- `/admin/t/{tenant}/reviews`
- `/admin/t/{tenant}/review-packs`
- `/admin/provider-connections`
- `/admin/t/{tenant}/inventory/inventory-items`
- `/admin/t/{tenant}/backup-schedules`
- `/admin/onboarding`
- **Data Ownership**:
- Workspace-owned platform registries, glossaries, and boundary notes define canonical platform vocabulary and ownership rules.
- Tenant-owned operation runs, findings, evidence, and review summaries may receive vocabulary hardening where they expose platform-core semantics.
- Pure Intune-owned entities, metadata, and adapter persistence remain Intune-owned unless a platform-near discriminator or label boundary explicitly requires hardening.
- This feature may require targeted persisted discriminator renames or mapping only where a platform-near surface currently encodes false-universal Intune meaning.
- **RBAC**:
- Existing platform, workspace, and tenant authorization boundaries remain authoritative.
- `/system` surfaces continue to use the platform guard and existing system-panel capability checks; vocabulary hardening must not widen platform-plane visibility or cross-plane leakage.
- Non-members remain `404`, entitled members without capability remain `403`, and vocabulary hardening must not change access boundaries.
- No new destructive operator action is introduced by this spec.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Canonical monitoring and review surfaces touched by this spec continue to respect active workspace context and should prefilter tenant-owned results to the current tenant when entered from tenant context, while tenant-neutral platform records remain clearly labeled as platform-scoped.
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical operation and review surfaces must continue to enforce workspace entitlement first and tenant entitlement for tenant-owned records second. Vocabulary hardening must not reveal hidden tenants, hidden runs, or inaccessible review context through labels, filters, or reason text.
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Monitoring operations list | Primary Decision Surface | Decide which run needs inspection or can be safely ignored | Canonical operation label, domain grouping, scope context, and high-level outcome | Legacy type mapping detail, raw provider wording, and deep diagnostics | Primary because monitoring attention and follow-up start here | Follows monitoring and troubleshooting workflow | Removes guesswork caused by ambiguous or Intune-shaped run labels |
| Operation run detail | Tertiary Evidence / Diagnostics Surface | Understand why a run had a given type, outcome, or explanation | Canonical operation type, platform reason family, and clear top-level meaning | Domain-specific cause detail, transition mapping context, and low-level diagnostics | Not primary because it explains evidence after the operator chooses to inspect a run | Follows diagnostics workflow after monitoring or review | Keeps platform meaning visible without forcing operators to decode domain-local internals |
| Tenant dashboard recent operations widget | Secondary Context Surface | Notice whether a recent tenant-scoped run deserves deeper inspection | Canonical operation label, tenant context, and high-level outcome | Raw alias mapping and deeper diagnostics on linked monitoring surfaces | Not primary because it summarizes current tenant activity inside the dashboard rather than replacing the monitoring register | Follows tenant triage workflow | Reduces the need to leave the dashboard for routine orientation |
| System dashboard Control Tower widgets | Secondary Context Surface | Notice whether failure clusters or repeat offenders require monitoring follow-up | Failure pressure, time-window context, and canonical operation labels | Per-run diagnostics and raw alias history | Not primary because the widgets summarize console pressure before the operator enters detailed monitoring views | Follows ops-console workflow | Keeps the system dashboard scannable and calm |
| Evidence snapshot resource and snapshot presentation | Tertiary Evidence / Diagnostics Surface | Inspect the evidence that backs a governed-subject or comparison outcome | Snapshot label, governed-subject descriptor, and evidence state | Raw payload structure and compatibility alias detail | Not primary because it explains evidence after a review or compare decision already exists | Follows evidence inspection workflow | Keeps evidence readable without defaulting to Intune-only nouns |
| Tenant baseline compare surfaces | Primary Decision Surface | Decide whether tenant follow-up is needed based on compare and review truth | Platform-correct governed-subject wording, canonical operation naming where shown, and clear platform-versus-domain explanation semantics | Domain-specific Intune detail, raw identifiers, and deep evidence | Primary because this is where the operator decides what to do next | Follows compare, review, and follow-up workflow | Prevents operators from mistaking Intune-local causes for universal platform concepts |
| Tenant review resource | Primary Decision Surface | Decide which review record or export context needs action | Review status, canonical reason ownership, and review summary language | Raw export details and deeper historical reasoning | Primary because review follow-up decisions happen here | Follows tenant review workflow | Keeps review meaning obvious before the operator opens exports or linked evidence |
| Tenant review pack widget | Secondary Context Surface | Notice whether the latest review pack warrants deeper inspection | Pack freshness, canonical review wording, and tenant scope | Export internals and derived detail on linked reporting surfaces | Not primary because it summarizes review state inside a broader tenant context | Follows tenant reporting workflow | Reduces clicks to current pack status |
| Provider connection resource launch surface | Secondary Context Surface | Decide whether to inspect or launch a provider workflow from the existing connection surface | Connection identity, status, and canonical launch labels | Provider diagnostics and audit history | Not primary because provider workflows remain subordinate to the connection record | Follows integration maintenance workflow | Keeps launch wording consistent without turning the record into a workflow hub |
| Inventory item list launch surface | Secondary Context Surface | Decide whether to inspect the item or launch inventory follow-up | Inventory state, scope context, and canonical launch wording | Deep dependency detail and raw provider wording | Not primary because inventory inspection stays primary | Follows inventory review workflow | Keeps follow-up wording clear without cluttering the list |
| Backup schedule resource launch surface | Secondary Context Surface | Decide whether to inspect or run schedule-related work | Schedule cadence, scope, and canonical run labels | Historical operation detail and raw audit wording | Not primary because schedule management remains the owning workflow | Follows backup management workflow | Keeps operational wording consistent |
| Managed tenant onboarding wizard | Primary Decision Surface | Decide the next onboarding step and launch verification at the right time | Current step, verification progress, and platform-safe action wording | Deeper provider diagnostics and legacy alias detail | Primary because onboarding progression decisions happen directly here | Follows staged onboarding workflow | Keeps the next step obvious |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
**List Surface Review Checklist**: Because this feature modifies the Monitoring operations list and may affect list-style review or reporting surfaces that render hardened vocabulary, sign-off MUST include review against `docs/product/standards/list-surface-review-checklist.md` for each touched list surface.
| Surface | Action Surface Class | Surface Type | Decision-role Prominence | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Monitoring operations list | List / Table / Bulk | Read-only Registry / Report Surface | Primary Decision Surface | Filter to a domain or open one run | Full-row open to operation detail | required | Filter and grouping controls stay in list chrome | none | `/admin/operations` | `/admin/operations/{run}` | Workspace context, tenant context when filtered, canonical domain-aware operation type | Operations / operation runs | What ran, in which domain, and whether the top-level outcome needs inspection | none |
| Operation run detail | Record / Detail / Edit | Detail-first Operational Surface | Tertiary Evidence / Diagnostics Surface | Inspect run meaning and next step | Explicit run detail page | n/a | Navigation and related links remain secondary to the summary body | none | `/admin/operations` | `/admin/operations/{run}` | Workspace context, tenant scope when applicable, platform reason family, domain detail when present | Operation run | Why the run means what it means without conflating platform and domain causes | canonical evidence detail |
| Tenant dashboard recent operations widget | Monitoring / Queue / Workbench | Read-only Registry / Report Surface | Secondary Context Surface | Jump to the tenant-scoped monitoring slice or open the highlighted run | Existing widget links open the tenant dashboard summary or run detail | n/a | Widget links remain limited to monitoring context | none | `/admin/t/{tenant}` | `/admin/operations/{run}` | Tenant context, canonical operation labels, high-level outcome | Recent operations | Which recent tenant-scoped runs deserve deeper inspection | none |
| System dashboard Control Tower widgets | Monitoring / Queue / Workbench | Read-only Registry / Report Surface | Secondary Context Surface | Jump to failing-run monitoring or open the highlighted run | Existing widget links open system monitoring or run detail | n/a | Widget links remain limited to system-console context | none | `/system` | `/system/ops/runs/{run}` | System time-window context, failure pressure, canonical operation labels | Control Tower summaries | Which failing or repeat-offender runs deserve follow-up | none |
| Evidence snapshot resource and snapshot presentation | List / Table / Bulk | Read-only Registry / Report Surface | Tertiary Evidence / Diagnostics Surface | Open the evidence snapshot that explains a governed subject or comparison result | Existing row or identifier open to snapshot detail | required | Filter and export controls remain secondary | none | `/admin/t/{tenant}/evidence` | `/admin/t/{tenant}/evidence/{snapshot}` | Tenant or workspace context, governed-subject descriptor, evidence state | Evidence snapshot | Snapshot meaning stays operator-safe without relying on false-universal `policy_type` | none |
| Tenant baseline compare surfaces | Monitoring / Queue / Workbench | Queue / Review Surface | Primary Decision Surface | Decide whether to follow up, inspect evidence, or defer | Explicit page and linked review context | forbidden | Secondary links to evidence, findings, and diagnostics remain contextual | none | `/admin/t/{tenant}/baseline-compare` | `/admin/t/{tenant}/baseline-compare` | Workspace context, tenant context, governed-subject wording, review meaning | Baseline compare / review | Platform-correct governed-subject and explanation language, with domain-specific detail only when needed | none |
| Tenant review resource | List / Table / Bulk | Read-only Registry / Report Surface | Primary Decision Surface | Open a review record or export context with canonical terminology intact | Existing row or identifier open to review detail | required | Existing safe review actions remain contextual | none | `/admin/t/{tenant}/reviews` | `/admin/t/{tenant}/reviews/{review}` | Tenant scope, review status, canonical reason ownership | Tenant review | Review status and meaning stay canonical without hiding domain detail | none |
| Tenant review pack widget | Monitoring / Queue / Workbench | Read-only Registry / Report Surface | Secondary Context Surface | Open the latest review pack or linked tenant review context | Existing widget CTA opens pack or review detail | n/a | Widget CTA remains scoped to the current tenant review context | none | `/admin/t/{tenant}/review-packs` | `/admin/t/{tenant}/review-packs/{reviewPack}` | Tenant scope, pack freshness, canonical review wording | Review pack | The latest pack state is visible with canonical vocabulary before opening export or detail | none |
| Provider connection resource launch surface | List / Table / Bulk | CRUD / List-first Resource | Secondary Context Surface | Launch provider-related flows without reintroducing legacy operation labels | Existing row or identifier open remains the inspect path | allowed when the owning resource already uses it | Launch actions stay in existing header or overflow locations | none | `/admin/provider-connections` | `/admin/provider-connections/{connection}` | Workspace scope, provider identity, canonical operation labels for launched flows | Provider connection | The connection record remains primary while launch labels stay canonical | none |
| Inventory item list launch surface | List / Table / Bulk | Read-only Registry / Report Surface | Secondary Context Surface | Launch inventory-related follow-up from the existing list without losing canonical operation wording | Existing row or identifier open remains the inspect path | allowed when the owning resource already uses it | Launch actions remain contextual to the list or record | none | `/admin/t/{tenant}/inventory/inventory-items` | `/admin/t/{tenant}/inventory/inventory-items/{item}` | Workspace and tenant scope, inventory state, canonical operation wording | Inventory item | Inventory meaning stays primary while launch wording stays canonical | none |
| Backup schedule resource launch surface | List / Table / Bulk | CRUD / List-first Resource | Secondary Context Surface | Launch or inspect backup schedule work with canonical operation wording | Existing row or identifier open remains the inspect path | allowed when the owning resource already uses it | Launch actions stay in existing header or overflow locations | none | `/admin/t/{tenant}/backup-schedules` | `/admin/t/{tenant}/backup-schedules/{schedule}/edit` | Tenant or workspace scope, schedule status, canonical backup-run wording | Backup schedule | The schedule stays primary while run labels remain canonical | none |
| Managed tenant onboarding wizard | Wizard / Flow | Detail-first Operational Surface | Primary Decision Surface | Continue onboarding and launch verification steps with canonical platform wording | Existing staged wizard progression | n/a | Secondary actions remain limited to back, resume, or contextual help | none | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace scope, tenant identification, verification progress, canonical operation wording | Managed tenant onboarding | The next onboarding step remains obvious while workflow labels stay platform-safe | Wizard |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision-role Prominence | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Monitoring operations list | Workspace operator | Primary Decision Surface | Decide which run needs inspection or filtering | Read-only Registry / Report Surface | What ran, and is the operation meaning clear enough to inspect or ignore? | Canonical operation label, domain grouping, scope context, and outcome | Legacy mapping detail and raw provider wording | execution outcome, domain, scope | read-only | Filter, open run | none |
| Operation run detail | Workspace operator or entitled tenant operator | Tertiary Evidence / Diagnostics Surface | Diagnose why the run means what it means | Detail-first Operational Surface | Is this a platform-level issue, a domain-specific issue, or both? | Canonical operation type, platform reason family, and top-level explanation | Domain-specific cause detail, transition mapping, raw diagnostics | execution outcome, platform reason, domain cause | read-only | Open related evidence or review surfaces | none |
| Tenant dashboard recent operations widget | Tenant operator | Secondary Context Surface | Spot the recent tenant-scoped run cohort that deserves deeper inspection | Read-only Registry / Report Surface | Which recent operations on this tenant should I inspect next? | Canonical operation labels, summarized outcome, and tenant context | Raw alias mapping or deeper diagnostics remain in linked detail surfaces | execution outcome, tenant scope | read-only | Open linked monitoring slices or run detail | none |
| System dashboard Control Tower widgets | Platform operator | Secondary Context Surface | Spot failing or repeat-offender run cohorts that deserve deeper inspection | Read-only Registry / Report Surface | Which failures or offenders need console follow-up now? | Failure pressure, time-window context, and canonical operation labels | Per-run diagnostics and raw alias history remain in linked console views | failure pressure, execution outcome, time window | read-only | Open linked monitoring slices or run detail | none |
| Evidence snapshot resource and snapshot presentation | Workspace operator or entitled tenant operator | Tertiary Evidence / Diagnostics Surface | Cross-check the evidence that explains a governed subject or comparison result | Read-only Registry / Report Surface | Which evidence snapshot explains this state, and is the governed-subject wording still canonical? | Snapshot label, governed-subject descriptor, and evidence state | Raw payload structure and compatibility alias detail | evidence freshness, subject scope, evidence status | read-only | Open snapshot detail or export existing evidence views | none |
| Tenant baseline compare surfaces | Tenant operator | Primary Decision Surface | Decide whether tenant state needs follow-up | Queue / Review Surface | What needs action, and is the explanation platform-wide or Intune-specific? | Governed-subject wording, review meaning, and platform reason semantics | Raw evidence, domain-specific detail, historical transition detail | governance result, evidence completeness, explanation ownership | `simulation only` or existing mutation scope for linked actions | Existing compare or review actions | none |
| Tenant review resource | Tenant operator | Primary Decision Surface | Open a review record or export context with canonical terminology intact | Read-only Registry / Report Surface | Which tenant review needs inspection, and is its explanation still aligned with platform vocabulary? | Review status, canonical reason ownership, and review summary language | Raw export details and deeper historical reasoning | review lifecycle, explanation ownership, export readiness | existing read-only or linked mutation scope | Open review detail or linked exports | none |
| Tenant review pack widget | Tenant operator | Secondary Context Surface | Open the latest review pack or linked tenant review context | Read-only Registry / Report Surface | Do I need to inspect the newest review pack or linked review state now? | Pack freshness, tenant scope, and canonical review wording | Export internals and derived detail remain on linked surfaces | pack freshness, tenant scope | read-only | Open pack or linked tenant review detail | none |
| Provider connection resource launch surface | Workspace operator | Secondary Context Surface | Launch provider-related flows without losing canonical operation wording | CRUD / List-first Resource | Can I inspect this connection and launch the next provider workflow with clear wording? | Connection identity, status, and canonical launch labels | Audit history and deeper provider diagnostics | connection state, provider type | existing resource mutation scope | Existing inspect, edit, and launch actions | none |
| Inventory item list launch surface | Workspace operator | Secondary Context Surface | Launch inventory-related follow-up from the existing list | Read-only Registry / Report Surface | Which inventory record needs follow-up, and is the action wording still canonical? | Inventory state, scope context, and canonical launch wording | Deep dependency detail and raw provider wording | inventory status, tenant scope | read-only | Existing inspect and launch actions | none |
| Backup schedule resource launch surface | Workspace operator | Secondary Context Surface | Launch or inspect backup schedule work with canonical run wording | CRUD / List-first Resource | Which schedule am I managing, and do its run actions still read clearly? | Schedule cadence, scope, and canonical run labels | Historical operation detail and raw audit wording | schedule state, tenant scope | existing resource mutation scope | Existing inspect, edit, and launch actions | none |
| Managed tenant onboarding wizard | Workspace operator | Primary Decision Surface | Continue onboarding and launch verification steps with canonical wording | Detail-first Operational Surface | What is the next onboarding step, and does the launch wording match platform semantics? | Current step, verification progress, and platform-safe action wording | Deeper provider diagnostics and legacy alias detail | onboarding stage, tenant identification, verification progress | staged workflow only | Continue, resume, verify | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: yes
- **New cross-domain UI framework/taxonomy?**: yes, but only as a narrow platform vocabulary hardening layer that builds directly on Spec 202
- **Current operator problem**: Platform surfaces and platform-near contracts still imply that the universal governed object is an Intune policy. That ambiguity makes monitoring, compare, review, and future domain work harder to understand and maintain.
- **Existing structure is insufficient because**: Structural progress alone does not fix the architecture the product communicates through names. Without explicit hardening, future contributors will keep placing platform concepts into Intune-shaped terms.
- **Narrowest correct implementation**: Define a canonical glossary, harden only platform-core and platform-near vocabulary that is currently misleading, adopt canonical operation types, separate platform and domain reason-code ownership, and preserve Intune-native naming where the object is truly Intune-owned.
- **Ownership cost**: Ongoing glossary maintenance, transition mapping discipline, targeted migration review where persisted fields are hardened, and regression coverage for operation labels, reason semantics, and no-regression Intune behavior.
- **Alternative intentionally rejected**: A local rename sweep, or a broad generic platform rewrite. The rename sweep would leave semantic ambiguity in place, and the rewrite would over-abstract current product reality.
- **Release truth**: current-release platform-boundary clarification with near-term value for Specs 202 and 203, not a speculative multi-domain framework build
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Remove false-universal Intune language from platform surfaces (Priority: P1)
As a platform contributor, I want platform-core and platform-near contracts to use canonical platform vocabulary so that I do not assume every governed subject is an Intune policy.
**Why this priority**: This is the core reason for the feature. If the platform continues to speak in universal `policy_type` semantics, future structural improvements still land into the wrong mental model.
**Independent Test**: Review the hardened platform contracts, summaries, and registries touched by this spec and confirm they use canonical platform terms while leaving explicitly Intune-owned contracts unchanged.
**Acceptance Scenarios**:
1. **Given** a platform-core contract that currently uses `policy_type` as a universal discriminator, **When** the hardening is applied, **Then** the contract uses canonical platform vocabulary instead of implying every governed subject is an Intune policy.
2. **Given** an explicitly Intune-owned contract such as policy metadata or adapter-specific catalog data, **When** the hardening is applied, **Then** Intune-native terms remain in place and are not replaced with vague generic wording.
---
### User Story 2 - Keep monitoring and review semantics clear during transition (Priority: P1)
As an operator, I want operation labels, reason semantics, and grouped summaries to remain understandable during the transition so that vocabulary hardening does not break monitoring or review workflows.
**Why this priority**: This feature cannot improve architecture at the cost of confusing current operators or breaking run and review interpretation.
**Independent Test**: Exercise historical and canonical operation types across monitoring, run detail, and compare or review surfaces and confirm labels, groupings, and explanation semantics remain correct.
**Acceptance Scenarios**:
1. **Given** a historical run or summary that still carries a legacy operation type, **When** an operator views it after this feature lands, **Then** the surface resolves it to the canonical operator-facing label and correct domain grouping without breaking the run history.
2. **Given** a surface that shows a platform reason and a domain-specific cause, **When** the operator inspects the explanation, **Then** the platform reason remains distinct from the domain-owned detail instead of collapsing into one ambiguous code family.
---
### User Story 3 - Make platform and domain ownership obvious to future contributors (Priority: P2)
As a contributor onboarding to the codebase, I want one maintained boundary reference for canonical platform terms so that I can tell whether a registry, discriminator, or reason belongs to the platform core or to the Intune adapter.
**Why this priority**: Clear ownership is what keeps future work from reintroducing semantic drift after the first hardening pass.
**Independent Test**: Use the glossary and boundary guidance alone to classify touched terms, registries, and reason families as `platform_core`, `cross_domain_governance`, or `intune_specific`, without relying on historical Intune knowledge.
**Acceptance Scenarios**:
1. **Given** a contributor reads the maintained glossary or boundary note, **When** they inspect a touched registry or reason family, **Then** they can determine whether it is `platform_core`, `cross_domain_governance`, or `intune_specific` from the documented vocabulary alone.
2. **Given** a contributor adds new platform-core work within the scope of this feature, **When** they choose labels or contract terms, **Then** the canonical glossary directs them away from false-universal Intune wording.
---
### User Story 4 - Preserve Intune-first behavior while hardening boundaries (Priority: P2)
As a product maintainer, I want vocabulary hardening to leave current Intune-first operation, compare, evidence, and review behavior intact so that the transition stays safe while the platform boundary becomes clearer.
**Why this priority**: The feature is about correctness of platform meaning, not about changing what the current Intune product does.
**Independent Test**: Run focused regression coverage for current Intune-first flows that use the touched operation types, reason semantics, registries, and platform-near discriminators and confirm behavior stays the same except for the intended vocabulary hardening.
**Acceptance Scenarios**:
1. **Given** an existing Intune-first compare, evidence, review, or monitoring flow, **When** the feature ships, **Then** the flow still works and the only intended difference is clearer platform-vs-domain vocabulary where the old wording was misleading.
2. **Given** a platform-near persisted discriminator or summary is hardened, **When** existing Intune data is read or rendered, **Then** compatibility mapping preserves current behavior during the documented transition period.
### Edge Cases
- A historical operation run still stores a legacy type value that no longer matches the canonical operation name one-to-one.
- A platform summary consumes data from an untouched Intune adapter contract that still uses `policy_type`, and the boundary must prevent that term from leaking back into platform-core language.
- A reason explanation includes both a platform-wide failure category and an Intune-specific root cause, and the surface must keep ownership explicit.
- A contributor encounters a registry whose name was historically broad but whose contents are still domain-owned, and the hardening must avoid leaving it ambiguously universal.
- A platform-near persisted discriminator needs a rename in one surface but remains Intune-owned in a neighboring table, and the transition must not imply a fake generic rewrite.
- Legacy and canonical operation type names coexist temporarily in reporting filters, exports, or saved views, and the system must not make both names appear permanently canonical.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce a new Microsoft Graph path or a new long-running workflow. It hardens the vocabulary used by existing platform surfaces, operation types, summaries, registries, and reason semantics so that platform-core meaning stays accurate as the product expands.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The glossary, operation-type model, and reason-code ownership rules are justified by current-release platform ambiguity. The spec must stay narrow: no new persisted entity, no generic plugin framework, no fake platform rewrite of Intune adapters, and no new state family without operator or contributor consequence.
**Constitution alignment (OPS-UX):** Existing `OperationRun` lifecycles, status ownership, summary-count rules, and three-surface feedback remain unchanged. If operation types or run titles are hardened, the change must preserve current observability and keep transition support compatible with existing monitoring surfaces.
**Constitution alignment (RBAC-UX):** The feature spans platform `/system` console surfaces, workspace monitoring surfaces, and tenant review surfaces but does not change guard, membership, or capability boundaries. Non-members remain `404`, capable members remain governed by current plane-specific capability checks, and vocabulary hardening must not leak hidden records through labels or explanations.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that this feature does not create an authentication-handshake exception.
**Constitution alignment (BADGE-001):** If any status-like or outcome-adjacent labels are touched, their semantics must remain centralized and derived from canonical platform or domain truth rather than page-local wording.
**Constitution alignment (UI-FIL-001):** Touched monitoring and review surfaces continue to use existing Filament pages, summaries, tables, and shared UI primitives. Vocabulary hardening must not introduce a page-local semantic framework or custom status language.
**Constitution alignment (UI-NAMING-001):** Operation labels, run titles, summary headings, notifications, and audit prose touched by this spec must use one canonical term per platform concept. Domain disambiguation must appear only where it clarifies ownership rather than adding noise.
**Constitution alignment (DECIDE-001):** The feature does not create a new primary decision surface. It improves existing decision and diagnostics surfaces by making platform-core meaning explicit and keeping domain-local detail secondary.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Existing navigation, inspect models, and action placement remain unchanged. The material change is vocabulary and grouping clarity, not a new surface hierarchy.
**Constitution alignment (ACTSURF-001 - action hierarchy):** No new header, row, or bulk action structure is introduced. Any touched labels or helper copy must preserve the current separation between navigation, mutation, context, and dangerous actions.
**Constitution alignment (OPSURF-001):** Default-visible content on touched monitoring and review surfaces must stay operator-first. Canonical operation meaning and platform reason semantics belong in the default view; raw provider terms, migration detail, and deep diagnostics remain secondary.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature must not add a second semantic interpretation layer. It should replace misleading names with canonical names and keep tests focused on operator meaning, boundary ownership, and compatibility behavior.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Each touched surface keeps one primary inspect or open model, no redundant `View` action is introduced, no empty action groups are added, and no destructive action changes are required. The UI Action Matrix below records the touched surfaces.
**Constitution alignment (UX-001 - Layout & Information Architecture):** Existing monitoring and review layouts remain in place. Vocabulary hardening must not introduce new layout patterns, duplicate summaries, or parallel explanation panes.
### Functional Requirements
- **FR-204-001 Canonical glossary source of truth**: The product MUST maintain one internal source of truth for canonical platform vocabulary defining at least `domain_key`, `subject_class`, `subject_type_key`, `resource_type` if used, `operation_type`, `platform_reason_family`, `reason_owner.owner_namespace`, `reason_code`, `registry_key`, and `boundary_classification` for registry ownership.
- **FR-204-002 Boundary rule in glossary**: The maintained vocabulary source MUST explicitly state that platform-core and cross-domain contracts use platform vocabulary, while Intune-owned contracts may retain correct Intune-native terminology.
- **FR-204-003 Platform-core discriminator hardening**: Platform-core and cross-domain contracts touched by this spec MUST stop using `policy_type` or equivalent Intune-only wording as a universal governed-subject discriminator.
- **FR-204-004 Intune-owned vocabulary preservation**: Intune-owned entities, metadata, and adapter contracts touched by this spec MUST remain free to use `policy_type`, `Policy`, `PolicyVersion`, `assignment`, `scope tags`, Graph resource terms, and other Intune-native language where the object is explicitly Intune-owned.
- **FR-204-005 Canonical governed-subject vocabulary**: Platform-near discriminators hardened by this spec MUST use the governed-subject vocabulary established by Spec 202, including `domain_key`, `subject_class`, and `subject_type_key` or an equivalent plural form when the contract carries multiple subject types.
- **FR-204-006 Platform-near persistence hardening**: If a platform-near persisted discriminator still communicates false-universal Intune meaning, the surface MUST be renamed or wrapped by a platform-correct contract; purely Intune-owned persistence MAY remain unchanged.
- **FR-204-007 Canonical operation type model**: The canonical platform operation-type model MUST be domain-aware and MUST use the exact `<domain>.<capability>.<action>` format for canonical operation codes.
- **FR-204-008 One canonical operation type per action**: Each operation surfaced after this hardening MUST have exactly one canonical operation type, and new or updated platform flows touched by this spec MUST emit only canonical names.
- **FR-204-009 Legacy operation-type mapping**: Where historical operation types already exist, the system MAY support a temporary mapping layer, but the mapping MUST point toward one canonical future-safe operation type and MUST be documented as transitional.
- **FR-204-010 Operation catalog continuity**: Operation catalogs, labels, groupings, filters, and monitoring summaries touched by this spec MUST resolve both canonical and supported legacy operation-type values without breaking operator comprehension during the transition period.
- **FR-204-011 Platform reason-family boundary**: Platform reason families touched by this spec MUST contain only domain-neutral causes that can apply across governance domains.
- **FR-204-012 Domain reason-code ownership**: Domain-specific reason codes touched by this spec MUST be namespaced or otherwise clearly segregated so they are recognizable as domain-owned rather than platform-core concepts.
- **FR-204-013 Explanation layering**: Operator-facing explanation surfaces touched by this spec MUST show platform reason meaning separately from domain-specific cause detail when both are present.
- **FR-204-014 Registry ownership naming**: Registries and catalogs touched by this spec MUST explicitly signal whether they are `platform_core`, `cross_domain_governance`, or `intune_specific`.
- **FR-204-015 No implicit universal Intune registry**: No contributor-facing registry, catalog, or API touched by this spec may imply that the current Intune policy catalog or supported Intune type list is the universal governed-subject registry of the platform.
- **FR-204-016 Platform-summary vocabulary hardening**: Compare, evidence, review, reporting, and monitoring summaries touched by this spec MUST use canonical platform vocabulary for governed subjects, operation types, and explanation ownership.
- **FR-204-017 One concept, one canonical name**: For each hardened platform concept, the spec MUST identify one canonical name and MUST document any temporary legacy alias that remains supported during rollout.
- **FR-204-018 Transition retirement rule**: Any dual vocabulary introduced for compatibility MUST include a documented retirement path so old and new names do not remain equally canonical after rollout stabilizes.
- **FR-204-019 Targeted migration rule**: Any persisted rename introduced by this spec MUST be narrowly targeted, backward-compatible during rollout, and limited to surfaces where the old name causes cross-domain confusion.
- **FR-204-020 No-regression guarantee**: The hardening MUST preserve current Intune-first operation, compare, evidence, review, and reporting behavior unless a separate spec explicitly changes that behavior.
- **FR-204-021 Contributor boundary guidance**: The resulting glossary or boundary note MUST let a new contributor answer whether a touched concept is `platform_core`, `cross_domain_governance`, or `intune_specific` without guessing from naming alone.
- **FR-204-022 Anti-churn guardrail**: The implementation MUST avoid broad repository-wide renaming and limit hardening to surfaces where vocabulary materially affects platform semantics, expansion safety, or contributor mental model.
- **FR-204-023 No reintroduction through copy**: New or changed platform-core labels, run titles, notifications, audit prose, and helper copy included in this spec MUST not reintroduce false-universal Intune wording.
- **FR-204-024 Regression coverage**: Automated coverage MUST protect operation-type mapping, reason-code layering, registry ownership boundaries, platform-near discriminator behavior, and no-regression Intune semantics for the flows touched by this spec.
### Non-Functional Requirements
- **NFR-204-001 DB-only render invariant**: Touched monitoring and review surfaces MUST remain DB-only at render time and MUST NOT initiate provider, Graph, or other external calls as a side effect of rendering, filtering, summarizing, or opening detail pages.
- **NFR-204-002 In-process resolution invariant**: Canonical operation resolution, reason ownership and family translation, registry ownership lookup, and platform subject normalization MUST execute in-process against application code, configuration, and persisted state already available to the request.
- **NFR-204-003 Query-shape guardrail**: Vocabulary hardening MUST NOT introduce request-path schema rewrites or query fan-out on touched monitoring and review surfaces relative to the current read-path architecture.
## Canonical Vocabulary Appendix
| Hardened Concept | Canonical Name / Code | Supported Legacy Alias | Retirement Path / Status |
|---|---|---|---|
| Governance domain field | `domain_key` | none | Permanent canonical field for the governance domain on touched cross-domain and platform-near contracts |
| Governance subject class field | `subject_class` | none | Permanent canonical field for subject-class semantics on touched cross-domain and platform-near contracts |
| Platform governed-subject discriminator | `subject_type_key` plus `subject_type_label`, with `domain_key` and `subject_class` | `policy_type` on platform-core or platform-near surfaces | Retire from touched platform-owned summaries, filters, and context payloads in Spec 204; keep only where the object remains explicitly Intune-specific |
| Platform governed-subject discriminator (plural) | `subject_type_keys` with per-item `subject_type_label` or `display_label` | `policy_types` on platform-core or platform-near surfaces | Retire from touched platform-owned collection payloads in Spec 204; adapter-owned lists may remain Intune-specific |
| Optional platform resource field | `resource_type` | mixed resource-like nouns on touched platform-owned summaries when a separate resource noun is required | Use only where a touched platform-facing contract needs a resource-shaped noun distinct from `subject_type_key`; otherwise omit |
| Platform operation-type field | `operation_type` | raw `type` on platform-facing summaries or filters | Use canonical `operation_type` on touched read models, summaries, filters, and exports even when persisted storage continues to use `operation_runs.type` |
| Platform reason-family field | `platform_reason_family` | none | Permanent canonical field for platform-neutral cause grouping |
| Domain reason ownership signal | `reason_owner.owner_namespace` plus original `reason_code` | unlabeled or mixed domain reason groupings | Use explicit owner namespace and original domain code on touched explanation contracts; do not keep unlabeled domain families as if they were platform-wide |
| Registry key | `registry_key` | none | Permanent internal identifier for touched registries and catalogs |
| Registry boundary classification | `boundary_classification` with `platform_core`, `cross_domain_governance`, `intune_specific` | unlabeled broad registry ownership | Retire unlabeled ownership on touched registries during Spec 204 review and implementation |
| Baseline compare operation type | `baseline.compare.execute` | `baseline_compare` | New or updated touched producers emit the canonical code in Spec 204; legacy alias remains read-only compatibility for historical runs until no supported producer writes it |
| Baseline capture operation type | `baseline.capture.execute` | `baseline_capture` | New or updated touched producers emit the canonical code in Spec 204; legacy alias remains read-only compatibility for historical runs until no supported producer writes it |
| Inventory sync operation type | `inventory.sync.execute` | `inventory_sync` | New or updated touched producers emit the canonical code in Spec 204; legacy alias remains read-only compatibility for historical runs until no supported producer writes it |
| Directory groups sync operation type | `directory.groups.sync` | `entra_group_sync` | New or updated touched producers emit the canonical code in Spec 204; legacy alias remains read-only compatibility for historical runs until no supported producer writes it |
| Backup schedule execution operation type | `backup.schedule.execute` | `backup_schedule_run` | New or updated touched producers emit the canonical code in Spec 204; legacy alias remains read-only compatibility for historical runs until no supported producer writes it |
| Tenant review composition operation type | `tenant.review.compose` | none | Already canonical; no alias retirement required |
| Review pack generation operation type | `tenant.review_pack.generate` | none | Already canonical for current scope; no alias retirement required |
| Evidence snapshot generation operation type | `tenant.evidence.snapshot.generate` | none | Already canonical for current scope; no alias retirement required |
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Monitoring operations list | Existing Monitoring operations list surface at `/admin/operations` | No new header actions; existing monitoring actions remain | Existing full-row or explicit run open remains the inspect path | Existing safe list actions only | Existing grouped bulk actions only | Existing monitoring empty-state CTA remains | n/a | n/a | Existing run and audit semantics remain | Action hierarchy does not change; vocabulary, labels, and filters are the only material changes |
| Operation run detail | Existing canonical operation detail surface at `/admin/operations/{run}` | No new header actions; existing navigation actions remain | Explicit run detail page | none added | none | Existing no-data or no-run states remain | Existing detail-page actions remain | n/a | Existing run-backed audit semantics remain | Platform and domain explanation layers become clearer, but no new action plane is introduced |
| Tenant dashboard recent operations widget | Existing tenant dashboard widget at `/admin/t/{tenant}` | No new dashboard header actions are introduced by this spec | Linked widget summary opens the existing run or monitoring context | none added | none | Existing dashboard empty-state behavior remains | n/a | n/a | Existing run and tenant audit semantics remain | Widget copy changes only; it must remain a calm secondary context surface |
| System dashboard Control Tower widgets | Existing system dashboard widgets at `/system` | Existing system dashboard header actions remain unchanged | Linked widget summary opens the existing console monitoring context or run detail | none added | none | Existing dashboard empty-state behavior remains | n/a | n/a | Existing system-console audit semantics remain | Widget copy changes only; no new system-console action plane is introduced |
| Evidence snapshot resource and snapshot presentation | Existing evidence surfaces at `/admin/t/{tenant}/evidence` and `/admin/t/{tenant}/evidence/{snapshot}` | Existing list-header create-snapshot action remains | Existing clickable-row snapshot open remains the inspect path | Existing safe row actions remain grouped under More | Existing grouped bulk actions remain unchanged | Existing evidence empty-state CTA remains | Existing snapshot detail actions remain | Existing create flow remains unchanged | Existing evidence audit semantics remain | Governed-subject wording changes, not action hierarchy |
| Tenant baseline compare surfaces | Existing tenant review surfaces including `/admin/t/{tenant}/baseline-compare` | Existing compare or review actions remain unchanged | Existing page and linked review context remain the inspect path | none added | none | Existing compare or review empty-state CTA remains | Existing page actions remain | n/a | Existing review and compare audit semantics remain | No destructive-action change and no Action Surface Contract exemption needed |
| Tenant review resource | Existing tenant review resource at `/admin/t/{tenant}/reviews` and `/admin/t/{tenant}/reviews/{review}` | Existing create or export header actions remain | Existing clickable-row review open remains the inspect path | Existing safe review actions remain contextual | Existing grouped bulk actions remain unchanged | Existing review empty-state CTA remains | Existing review detail actions remain | Existing create flow remains unchanged | Existing review audit semantics remain | Vocabulary hardening must not alter review action hierarchy |
| Tenant review pack widget | Existing tenant reporting widget opening `/admin/t/{tenant}/review-packs/{reviewPack}` | No new widget header actions are introduced | Existing widget CTA remains the inspect path | none added | none | Existing widget empty-state behavior remains | n/a | n/a | Existing review-pack audit semantics remain | Widget copy and labels change only |
| Provider connection resource launch surface | Existing provider connection resource at `/admin/provider-connections` and `/admin/provider-connections/{connection}` | Existing create and provider-operation header actions remain | Existing clickable-row or detail inspect path remains primary | Existing provider-operation actions remain grouped under More or detail headers | Existing bulk-action omission remains | Existing empty-state CTA remains | Existing provider-connection detail actions remain | Existing create and edit flows remain unchanged | Existing provider audit semantics remain | Launch labels change only; no new workflow hub is introduced |
| Inventory item list launch surface | Existing inventory register at `/admin/t/{tenant}/inventory/inventory-items` and `/admin/t/{tenant}/inventory/inventory-items/{item}` | Existing list-header sync action remains | Existing clickable-row item open remains the inspect path | Existing safe launch actions remain contextual | Existing bulk-action omission remains | Existing inventory empty-state behavior remains | Existing item detail actions remain | n/a | Existing inventory and run audit semantics remain | Vocabulary hardening must not turn the list into a control center |
| Backup schedule resource launch surface | Existing backup schedule resource at `/admin/t/{tenant}/backup-schedules` and `/admin/t/{tenant}/backup-schedules/{schedule}/edit` | Existing create and schedule-run header actions remain | Existing clickable-row or edit inspect path remains primary | Existing schedule actions remain grouped under More or detail headers | Existing grouped bulk actions remain | Existing backup empty-state CTA remains | Existing schedule detail actions remain | Existing create and edit flows remain unchanged | Existing backup audit semantics remain | Canonical run wording changes only |
| Managed tenant onboarding wizard | Existing onboarding routes at `/admin/onboarding` and `/admin/onboarding/{onboardingDraft}` | Existing staged wizard header actions remain | Existing staged wizard progression remains the inspect path | Existing inline stage actions remain limited to the current step | none | Existing onboarding empty-state and resume affordances remain | Existing stage actions remain | Existing staged save and resume flow remains unchanged | Existing onboarding audit semantics remain | Wizard exception remains explicit; the next step must stay obvious |
### Key Entities *(include if feature involves data)*
- **Canonical Platform Vocabulary Glossary**: The maintained source of truth that defines platform-core terms, ownership boundaries, and the canonical names contributors must use.
- **Platform-near Governed Subject Discriminator**: Any persisted or contract-level discriminator on a platform-facing surface that identifies what kind of governed subject a record refers to.
- **Canonical Operation Type**: The domain-aware identifier used by platform operation catalogs, monitoring surfaces, and run semantics.
- **Platform Reason Family**: The reusable platform-wide explanation category set used to describe cross-domain causes such as unsupported scope or provider unavailability.
- **Domain Reason Ownership Signal**: The explicit owner namespace and original domain code that identify a cause as Intune-owned or otherwise domain-owned rather than platform-core.
- **Registry Ownership Boundary**: The documented distinction between platform-wide registries and domain-owned registries or catalogs.
## Verification Scope Inventory
For `FR-204-024` and `SC-001` through `SC-005`, "touched", "reviewed", and "focused regression coverage" refer only to:
- the operator-facing surfaces enumerated in the Decision-First Surface Role, UI/UX Surface Classification, Operator Surface Contract, and UI Action Matrix tables
- the platform-near contract families enumerated in the Canonical Vocabulary Appendix
- the platform-owned or platform-near payloads, filters, summaries, and translation envelopes that directly serve those surfaces
No other repository surface, contract, or adapter is implicitly included in those measurements.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In the reviewed platform-core and platform-near contracts touched by this feature, 100% of universal governed-subject discriminators no longer rely on Intune-only `policy_type` wording.
- **SC-002**: In regression coverage for touched monitoring and review flows, 100% of supported historical and canonical operation-type values resolve to the correct operator label and domain grouping during transition.
- **SC-003**: In regression coverage for touched explanation surfaces, 100% of platform reason families remain domain-neutral and 100% of domain-local causes appear only as namespaced or domain-owned detail.
- **SC-004**: Architecture review can classify every touched registry, catalog, and reason family as `platform_core`, `cross_domain_governance`, or `intune_specific` using the maintained glossary or boundary note alone.
- **SC-005**: Current Intune-first operation, compare, evidence, review, and reporting behavior remains unchanged in focused regression coverage except for the intended vocabulary hardening on platform-core surfaces.
## Rollout Strategy
### Phase 1 - Introduce canonical vocabulary
- Publish the maintained glossary or boundary note.
- Define the canonical operation-type model.
- Define platform-vs-domain reason-code ownership and registry ownership rules.
### Phase 2 - Harden platform-core surfaces
- Update platform-near discriminators and platform summaries where the old wording is misleading.
- Update operation catalogs, labels, and grouping semantics.
- Harden touched compare, evidence, review, and reporting contracts to use canonical platform vocabulary.
### Phase 3 - Transitional compatibility
- Support limited mapping from legacy operation-type names or discriminator aliases where historical values already exist.
- Keep compatibility explicitly temporary and documented.
- Prevent new code added within scope from reintroducing legacy platform-core vocabulary.
### Phase 4 - Remove ambiguity
- Remove unnecessary dual names once rollout is stable.
- Retire temporary aliases and mapping where feasible.
- Keep only the canonical platform names documented as current truth.
## Non-Goals
- Implementing a new governance domain
- Redesigning baseline scope after Spec 202
- Replacing compare strategy extraction from Spec 203
- Generalizing Intune-owned adapter models into vague platform abstractions
- Renaming every historical Intune table, field, or model in the repository
- Reworking backup and restore into a generic cross-domain engine
- Redesigning user-facing copy across every page unless needed to stop platform-core leakage
- Building a universal plugin framework for every registry or catalog
## Assumptions
- Spec 202 provides the canonical governed-subject vocabulary the platform should prefer for cross-domain semantics.
- Spec 203 provides the compare boundary that this vocabulary hardening can align with rather than replace.
- Intune remains the first real governance domain during this rollout, so preserving correct Intune-native terminology is part of the success condition.
- Historical operation-type values and some platform-near discriminators may already exist and may require temporary mapping rather than immediate destructive rewrite.
- Current monitoring, compare, evidence, review, and reporting surfaces are the operator truth surfaces that most need stable platform vocabulary.
## Dependencies
- Spec 202 - Governance Subject Taxonomy and Baseline Scope V2
- Spec 203 - Baseline Compare Engine Strategy Extraction
- Existing Monitoring operations list and operation detail surfaces
- Existing compare, evidence, review, and reporting summaries touched by platform-core labels or explanation semantics
- Existing Intune adapters, catalogs, and metadata as the baseline behavior and vocabulary that must remain intact where domain-owned
## Risks
- The work could drift into a broad rename sweep instead of targeted platform hardening.
- Intune-specific objects could be renamed into vague generic language, reducing clarity instead of improving it.
- Compatibility support could become permanent and leave dual vocabulary in place.
- Targeted persisted renames could create unnecessary migration churn if the platform-near boundary is not explicit enough.
- Operation-type changes could break monitoring, filtering, or reporting if mapping and regression coverage are incomplete.
## Definition of Done
- The codebase has one maintained canonical platform vocabulary for governed subjects, operation types, reason ownership, and registry ownership.
- Platform-core and platform-near surfaces touched by this spec no longer communicate that everything is an Intune policy.
- Intune-owned adapter surfaces remain clearly and intentionally Intune-specific.
- Operation typing is domain-aware, future-safe, and backward-compatible during the documented transition.
- Platform reason semantics are cleanly separated from domain-specific cause vocabularies.
- Registry and catalog ownership is obvious to contributors.
- Focused regression coverage proves no current Intune-first behavior regressed because of the hardening.
- Documentation makes platform-core versus domain-owned boundaries obvious for future contributors.

View File

@ -0,0 +1,247 @@
# Tasks: Platform Core Vocabulary Hardening
**Input**: Design documents from `/specs/204-platform-core-vocabulary-hardening/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/platform-core-vocabulary-hardening.logical.openapi.yaml`, `quickstart.md`
**Tests**: Required. This feature changes runtime vocabulary resolution, operation-run presentation, reason translation, compare and snapshot rendering, and contributor-facing boundary contracts, so Pest unit, feature, Filament, and architecture coverage must be added or extended.
**Operations**: This feature reuses the existing `OperationRun` presentation, monitoring widgets, reporting widgets, and launch surfaces only. No new run type, queued notification channel, lifecycle transition path, workflow hub, or alternate monitoring hub should be introduced; touched run labels, filters, summaries, widget copy, launch labels, and audit prose must continue to resolve through the canonical Monitoring and existing owning surfaces.
**Monitoring Render Contract**: Touched monitoring and review surfaces must remain DB-only at render time; vocabulary hardening must not add provider, Graph, or other external calls as a side effect of rendering, filtering, summarizing, or opening detail pages.
**RBAC**: Existing platform, workspace, and tenant authorization boundaries remain authoritative. Tasks must preserve the current `/system` platform-guard capability semantics, keep `404` versus `403` behavior intact, and ensure clearer labels or explanations do not leak hidden tenant, workspace, or platform-console context.
**Operator Surfaces**: The affected surfaces are the existing monitoring operations list and run detail, the tenant dashboard recent-operations widget, the system Control Tower widgets, tenant baseline compare landing and matrix, evidence snapshot resource and snapshot presentation, tenant review resource, review-pack reporting summaries and widgets, and the existing provider connection, inventory item, backup schedule, and onboarding launch surfaces.
**Filament UI Action Surfaces**: No new action plane, destructive action, or global search behavior is introduced. Existing inspect or open affordances remain primary and touched copy must stay aligned to the current surface roles.
**Proportionality**: Add only the narrow glossary, alias, reason-ownership, and subject-descriptor contracts under the existing governance, operation, compare, and reason-translation seams. Prefer extensions to current files and introduce standalone support types only where the same meaning would otherwise be duplicated across multiple touched surfaces. Do not expand into a repo-wide rename sweep or a second platform vocabulary framework.
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1 -> US2 -> US3 -> US4`, with `US1` as the MVP cut after the shared vocabulary foundation is in place.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Prepare focused test entry points for the glossary, operation vocabulary resolution, and platform subject normalization.
- [X] T001 Create the platform glossary and registry ownership test scaffolds in `apps/platform/tests/Unit/Support/Governance/PlatformVocabularyGlossaryTest.php` and `apps/platform/tests/Unit/Support/Governance/RegistryOwnershipDescriptorTest.php`
- [X] T002 [P] Create the canonical operation vocabulary and alias resolution test scaffold in `apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php`
- [X] T003 [P] Create the platform subject normalization and vocabulary boundary guard test scaffolds in `apps/platform/tests/Unit/Support/Governance/PlatformSubjectDescriptorNormalizerTest.php` and `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php`
**Checkpoint**: Dedicated Spec 204 test entry points exist and the feature can proceed without mixing the first slice into unrelated suites.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the shared glossary, alias, reason-owner, and subject-descriptor contracts before any story-specific integrations land.
**CRITICAL**: No user story work should start before this phase is complete.
- [X] T004 [P] Add foundational glossary and registry ownership expectations in `apps/platform/tests/Unit/Baselines/GovernanceSubjectTaxonomyRegistryTest.php`, `apps/platform/tests/Unit/Support/Governance/PlatformVocabularyGlossaryTest.php`, and `apps/platform/tests/Unit/Support/Governance/RegistryOwnershipDescriptorTest.php`
- [X] T005 [P] Add foundational canonical operation alias and filter-resolution expectations in `apps/platform/tests/Unit/Support/OperationTypeResolutionTest.php`, `apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php`, and `apps/platform/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php`
- [X] T006 [P] Add foundational reason-owner and platform-family expectations in `apps/platform/tests/Unit/Support/ReasonTranslation/ReasonResolutionEnvelopeTest.php`, `apps/platform/tests/Unit/Support/ReasonTranslation/ProviderReasonTranslationTest.php`, and `apps/platform/tests/Unit/Support/ReasonTranslation/RbacReasonTranslationTest.php`
- [X] T007 [P] Add foundational platform-subject normalization and no-leakage guard expectations in `apps/platform/tests/Unit/Support/Governance/PlatformSubjectDescriptorNormalizerTest.php`, `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php`, and `apps/platform/tests/Feature/Authorization/ReasonTranslationScopeSafetyTest.php`
- [X] T008 Implement the minimal glossary, registry ownership, and subject-descriptor support under the existing governance seam in `apps/platform/app/Support/Governance/PlatformVocabularyGlossary.php`, `apps/platform/app/Support/Governance/PlatformVocabularyTerm.php`, `apps/platform/app/Support/Governance/RegistryOwnershipDescriptor.php`, `apps/platform/app/Support/Governance/PlatformSubjectDescriptor.php`, `apps/platform/app/Support/Governance/SubjectDescriptorNormalizationResult.php`, and `apps/platform/app/Support/Governance/PlatformSubjectDescriptorNormalizer.php`
- [X] T009 Implement the minimal canonical operation type, alias, and resolution support under the existing operation seam in `apps/platform/app/Support/CanonicalOperationType.php`, `apps/platform/app/Support/OperationTypeAlias.php`, `apps/platform/app/Support/OperationTypeResolution.php`, `apps/platform/app/Support/OperationCatalog.php`, and `apps/platform/app/Support/OperationRunType.php`
- [X] T010 Implement the minimal platform reason family and reason-ownership metadata under the existing translation seam in `apps/platform/app/Support/ReasonTranslation/PlatformReasonFamily.php`, `apps/platform/app/Support/ReasonTranslation/ReasonOwnershipDescriptor.php`, `apps/platform/app/Support/ReasonTranslation/ReasonResolutionEnvelope.php`, and `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php`
- [X] T011 Wire foundational ownership and compatibility bootstrap into `apps/platform/app/Support/Governance/GovernanceSubjectTaxonomyRegistry.php`, `apps/platform/app/Support/Baselines/BaselineScope.php`, `apps/platform/app/Support/Providers/ProviderReasonCodes.php`, and `apps/platform/config/tenantpilot.php`
**Checkpoint**: The repo can model canonical platform vocabulary, resolve one canonical operation meaning from legacy values, classify translated reasons by owner and family, and normalize platform-near subject descriptors without changing Intune-owned persistence.
---
## Phase 3: User Story 1 - Remove false-universal Intune language from platform surfaces (Priority: P1) 🎯 MVP
**Goal**: Harden platform-near compare, snapshot, evidence, and filter contracts so they prefer governed-subject descriptors instead of false-universal `policy_type` wording.
**Independent Test**: Baseline compare, evidence, and snapshot surfaces render governed-subject descriptors by default while any Intune-owned fallback data remains secondary and compatibility-only.
### Tests for User Story 1
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T012 [P] [US1] Extend snapshot and evidence rendering coverage in `apps/platform/tests/Unit/Baselines/SnapshotRendering/BaselineSnapshotPresenterTest.php`, `apps/platform/tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php`, `apps/platform/tests/Feature/Filament/BaselineSnapshotResolvedReferencePresentationTest.php`, and `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
- [X] T013 [P] [US1] Extend compare and review vocabulary coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareDriftEvidenceContractTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`, and `apps/platform/tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
### Implementation for User Story 1
- [X] T014 [US1] Implement governed-subject descriptor normalization for compare and snapshot payloads in `apps/platform/app/Support/Governance/PlatformSubjectDescriptorNormalizer.php`, `apps/platform/app/Support/Baselines/Compare/CompareSubjectProjection.php`, and `apps/platform/app/Support/Baselines/Compare/CompareSubjectResult.php`
- [X] T015 [US1] Replace false-universal `policy_type` presentation in baseline snapshot and evidence surfaces in `apps/platform/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php`, `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, and `apps/platform/app/Support/Filament/FilterOptionCatalog.php`
- [X] T016 [US1] Harden compare and review surfaces and normalize platform-owned persisted compare context to prefer governed-subject vocabulary in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Models/OperationRun.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`, `apps/platform/app/Support/ReasonTranslation/ReasonPresenter.php`, `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
- [X] T017 [US1] Keep legacy subject aliases compatibility-only in `apps/platform/app/Support/Baselines/BaselineScope.php`, `apps/platform/app/Support/Governance/PlatformVocabularyGlossary.php`, and `apps/platform/app/Support/Governance/PlatformSubjectDescriptorNormalizer.php`
**Checkpoint**: Platform-near compare, snapshot, and evidence surfaces are independently functional with governed-subject vocabulary and explicit compatibility fallbacks.
---
## Phase 4: User Story 2 - Keep monitoring and review semantics clear during transition (Priority: P1)
**Goal**: Keep canonical operation labels, legacy alias resolution, and platform-versus-domain explanation semantics clear across monitoring, run-detail, review, and reporting surfaces during rollout.
**Independent Test**: Historical and canonical operation types render the same operator meaning across monitoring, review, and reporting summaries, and explanation surfaces separate platform reason families from domain-owned detail without changing access semantics.
### Tests for User Story 2
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T018 [P] [US2] Extend canonical operation label, canonical `operation_type` read-path, DB-only render, query-shape guard, and filter continuity coverage across monitoring widgets and launch surfaces in `apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php`, `apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `apps/platform/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/System/Spec114/ControlTowerDashboardTest.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, `apps/platform/tests/Feature/Filament/InventoryItemResourceTest.php`, `apps/platform/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php`, and `apps/platform/tests/Feature/Onboarding/OnboardingEntryPointTest.php`
- [X] T019 [P] [US2] Extend platform-versus-domain explanation layering coverage across review and reporting surfaces in `apps/platform/tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php`, `apps/platform/tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php`, `apps/platform/tests/Feature/Authorization/ReasonTranslationScopeSafetyTest.php`, `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php`, and `apps/platform/tests/Feature/ReviewPack/ReviewPackWidgetTest.php`
### Implementation for User Story 2
- [X] T020 [US2] Wire canonical operation resolution and canonical-code emission into touched run producers and monitoring labels using in-process resolution helpers and without adding remote render-time work in `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/OperationRunType.php`, `apps/platform/app/Services/Baselines/BaselineCompareService.php`, `apps/platform/app/Services/Baselines/BaselineCaptureService.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, `apps/platform/app/Services/Inventory/InventorySyncService.php`, `apps/platform/app/Services/ReviewPackService.php`, `apps/platform/app/Services/TenantReviews/TenantReviewService.php`, `apps/platform/app/Services/BackupScheduling/BackupScheduleDispatcher.php`, `apps/platform/app/Services/Directory/EntraGroupSyncService.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Filament/System/Pages/Ops/Runs.php`, and `apps/platform/app/Filament/Widgets/Dashboard/RecentOperations.php`
- [X] T021 [US2] Normalize legacy and canonical operation labels and expose canonical `operation_type` across touched monitoring widgets, summaries, filters, audit prose, launch surfaces, and exports without introducing read-path query fan-out in `apps/platform/app/Filament/System/Widgets/ControlTowerRecentFailures.php`, `apps/platform/app/Filament/System/Widgets/ControlTowerTopOffenders.php`, `apps/platform/app/Services/Audit/AuditEventBuilder.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`, `apps/platform/app/Filament/Resources/BackupScheduleResource.php`, and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [X] T022 [US2] Add explicit reason-owner and platform-family rendering to translated explanations in `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php`, `apps/platform/app/Support/ReasonTranslation/ReasonPresenter.php`, `apps/platform/app/Support/ReasonTranslation/ReasonResolutionEnvelope.php`, `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, and `apps/platform/app/Support/Baselines/BaselineCompareReasonCode.php`
- [X] T023 [US2] Keep review and reporting surface copy and filter semantics canonical during the transition in `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource.php`, and `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php`
**Checkpoint**: Monitoring, run detail, and review explanations are independently functional with canonical operation resolution and explicit platform-versus-domain reason semantics.
---
## Phase 5: User Story 3 - Make platform and domain ownership obvious to future contributors (Priority: P2)
**Goal**: Give contributors one maintained boundary reference so they can classify touched registries, terms, and reason families as `platform_core`, `cross_domain_governance`, or `intune_specific` without reverse-engineering historical Intune assumptions.
**Independent Test**: The maintained glossary plus architecture guards are enough to classify touched registries and reason families as `platform_core`, `cross_domain_governance`, or `intune_specific` without relying on historical Intune knowledge.
### Tests for User Story 3
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T024 [P] [US3] Extend glossary and registry ownership contract coverage for explicit three-way boundary classification in `apps/platform/tests/Unit/Support/Governance/PlatformVocabularyGlossaryTest.php`, `apps/platform/tests/Unit/Support/Governance/RegistryOwnershipDescriptorTest.php`, and `apps/platform/tests/Unit/Baselines/GovernanceSubjectTaxonomyRegistryTest.php`
- [X] T025 [P] [US3] Extend contributor-boundary guard coverage in `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php` and `apps/platform/tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php`
### Implementation for User Story 3
- [X] T026 [US3] Expose contributor-facing glossary lookup helpers and canonical term inventory from the foundational glossary in `apps/platform/app/Support/Governance/PlatformVocabularyGlossary.php`, `apps/platform/app/Support/Governance/PlatformVocabularyTerm.php`, and `apps/platform/app/Support/Governance/GovernanceSubjectTaxonomyRegistry.php`
- [X] T027 [US3] Expose contributor-facing ownership metadata and canonical noun references from the foundational registries in `apps/platform/app/Support/Governance/RegistryOwnershipDescriptor.php`, `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/Providers/ProviderReasonCodes.php`, and `apps/platform/config/tenantpilot.php`
- [X] T028 [US3] Expose contributor-safe three-way boundary helpers for operation, reason, and subject vocabulary classification in `apps/platform/app/Support/Governance/PlatformVocabularyGlossary.php`, `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php`, `apps/platform/app/Support/RbacReason.php`, and `apps/platform/app/Support/Tenants/TenantOperabilityReasonCode.php`
- [X] T029 [US3] Encode canonical-name and retirement metadata for touched aliases in `apps/platform/app/Support/OperationTypeAlias.php`, `apps/platform/app/Support/OperationCatalog.php`, and `apps/platform/app/Support/Governance/PlatformVocabularyGlossary.php`
**Checkpoint**: Contributors can independently classify touched terms, registries, and reason families using the maintained glossary and guardrails alone.
---
## Phase 6: User Story 4 - Preserve Intune-first behavior while hardening boundaries (Priority: P2)
**Goal**: Keep compatibility mapping and explicit ownership from changing current Intune-first operation, compare, evidence, and review behavior beyond the intended vocabulary hardening.
**Independent Test**: Existing Intune-first flows behave the same except for clearer platform vocabulary, and legacy operation values or policy-type data still resolve correctly during the documented transition.
### Tests for User Story 4
> **NOTE**: Write these tests first and confirm they fail before implementation.
- [X] T030 [P] [US4] Extend Intune-first no-regression coverage for compare and review surfaces in `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`, and `apps/platform/tests/Feature/Filament/BaselineSnapshotFallbackRenderingTest.php`
- [X] T031 [P] [US4] Extend compatibility and domain-owned vocabulary preservation coverage in `apps/platform/tests/Unit/Support/ReasonTranslation/TenantOperabilityReasonTranslationTest.php`, `apps/platform/tests/Unit/Support/ReasonTranslation/ExecutionDenialReasonTranslationTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php`
### Implementation for User Story 4
- [X] T032 [US4] Preserve Intune-owned reason and policy vocabulary where ownership is explicit in `apps/platform/app/Support/Providers/ProviderReasonCodes.php`, `apps/platform/app/Support/RbacReason.php`, `apps/platform/app/Support/Tenants/TenantOperabilityReasonCode.php`, `apps/platform/app/Support/Operations/ExecutionDenialReasonCode.php`, and `apps/platform/app/Support/Baselines/BaselineCompareReasonCode.php`
- [X] T033 [US4] Keep historical operation aliases and legacy subject discriminators readable during rollout across platform-owned context and evidence payloads in `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/OperationTypeResolution.php`, `apps/platform/app/Support/Governance/PlatformSubjectDescriptorNormalizer.php`, `apps/platform/app/Models/OperationRun.php`, `apps/platform/app/Support/ReasonTranslation/ReasonPresenter.php`, and `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`
- [X] T034 [US4] Recheck Intune-first compare, evidence, monitoring, and reporting continuity in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Services/Audit/AuditEventBuilder.php`, `apps/platform/app/Filament/Resources/TenantReviewResource.php`, and `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php`
**Checkpoint**: The platform boundary is clearer while current Intune-first operator behavior remains independently functional and compatibility-safe.
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Lock the slice down with final copy review, architecture guardrails, and focused Sail verification.
- [X] T035 [P] Recheck operator-facing canonical naming, copy alignment, touched widget and launch-surface semantics, and list-surface checklist compliance in `apps/platform/app/Filament/Resources/OperationRunResource.php`, `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Filament/System/Widgets/ControlTowerRecentFailures.php`, `apps/platform/app/Filament/System/Widgets/ControlTowerTopOffenders.php`, `apps/platform/app/Filament/Widgets/Dashboard/RecentOperations.php`, `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantReviewPackCard.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`, `apps/platform/app/Filament/Resources/BackupScheduleResource.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, and `docs/product/standards/list-surface-review-checklist.md`
- [X] T036 [P] Extend final vocabulary, in-process resolution, and query-shape guardrails against false-universal platform leakage on touched read paths in `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php`, `apps/platform/tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php`, and `apps/platform/tests/Feature/Authorization/ReasonTranslationScopeSafetyTest.php`
- [X] T037 Run the full spec-specific Sail verification pack from `specs/204-platform-core-vocabulary-hardening/quickstart.md` against `apps/platform/tests/Unit/Support/Governance/PlatformVocabularyGlossaryTest.php`, `apps/platform/tests/Unit/Support/Governance/RegistryOwnershipDescriptorTest.php`, `apps/platform/tests/Unit/Baselines/GovernanceSubjectTaxonomyRegistryTest.php`, `apps/platform/tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php`, `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php`, `apps/platform/tests/Feature/Authorization/ReasonTranslationScopeSafetyTest.php`, `apps/platform/tests/Unit/Support/ReasonTranslation/ReasonResolutionEnvelopeTest.php`, `apps/platform/tests/Unit/Support/ReasonTranslation/ProviderReasonTranslationTest.php`, `apps/platform/tests/Unit/Support/ReasonTranslation/RbacReasonTranslationTest.php`, `apps/platform/tests/Unit/Support/ReasonTranslation/TenantOperabilityReasonTranslationTest.php`, `apps/platform/tests/Unit/Support/ReasonTranslation/ExecutionDenialReasonTranslationTest.php`, `apps/platform/tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php`, `apps/platform/tests/Feature/ReasonTranslation/ReasonTranslationExplanationTest.php`, `apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php`, `apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `apps/platform/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/System/Spec114/ControlTowerDashboardTest.php`, `apps/platform/tests/Unit/Baselines/SnapshotRendering/BaselineSnapshotPresenterTest.php`, `apps/platform/tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php`, `apps/platform/tests/Feature/Filament/BaselineSnapshotResolvedReferencePresentationTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareDriftEvidenceContractTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareExplanationSurfaceTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Filament/BaselineSnapshotFallbackRenderingTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareDriftEvidenceContractRbacTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewExplanationSurfaceTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackGenerationTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackWidgetTest.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, `apps/platform/tests/Feature/Filament/InventoryItemResourceTest.php`, `apps/platform/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php`, and `apps/platform/tests/Feature/Onboarding/OnboardingEntryPointTest.php`
- [X] T038 Run formatting and final regression verification in `apps/platform/app/Support/Governance/PlatformVocabularyGlossary.php`, `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php`, and `apps/platform/tests/Architecture/PlatformVocabularyBoundaryGuardTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational completion; this is the recommended MVP cut.
- **User Story 2 (Phase 4)**: Depends on Foundational completion and is easiest to review after US1 proves platform-near vocabulary hardening is stable.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from US1 plus US2 because the contributor glossary is clearer once subject and operation boundaries are already explicit.
- **User Story 4 (Phase 6)**: Depends on Foundational completion and should follow US1 plus US2 so compatibility work validates the already-hardened platform surfaces.
- **Polish (Phase 7)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **US1**: No dependencies beyond Foundational.
- **US2**: No hard dependency beyond Foundational, but it should follow US1 so monitoring and review semantics build on hardened subject vocabulary.
- **US3**: Depends on the shared glossary, alias, and reason-owner contracts from Foundational.
- **US4**: Depends on the shared contracts from Foundational and should follow US1 plus US2 so compatibility rules validate the final platform-facing behavior.
### Within Each User Story
- Write the story tests first and confirm they fail before implementation.
- Keep glossary, alias, and reason-owner work inside the existing governance, operation, and reason-translation seams; no parallel vocabulary framework should be introduced.
- Finish each story's focused verification before moving to the next priority.
### Parallel Opportunities
- `T002` and `T003` can run in parallel after `T001`.
- `T004`, `T005`, `T006`, and `T007` can run in parallel before `T008` through `T011`.
- Within US1, `T012` and `T013` can run in parallel.
- Within US2, `T018` and `T019` can run in parallel.
- Within US3, `T024` and `T025` can run in parallel.
- Within US4, `T030` and `T031` can run in parallel.
- `T035` and `T036` can run in parallel once implementation is complete.
---
## Parallel Example: User Story 1
```bash
# Parallel test pass for US1
T012 Extend snapshot and evidence rendering coverage
T013 Extend compare and review vocabulary coverage
```
## Parallel Example: User Story 2
```bash
# Parallel test pass for US2
T018 Extend canonical operation label, canonical operation_type read-path, DB-only render, query-shape guard, and filter continuity coverage
T019 Extend platform-versus-domain explanation layering coverage
```
## Parallel Example: User Story 3
```bash
# Parallel test pass for US3
T024 Extend glossary and registry ownership contract coverage
T025 Extend contributor-boundary guard coverage
```
## Parallel Example: User Story 4
```bash
# Parallel test pass for US4
T030 Extend Intune-first no-regression coverage for compare and review surfaces
T031 Extend compatibility and domain-owned vocabulary preservation coverage
```
---
## Implementation Strategy
### MVP First
1. Finish Setup and Foundational work.
2. Deliver US1 to remove false-universal Intune wording from platform-near compare, snapshot, and evidence surfaces.
3. Validate US1 independently before widening the slice.
### Incremental Delivery
1. Add US2 to keep monitoring, run detail, and review semantics stable during the transition.
2. Add US3 to make platform and domain ownership obvious for future contributors.
3. Add US4 to prove compatibility and Intune-first behavior remain intact.
4. Finish with copy review, guardrails, focused Sail verification, and formatting in Phase 7.
### Parallel Team Strategy
1. One contributor completes Setup and Foundational tasks.
2. After Foundation is green:
- Contributor A takes US1.
- Contributor B takes US2.
- Contributor C takes US3.
- Contributor D prepares US4 compatibility and no-regression work.
3. Merge back for Phase 7 copy review, guardrails, focused verification, and formatting.