Compare commits
2 Commits
204-platfo
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| bb72a54e84 | |||
| ad16eee591 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -182,6 +182,10 @@ ## 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)
|
||||
- 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)
|
||||
- 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 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services (205-compare-job-cleanup)
|
||||
- PostgreSQL via existing baseline snapshots, baseline snapshot items, inventory items, `operation_runs`, findings, and current run-context JSON; no new storage planned (205-compare-job-cleanup)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -216,8 +220,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 205-compare-job-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services
|
||||
- 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
|
||||
- 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 END -->
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -203,6 +204,10 @@ public function refreshStats(): void
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$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);
|
||||
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
|
||||
? (int) $evidenceGapSummary['count']
|
||||
@ -276,6 +281,7 @@ protected function getViewData(): array
|
||||
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
||||
'whyNoFindingsColor' => $whyNoFindingsColor,
|
||||
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
|
||||
'reasonSemantics' => $reasonSemantics,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -130,15 +130,15 @@ public function form(Schema $schema): Schema
|
||||
])
|
||||
->schema([
|
||||
Select::make('draftSelectedPolicyTypes')
|
||||
->label('Policy types')
|
||||
->label('Governed subjects')
|
||||
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
|
||||
->multiple()
|
||||
->searchable()
|
||||
->preload()
|
||||
->native(false)
|
||||
->placeholder('All policy types')
|
||||
->placeholder('All governed subjects')
|
||||
->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)
|
||||
->extraFieldWrapperAttributes([
|
||||
'data-testid' => 'matrix-policy-type-filter',
|
||||
@ -426,7 +426,7 @@ public function activeFilterSummary(): array
|
||||
$summary = [];
|
||||
|
||||
if ($this->selectedPolicyTypes !== []) {
|
||||
$summary['Policy types'] = count($this->selectedPolicyTypes);
|
||||
$summary['Governed subjects'] = count($this->selectedPolicyTypes);
|
||||
}
|
||||
|
||||
if ($this->selectedStates !== []) {
|
||||
@ -452,7 +452,7 @@ public function stagedFilterSummary(): array
|
||||
$summary = [];
|
||||
|
||||
if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) {
|
||||
$summary['Policy types'] = count($this->draftSelectedPolicyTypes);
|
||||
$summary['Governed subjects'] = count($this->draftSelectedPolicyTypes);
|
||||
}
|
||||
|
||||
if ($this->draftSelectedStates !== $this->selectedStates) {
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
use App\Support\OpsUx\RunDurationInsights;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -219,6 +220,15 @@ public static function table(Table $table): Table
|
||||
->all();
|
||||
|
||||
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')
|
||||
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
|
||||
@ -268,6 +278,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
: null;
|
||||
$artifactTruth = static::artifactTruthEnvelope($record);
|
||||
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
||||
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
|
||||
$restoreContinuation = static::restoreContinuation($record);
|
||||
$supportingGroups = static::supportingGroups(
|
||||
@ -275,6 +286,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
factory: $factory,
|
||||
referencedTenantLifecycle: $referencedTenantLifecycle,
|
||||
operatorExplanation: $operatorExplanation,
|
||||
reasonEnvelope: $reasonEnvelope,
|
||||
primaryNextStep: $primaryNextStep,
|
||||
);
|
||||
|
||||
@ -439,7 +451,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
id: 'baseline_compare_gap_details',
|
||||
kind: 'type_specific_detail',
|
||||
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',
|
||||
viewData: [
|
||||
'summary' => $gapSummary,
|
||||
@ -537,10 +549,12 @@ private static function supportingGroups(
|
||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
?ReasonResolutionEnvelope $reasonEnvelope,
|
||||
array $primaryNextStep,
|
||||
): array {
|
||||
$groups = [];
|
||||
$hasElevatedLifecycleState = static::lifecycleAttentionSummary($record) !== null;
|
||||
$reasonSemantics = app(ReasonPresenter::class)->semantics($reasonEnvelope);
|
||||
|
||||
$guidanceItems = array_values(array_filter([
|
||||
$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([
|
||||
$referencedTenantLifecycle !== null
|
||||
? $factory->keyFact(
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
@ -564,9 +565,12 @@ private static function reviewCompletenessCountLabel(string $state): string
|
||||
private static function summaryPresentation(TenantReview $record): array
|
||||
{
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
$truthEnvelope = static::truthEnvelope($record);
|
||||
$reasonPresenter = app(ReasonPresenter::class);
|
||||
|
||||
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'] : [],
|
||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
@ -131,7 +132,7 @@ protected function getViewData(): array
|
||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||
|
||||
$latestPack = ReviewPack::query()
|
||||
->with('tenantReview')
|
||||
->with(['tenantReview', 'operationRun'])
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
@ -166,9 +167,24 @@ protected function getViewData(): array
|
||||
}
|
||||
|
||||
$failedReason = null;
|
||||
$failedReasonDetail = null;
|
||||
$failedReasonSemantics = null;
|
||||
|
||||
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 : [];
|
||||
$failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error');
|
||||
|
||||
if ($failedReason === null) {
|
||||
$failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
@ -180,6 +196,8 @@ protected function getViewData(): array
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => $downloadUrl,
|
||||
'failedReason' => $failedReason,
|
||||
'failedReasonDetail' => $failedReasonDetail,
|
||||
'failedReasonSemantics' => $failedReasonSemantics,
|
||||
'reviewUrl' => $reviewUrl,
|
||||
];
|
||||
}
|
||||
@ -208,6 +226,8 @@ private function emptyState(): array
|
||||
'canManage' => false,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
'failedReasonDetail' => null,
|
||||
'failedReasonSemantics' => null,
|
||||
'reviewUrl' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -21,19 +20,13 @@
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
||||
use App\Services\Baselines\Evidence\MetaEvidenceProvider;
|
||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
@ -76,11 +69,6 @@ class CompareBaselineToTenantJob implements ShouldQueue
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private array $baselineContentHashCache = [];
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
@ -825,7 +813,7 @@ private function rekeyResolvedEvidenceBySubjectKey(array $currentItems, array $r
|
||||
* captured_versions?: array<string, array{
|
||||
* policy_type: string,
|
||||
* subject_external_id: string,
|
||||
* version: PolicyVersion,
|
||||
* version: \App\Models\PolicyVersion,
|
||||
* observed_at: string,
|
||||
* observed_operation_run_id: ?int
|
||||
* }>
|
||||
@ -855,7 +843,7 @@ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult):
|
||||
$observedOperationRunId = $capturedVersion['observed_operation_run_id'] ?? null;
|
||||
$observedOperationRunId = is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null;
|
||||
|
||||
if (! $version instanceof PolicyVersion || $subjectExternalId === '' || ! is_string($key) || $key === '') {
|
||||
if (! $version instanceof \App\Models\PolicyVersion || $subjectExternalId === '' || ! is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -870,6 +858,7 @@ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult):
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
|
||||
private function completeWithCoverageWarning(
|
||||
OperationRunService $operationRunService,
|
||||
AuditLogger $auditLogger,
|
||||
@ -1423,750 +1412,6 @@ private function truthfulTypesFromContext(array $context, BaselineScope $effecti
|
||||
return $effectiveScope->allTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare baseline items vs current inventory and produce drift results.
|
||||
*
|
||||
* @param array<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
||||
* @param array<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||
* @param array<string, string> $severityMapping
|
||||
* @return array{
|
||||
* drift: array<int, array{change_type: string, severity: string, evidence_fidelity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>,
|
||||
* evidence_gaps: array<string, int>,
|
||||
* rbac_role_definitions: array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
|
||||
* }
|
||||
*/
|
||||
private function computeDrift(
|
||||
Tenant $tenant,
|
||||
int $baselineProfileId,
|
||||
int $baselineSnapshotId,
|
||||
int $compareOperationRunId,
|
||||
int $inventorySyncRunId,
|
||||
array $baselineItems,
|
||||
array $currentItems,
|
||||
array $resolvedCurrentEvidence,
|
||||
array $severityMapping,
|
||||
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
|
||||
DriftHasher $hasher,
|
||||
SettingsNormalizer $settingsNormalizer,
|
||||
AssignmentsNormalizer $assignmentsNormalizer,
|
||||
ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||
ContentEvidenceProvider $contentEvidenceProvider,
|
||||
): array {
|
||||
$drift = [];
|
||||
$evidenceGaps = [];
|
||||
$evidenceGapSubjects = [];
|
||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||
|
||||
$baselinePlaceholderProvenance = EvidenceProvenance::build(
|
||||
fidelity: EvidenceProvenance::FidelityMeta,
|
||||
source: EvidenceProvenance::SourceInventory,
|
||||
observedAt: null,
|
||||
observedOperationRunId: null,
|
||||
);
|
||||
|
||||
$currentMissingProvenance = EvidenceProvenance::build(
|
||||
fidelity: EvidenceProvenance::FidelityMeta,
|
||||
source: EvidenceProvenance::SourceInventory,
|
||||
observedAt: null,
|
||||
observedOperationRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
foreach ($baselineItems as $key => $baselineItem) {
|
||||
$currentItem = $currentItems[$key] ?? null;
|
||||
|
||||
$policyType = (string) ($baselineItem['policy_type'] ?? '');
|
||||
$subjectKey = (string) ($baselineItem['subject_key'] ?? '');
|
||||
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
|
||||
|
||||
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []);
|
||||
$baselinePolicyVersionId = $this->resolveBaselinePolicyVersionId(
|
||||
tenant: $tenant,
|
||||
baselineItem: $baselineItem,
|
||||
baselineProvenance: $baselineProvenance,
|
||||
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
|
||||
);
|
||||
$baselineComparableHash = $this->effectiveBaselineHash(
|
||||
tenant: $tenant,
|
||||
baselineItem: $baselineItem,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
contentEvidenceProvider: $contentEvidenceProvider,
|
||||
);
|
||||
|
||||
if (! is_array($currentItem)) {
|
||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$displayName = $baselineItem['meta_jsonb']['display_name'] ?? null;
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
|
||||
$evidence = $this->buildDriftEvidenceContract(
|
||||
changeType: 'missing_policy',
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
displayName: $displayName,
|
||||
baselineHash: $baselineComparableHash,
|
||||
currentHash: null,
|
||||
baselineProvenance: $baselineProvenance,
|
||||
currentProvenance: $currentMissingProvenance,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: null,
|
||||
summaryKind: 'policy_snapshot',
|
||||
baselineProfileId: $baselineProfileId,
|
||||
baselineSnapshotId: $baselineSnapshotId,
|
||||
compareOperationRunId: $compareOperationRunId,
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$evidence['summary']['kind'] = 'rbac_role_definition';
|
||||
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: null,
|
||||
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
|
||||
currentMeta: [],
|
||||
diffKind: 'missing',
|
||||
);
|
||||
}
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$rbacRoleDefinitionSummary['missing']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'missing_policy',
|
||||
'severity' => $isRbacRoleDefinition
|
||||
? Finding::SEVERITY_HIGH
|
||||
: $this->severityForChangeType($severityMapping, 'missing_policy'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
||||
'baseline_hash' => $baselineComparableHash,
|
||||
'current_hash' => '',
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
||||
|
||||
if ($baselineComparableHash !== $currentEvidence->hash) {
|
||||
$displayName = $currentItem['meta_jsonb']['display_name']
|
||||
?? ($baselineItem['meta_jsonb']['display_name'] ?? null);
|
||||
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
$roleDefinitionDiff = null;
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
if ($baselinePolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($currentPolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$roleDefinitionDiff = $this->resolveRoleDefinitionDiff(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
normalizer: $roleDefinitionNormalizer,
|
||||
);
|
||||
|
||||
if ($roleDefinitionDiff === null) {
|
||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$summaryKind = $isRbacRoleDefinition
|
||||
? 'rbac_role_definition'
|
||||
: $this->selectSummaryKind(
|
||||
tenant: $tenant,
|
||||
policyType: $policyType,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
hasher: $hasher,
|
||||
settingsNormalizer: $settingsNormalizer,
|
||||
assignmentsNormalizer: $assignmentsNormalizer,
|
||||
scopeTagsNormalizer: $scopeTagsNormalizer,
|
||||
);
|
||||
|
||||
$evidence = $this->buildDriftEvidenceContract(
|
||||
changeType: 'different_version',
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
displayName: $displayName,
|
||||
baselineHash: $baselineComparableHash,
|
||||
currentHash: (string) $currentEvidence->hash,
|
||||
baselineProvenance: $baselineProvenance,
|
||||
currentProvenance: $currentEvidence->tenantProvenance(),
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
summaryKind: $summaryKind,
|
||||
baselineProfileId: $baselineProfileId,
|
||||
baselineSnapshotId: $baselineSnapshotId,
|
||||
compareOperationRunId: $compareOperationRunId,
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
if ($isRbacRoleDefinition && is_array($roleDefinitionDiff)) {
|
||||
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
|
||||
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
|
||||
diffKind: (string) $roleDefinitionDiff['diff_kind'],
|
||||
roleDefinitionDiff: $roleDefinitionDiff,
|
||||
);
|
||||
$rbacRoleDefinitionSummary['modified']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'different_version',
|
||||
'severity' => $isRbacRoleDefinition
|
||||
? $this->severityForRoleDefinitionDiff($roleDefinitionDiff)
|
||||
: $this->severityForChangeType($severityMapping, 'different_version'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $currentItem['subject_external_id'],
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
||||
'baseline_hash' => $baselineComparableHash,
|
||||
'current_hash' => $currentEvidence->hash,
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$rbacRoleDefinitionSummary['unchanged']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($currentItems as $key => $currentItem) {
|
||||
if (! array_key_exists($key, $baselineItems)) {
|
||||
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$policyType = (string) ($currentItem['policy_type'] ?? '');
|
||||
$subjectKey = (string) ($currentItem['subject_key'] ?? '');
|
||||
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
|
||||
|
||||
$displayName = $currentItem['meta_jsonb']['display_name'] ?? null;
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
|
||||
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
||||
|
||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$evidence = $this->buildDriftEvidenceContract(
|
||||
changeType: 'unexpected_policy',
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
displayName: $displayName,
|
||||
baselineHash: null,
|
||||
currentHash: (string) $currentEvidence->hash,
|
||||
baselineProvenance: $baselinePlaceholderProvenance,
|
||||
currentProvenance: $currentEvidence->tenantProvenance(),
|
||||
baselinePolicyVersionId: null,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
summaryKind: 'policy_snapshot',
|
||||
baselineProfileId: $baselineProfileId,
|
||||
baselineSnapshotId: $baselineSnapshotId,
|
||||
compareOperationRunId: $compareOperationRunId,
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$evidence['summary']['kind'] = 'rbac_role_definition';
|
||||
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: null,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
baselineMeta: [],
|
||||
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
|
||||
diffKind: 'unexpected',
|
||||
);
|
||||
}
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$rbacRoleDefinitionSummary['unexpected']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'unexpected_policy',
|
||||
'severity' => $isRbacRoleDefinition
|
||||
? Finding::SEVERITY_MEDIUM
|
||||
: $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $currentItem['subject_external_id'],
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
||||
'baseline_hash' => '',
|
||||
'current_hash' => $currentEvidence->hash,
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'drift' => $drift,
|
||||
'evidence_gaps' => $evidenceGaps,
|
||||
'evidence_gap_subjects' => $evidenceGapSubjects,
|
||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{subject_external_id: string, baseline_hash: string} $baselineItem
|
||||
*/
|
||||
private function effectiveBaselineHash(
|
||||
Tenant $tenant,
|
||||
array $baselineItem,
|
||||
?int $baselinePolicyVersionId,
|
||||
ContentEvidenceProvider $contentEvidenceProvider,
|
||||
): string {
|
||||
$storedHash = (string) ($baselineItem['baseline_hash'] ?? '');
|
||||
|
||||
if ($baselinePolicyVersionId === null) {
|
||||
return $storedHash;
|
||||
}
|
||||
|
||||
if (array_key_exists($baselinePolicyVersionId, $this->baselineContentHashCache)) {
|
||||
return $this->baselineContentHashCache[$baselinePolicyVersionId];
|
||||
}
|
||||
|
||||
$baselineVersion = PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($baselinePolicyVersionId);
|
||||
|
||||
if (! $baselineVersion instanceof PolicyVersion) {
|
||||
return $storedHash;
|
||||
}
|
||||
|
||||
$hash = $contentEvidenceProvider->fromPolicyVersion(
|
||||
version: $baselineVersion,
|
||||
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
|
||||
)->hash;
|
||||
|
||||
$this->baselineContentHashCache[$baselinePolicyVersionId] = $hash;
|
||||
|
||||
return $hash;
|
||||
}
|
||||
|
||||
private function resolveBaselinePolicyVersionId(
|
||||
Tenant $tenant,
|
||||
array $baselineItem,
|
||||
array $baselineProvenance,
|
||||
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
|
||||
): ?int {
|
||||
$metaJsonb = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
|
||||
$versionReferenceId = data_get($metaJsonb, 'version_reference.policy_version_id');
|
||||
|
||||
if (is_numeric($versionReferenceId)) {
|
||||
return (int) $versionReferenceId;
|
||||
}
|
||||
|
||||
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
|
||||
$baselineSource = (string) ($baselineProvenance['source'] ?? EvidenceProvenance::SourceInventory);
|
||||
|
||||
if ($baselineFidelity !== EvidenceProvenance::FidelityContent || $baselineSource !== EvidenceProvenance::SourcePolicyVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $baselineProvenance['observed_at'] ?? null;
|
||||
$observedAt = is_string($observedAt) ? trim($observedAt) : null;
|
||||
|
||||
if (! is_string($observedAt) || $observedAt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $baselinePolicyVersionResolver->resolve(
|
||||
tenant: $tenant,
|
||||
policyType: (string) ($baselineItem['policy_type'] ?? ''),
|
||||
subjectKey: (string) ($baselineItem['subject_key'] ?? ''),
|
||||
observedAt: $observedAt,
|
||||
);
|
||||
}
|
||||
|
||||
private function currentPolicyVersionIdFromEvidence(ResolvedEvidence $evidence): ?int
|
||||
{
|
||||
$policyVersionId = $evidence->meta['policy_version_id'] ?? null;
|
||||
|
||||
return is_numeric($policyVersionId) ? (int) $policyVersionId : null;
|
||||
}
|
||||
|
||||
private function selectSummaryKind(
|
||||
Tenant $tenant,
|
||||
string $policyType,
|
||||
?int $baselinePolicyVersionId,
|
||||
?int $currentPolicyVersionId,
|
||||
DriftHasher $hasher,
|
||||
SettingsNormalizer $settingsNormalizer,
|
||||
AssignmentsNormalizer $assignmentsNormalizer,
|
||||
ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||
): string {
|
||||
if ($baselinePolicyVersionId === null || $currentPolicyVersionId === null) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$baselineVersion = PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($baselinePolicyVersionId);
|
||||
|
||||
$currentVersion = PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($currentPolicyVersionId);
|
||||
|
||||
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$platform = is_string($baselineVersion->platform ?? null)
|
||||
? (string) $baselineVersion->platform
|
||||
: (is_string($currentVersion->platform ?? null) ? (string) $currentVersion->platform : null);
|
||||
|
||||
$baselineSnapshot = is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [];
|
||||
$currentSnapshot = is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [];
|
||||
|
||||
$baselineNormalized = $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $baselineSnapshot,
|
||||
policyType: $policyType,
|
||||
platform: $platform,
|
||||
);
|
||||
$currentNormalized = $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $currentSnapshot,
|
||||
policyType: $policyType,
|
||||
platform: $platform,
|
||||
);
|
||||
|
||||
$baselineSnapshotHash = $hasher->hashNormalized([
|
||||
'settings' => $baselineNormalized,
|
||||
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'snapshot'),
|
||||
]);
|
||||
$currentSnapshotHash = $hasher->hashNormalized([
|
||||
'settings' => $currentNormalized,
|
||||
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'snapshot'),
|
||||
]);
|
||||
|
||||
if ($baselineSnapshotHash !== $currentSnapshotHash) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
|
||||
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
|
||||
|
||||
$baselineAssignmentsHash = $hasher->hashNormalized([
|
||||
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineAssignments),
|
||||
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'assignments'),
|
||||
]);
|
||||
$currentAssignmentsHash = $hasher->hashNormalized([
|
||||
'assignments' => $assignmentsNormalizer->normalizeForDiff($currentAssignments),
|
||||
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'assignments'),
|
||||
]);
|
||||
|
||||
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
|
||||
return 'policy_assignments';
|
||||
}
|
||||
|
||||
$baselineScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
|
||||
$currentScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
|
||||
|
||||
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$baselineScopeTagsHash = $hasher->hashNormalized([
|
||||
'scope_tag_ids' => $baselineScopeTagIds,
|
||||
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'scope_tags'),
|
||||
]);
|
||||
$currentScopeTagsHash = $hasher->hashNormalized([
|
||||
'scope_tag_ids' => $currentScopeTagIds,
|
||||
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'scope_tags'),
|
||||
]);
|
||||
|
||||
if ($baselineScopeTagsHash !== $currentScopeTagsHash) {
|
||||
return 'policy_scope_tags';
|
||||
}
|
||||
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
|
||||
{
|
||||
$secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
|
||||
$bucketFingerprints = $secretFingerprints[$bucket] ?? [];
|
||||
|
||||
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int} $baselineProvenance
|
||||
* @param array<string, mixed> $currentProvenance
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildDriftEvidenceContract(
|
||||
string $changeType,
|
||||
string $policyType,
|
||||
string $subjectKey,
|
||||
?string $displayName,
|
||||
?string $baselineHash,
|
||||
?string $currentHash,
|
||||
array $baselineProvenance,
|
||||
array $currentProvenance,
|
||||
?int $baselinePolicyVersionId,
|
||||
?int $currentPolicyVersionId,
|
||||
string $summaryKind,
|
||||
int $baselineProfileId,
|
||||
int $baselineSnapshotId,
|
||||
int $compareOperationRunId,
|
||||
int $inventorySyncRunId,
|
||||
): array {
|
||||
$fidelity = $this->fidelityFromPolicyVersionRefs($baselinePolicyVersionId, $currentPolicyVersionId);
|
||||
|
||||
return [
|
||||
'change_type' => $changeType,
|
||||
'policy_type' => $policyType,
|
||||
'subject_key' => $subjectKey,
|
||||
'display_name' => $displayName,
|
||||
'summary' => [
|
||||
'kind' => $summaryKind,
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_version_id' => $baselinePolicyVersionId,
|
||||
'hash' => $baselineHash,
|
||||
'provenance' => $baselineProvenance,
|
||||
],
|
||||
'current' => [
|
||||
'policy_version_id' => $currentPolicyVersionId,
|
||||
'hash' => $currentHash,
|
||||
'provenance' => $currentProvenance,
|
||||
],
|
||||
'fidelity' => $fidelity,
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
'baseline_snapshot_id' => $baselineSnapshotId,
|
||||
'compare_operation_run_id' => $compareOperationRunId,
|
||||
'inventory_sync_run_id' => $inventorySyncRunId,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baselineMeta
|
||||
* @param array<string, mixed> $currentMeta
|
||||
* @param array{
|
||||
* baseline: array<string, mixed>,
|
||||
* current: array<string, mixed>,
|
||||
* changed_keys: list<string>,
|
||||
* metadata_keys: list<string>,
|
||||
* permission_keys: list<string>,
|
||||
* diff_kind: string,
|
||||
* diff_fingerprint: string
|
||||
* }|null $roleDefinitionDiff
|
||||
* @return array{
|
||||
* diff_kind: string,
|
||||
* diff_fingerprint: string,
|
||||
* changed_keys: list<string>,
|
||||
* metadata_keys: list<string>,
|
||||
* permission_keys: list<string>,
|
||||
* baseline: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed},
|
||||
* current: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed}
|
||||
* }
|
||||
*/
|
||||
private function buildRoleDefinitionEvidencePayload(
|
||||
Tenant $tenant,
|
||||
?int $baselinePolicyVersionId,
|
||||
?int $currentPolicyVersionId,
|
||||
array $baselineMeta,
|
||||
array $currentMeta,
|
||||
string $diffKind,
|
||||
?array $roleDefinitionDiff = null,
|
||||
): array {
|
||||
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
|
||||
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
|
||||
|
||||
$baselineNormalized = is_array($roleDefinitionDiff['baseline'] ?? null)
|
||||
? $roleDefinitionDiff['baseline']
|
||||
: $this->fallbackRoleDefinitionNormalized($baselineVersion, $baselineMeta);
|
||||
$currentNormalized = is_array($roleDefinitionDiff['current'] ?? null)
|
||||
? $roleDefinitionDiff['current']
|
||||
: $this->fallbackRoleDefinitionNormalized($currentVersion, $currentMeta);
|
||||
|
||||
$changedKeys = is_array($roleDefinitionDiff['changed_keys'] ?? null)
|
||||
? array_values(array_filter($roleDefinitionDiff['changed_keys'], 'is_string'))
|
||||
: $this->roleDefinitionChangedKeys($baselineNormalized, $currentNormalized);
|
||||
$metadataKeys = is_array($roleDefinitionDiff['metadata_keys'] ?? null)
|
||||
? array_values(array_filter($roleDefinitionDiff['metadata_keys'], 'is_string'))
|
||||
: array_values(array_diff($changedKeys, $this->roleDefinitionPermissionKeys($changedKeys)));
|
||||
$permissionKeys = is_array($roleDefinitionDiff['permission_keys'] ?? null)
|
||||
? array_values(array_filter($roleDefinitionDiff['permission_keys'], 'is_string'))
|
||||
: $this->roleDefinitionPermissionKeys($changedKeys);
|
||||
|
||||
$resolvedDiffKind = is_string($roleDefinitionDiff['diff_kind'] ?? null)
|
||||
? (string) $roleDefinitionDiff['diff_kind']
|
||||
: $diffKind;
|
||||
$diffFingerprint = is_string($roleDefinitionDiff['diff_fingerprint'] ?? null)
|
||||
? (string) $roleDefinitionDiff['diff_fingerprint']
|
||||
: hash(
|
||||
'sha256',
|
||||
json_encode([
|
||||
'diff_kind' => $resolvedDiffKind,
|
||||
'changed_keys' => $changedKeys,
|
||||
'baseline' => $baselineNormalized,
|
||||
'current' => $currentNormalized,
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
);
|
||||
|
||||
return [
|
||||
'diff_kind' => $resolvedDiffKind,
|
||||
'diff_fingerprint' => $diffFingerprint,
|
||||
'changed_keys' => $changedKeys,
|
||||
'metadata_keys' => $metadataKeys,
|
||||
'permission_keys' => $permissionKeys,
|
||||
'baseline' => [
|
||||
'normalized' => $baselineNormalized,
|
||||
'is_built_in' => data_get($baselineMeta, 'rbac.is_built_in', data_get($baselineMeta, 'is_built_in')),
|
||||
'role_permission_count' => data_get($baselineMeta, 'rbac.role_permission_count', data_get($baselineMeta, 'role_permission_count')),
|
||||
],
|
||||
'current' => [
|
||||
'normalized' => $currentNormalized,
|
||||
'is_built_in' => data_get($currentMeta, 'rbac.is_built_in', data_get($currentMeta, 'is_built_in')),
|
||||
'role_permission_count' => data_get($currentMeta, 'rbac.role_permission_count', data_get($currentMeta, 'role_permission_count')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersionId): ?PolicyVersion
|
||||
{
|
||||
if ($policyVersionId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($policyVersionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
|
||||
{
|
||||
if ($version instanceof PolicyVersion) {
|
||||
return app(IntuneRoleDefinitionNormalizer::class)->buildEvidenceMap(
|
||||
is_array($version->snapshot) ? $version->snapshot : [],
|
||||
is_string($version->platform ?? null) ? (string) $version->platform : null,
|
||||
);
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
$displayName = $meta['display_name'] ?? null;
|
||||
|
||||
if (is_string($displayName) && trim($displayName) !== '') {
|
||||
$normalized['Role definition > Display name'] = trim($displayName);
|
||||
}
|
||||
|
||||
$isBuiltIn = data_get($meta, 'rbac.is_built_in', data_get($meta, 'is_built_in'));
|
||||
if (is_bool($isBuiltIn)) {
|
||||
$normalized['Role definition > Role source'] = $isBuiltIn ? 'Built-in' : 'Custom';
|
||||
}
|
||||
|
||||
$rolePermissionCount = data_get($meta, 'rbac.role_permission_count', data_get($meta, 'role_permission_count'));
|
||||
if (is_numeric($rolePermissionCount)) {
|
||||
$normalized['Role definition > Permission blocks'] = (int) $rolePermissionCount;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baselineNormalized
|
||||
* @param array<string, mixed> $currentNormalized
|
||||
* @return list<string>
|
||||
*/
|
||||
private function roleDefinitionChangedKeys(array $baselineNormalized, array $currentNormalized): array
|
||||
{
|
||||
$keys = array_values(array_unique(array_merge(array_keys($baselineNormalized), array_keys($currentNormalized))));
|
||||
sort($keys, SORT_STRING);
|
||||
|
||||
return array_values(array_filter($keys, fn (string $key): bool => ($baselineNormalized[$key] ?? null) !== ($currentNormalized[$key] ?? null)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $keys
|
||||
* @return list<string>
|
||||
*/
|
||||
private function roleDefinitionPermissionKeys(array $keys): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$keys,
|
||||
fn (string $key): bool => str_starts_with($key, 'Permission block ')
|
||||
));
|
||||
}
|
||||
|
||||
private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
|
||||
{
|
||||
if ($baselinePolicyVersionId !== null && $currentPolicyVersionId !== null) {
|
||||
return 'content';
|
||||
}
|
||||
|
||||
if ($baselinePolicyVersionId !== null || $currentPolicyVersionId !== null) {
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
private function normalizeSubjectKey(
|
||||
string $policyType,
|
||||
?string $storedSubjectKey = null,
|
||||
@ -2182,50 +1427,6 @@ private function normalizeSubjectKey(
|
||||
return BaselineSubjectKey::forPolicy($policyType, $displayName, $subjectExternalId) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* baseline: array<string, mixed>,
|
||||
* current: array<string, mixed>,
|
||||
* changed_keys: list<string>,
|
||||
* metadata_keys: list<string>,
|
||||
* permission_keys: list<string>,
|
||||
* diff_kind: string,
|
||||
* diff_fingerprint: string
|
||||
* }|null
|
||||
*/
|
||||
private function resolveRoleDefinitionDiff(
|
||||
Tenant $tenant,
|
||||
int $baselinePolicyVersionId,
|
||||
int $currentPolicyVersionId,
|
||||
IntuneRoleDefinitionNormalizer $normalizer,
|
||||
): ?array {
|
||||
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
|
||||
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
|
||||
|
||||
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $normalizer->classifyDiff(
|
||||
baselineSnapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
|
||||
currentSnapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [],
|
||||
platform: is_string($currentVersion->platform ?? null)
|
||||
? (string) $currentVersion->platform
|
||||
: (is_string($baselineVersion->platform ?? null) ? (string) $baselineVersion->platform : null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{diff_kind?: string}|null $roleDefinitionDiff
|
||||
*/
|
||||
private function severityForRoleDefinitionDiff(?array $roleDefinitionDiff): string
|
||||
{
|
||||
return match ($roleDefinitionDiff['diff_kind'] ?? null) {
|
||||
'metadata_only' => Finding::SEVERITY_LOW,
|
||||
default => Finding::SEVERITY_HIGH,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
|
||||
*/
|
||||
|
||||
@ -94,13 +94,13 @@ public function table(Table $table): Table
|
||||
->sortable()
|
||||
->wrap()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('policy_type')
|
||||
TextColumn::make('governed_subject_label')
|
||||
->label(__('baseline-compare.evidence_gap_policy_type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType))
|
||||
->formatStateUsing(fn (mixed $state): string => is_string($state) && trim($state) !== '' ? $state : 'Unknown governed subject')
|
||||
->color(fn (mixed $state, Model $record): string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->color)
|
||||
->icon(fn (mixed $state, Model $record): ?string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->icon)
|
||||
->iconColor(fn (mixed $state, Model $record): ?string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->iconColor)
|
||||
->searchable()
|
||||
->sortable()
|
||||
->wrap(),
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationTypeResolution;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
@ -291,6 +292,16 @@ public function inventoryCoverage(): ?InventoryCoverage
|
||||
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
|
||||
{
|
||||
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||
@ -51,6 +52,8 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
||||
static fn (RenderedSnapshotGroup $group): array => [
|
||||
'policyType' => $group->policyType,
|
||||
'label' => $group->label,
|
||||
'governedSubjectLabel' => data_get($group->subjectDescriptor, 'display_label', $group->label),
|
||||
'subjectDescriptor' => $group->subjectDescriptor,
|
||||
'itemCount' => $group->itemCount,
|
||||
'fidelity' => $group->fidelity->value,
|
||||
'gapCount' => $group->gapSummary->count,
|
||||
@ -166,7 +169,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
title: 'Coverage summary',
|
||||
view: 'filament.infolists.entries.baseline-snapshot-summary-table',
|
||||
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(
|
||||
id: 'related_context',
|
||||
@ -179,7 +182,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
$factory->viewSection(
|
||||
id: 'captured_policy_types',
|
||||
kind: 'domain_detail',
|
||||
title: 'Captured policy types',
|
||||
title: 'Captured governed subjects',
|
||||
view: 'filament.infolists.entries.baseline-snapshot-groups',
|
||||
viewData: ['groups' => array_map(
|
||||
static fn (RenderedSnapshotGroup $group): array => $group->toArray(),
|
||||
@ -250,7 +253,8 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
|
||||
$renderer = $this->registry->rendererFor($policyType);
|
||||
$fallbackRenderer = $this->registry->fallbackRenderer();
|
||||
$renderingError = null;
|
||||
$technicalPayload = $this->technicalPayload($items);
|
||||
$subjectDescriptor = $this->subjectDescriptor($policyType);
|
||||
$technicalPayload = $this->technicalPayload($items) + ['subject_descriptor' => $subjectDescriptor];
|
||||
|
||||
try {
|
||||
$renderedItems = $items
|
||||
@ -261,7 +265,7 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
|
||||
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $fallbackRenderer->render($item))
|
||||
->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 */
|
||||
@ -299,6 +303,7 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
|
||||
coverageHint: $coverageHint,
|
||||
capturedAt: is_string($capturedAt) ? $capturedAt : null,
|
||||
technicalPayload: $technicalPayload,
|
||||
subjectDescriptor: $subjectDescriptor,
|
||||
);
|
||||
}
|
||||
|
||||
@ -404,9 +409,28 @@ private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
|
||||
|
||||
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)
|
||||
?? 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
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
/**
|
||||
* @param array<int, RenderedSnapshotItem> $items
|
||||
* @param array<string, mixed> $technicalPayload
|
||||
* @param array<string, mixed> $subjectDescriptor
|
||||
*/
|
||||
public function __construct(
|
||||
public string $policyType,
|
||||
@ -22,6 +23,7 @@ public function __construct(
|
||||
public ?string $coverageHint = null,
|
||||
public ?string $capturedAt = null,
|
||||
public array $technicalPayload = [],
|
||||
public array $subjectDescriptor = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -46,7 +48,8 @@ public function __construct(
|
||||
* renderingError: ?string,
|
||||
* coverageHint: ?string,
|
||||
* capturedAt: ?string,
|
||||
* technicalPayload: array<string, mixed>
|
||||
* technicalPayload: array<string, mixed>,
|
||||
* subjectDescriptor: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
@ -66,6 +69,7 @@ public function toArray(): array
|
||||
'coverageHint' => $this->coverageHint,
|
||||
'capturedAt' => $this->capturedAt,
|
||||
'technicalPayload' => $this->technicalPayload,
|
||||
'subjectDescriptor' => $this->subjectDescriptor,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class BaselineCompareEvidenceGapDetails
|
||||
@ -333,6 +334,8 @@ public static function tableRows(array $buckets): array
|
||||
'reason_code' => $reasonCode,
|
||||
'reason_label' => self::reasonLabel($reasonCode),
|
||||
'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_class' => $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
|
||||
{
|
||||
return collect($rows)
|
||||
->pluck('policy_type')
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->mapWithKeys(fn (string $value): array => [$value => $value])
|
||||
->sortKeysUsing('strnatcasecmp')
|
||||
->filter(fn (array $row): bool => filled($row['policy_type'] ?? null))
|
||||
->mapWithKeys(fn (array $row): array => [
|
||||
(string) $row['policy_type'] => (string) ($row['governed_subject_label'] ?? $row['policy_type']),
|
||||
])
|
||||
->sortBy(fn (string $label): string => Str::lower($label))
|
||||
->all();
|
||||
}
|
||||
|
||||
@ -659,16 +663,20 @@ private static function projectSubjectRow(array $subject): array
|
||||
$subjectClass = (string) $subject['subject_class'];
|
||||
$resolutionOutcome = (string) $subject['resolution_outcome'];
|
||||
$operatorActionCategory = (string) $subject['operator_action_category'];
|
||||
$policyType = (string) ($subject['policy_type'] ?? '');
|
||||
|
||||
return array_merge($subject, [
|
||||
'reason_label' => self::reasonLabel($reasonCode),
|
||||
'governed_subject' => self::subjectDescriptor($policyType),
|
||||
'governed_subject_label' => self::governedSubjectLabel($policyType),
|
||||
'subject_class_label' => self::subjectClassLabel($subjectClass),
|
||||
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
|
||||
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
|
||||
'search_text' => Str::lower(trim(implode(' ', array_filter([
|
||||
$reasonCode,
|
||||
self::reasonLabel($reasonCode),
|
||||
(string) ($subject['policy_type'] ?? ''),
|
||||
$policyType,
|
||||
self::governedSubjectLabel($policyType),
|
||||
(string) ($subject['subject_key'] ?? ''),
|
||||
$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
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
|
||||
@ -128,7 +128,7 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
|
||||
$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.',
|
||||
$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.',
|
||||
$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.',
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
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) !== '')
|
||||
->unique()
|
||||
->sort()
|
||||
->mapWithKeys(static fn (string $type): array => [
|
||||
$type => InventoryPolicyTypeMeta::label($type) ?? $type,
|
||||
->mapWithKeys(fn (string $type): array => [
|
||||
$type => $this->governedSubjectLabel($type),
|
||||
])
|
||||
->all();
|
||||
|
||||
@ -118,7 +119,7 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
|
||||
],
|
||||
'subjectSortOptions' => [
|
||||
'deviation_breadth' => 'Deviation breadth',
|
||||
'policy_type' => 'Policy type',
|
||||
'policy_type' => 'Governed subject',
|
||||
'display_name' => 'Display name',
|
||||
],
|
||||
'stateLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixState, [
|
||||
@ -209,6 +210,8 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
|
||||
$subject = [
|
||||
'subjectKey' => $subjectKey,
|
||||
'policyType' => (string) $item->policy_type,
|
||||
'governedSubjectLabel' => $this->governedSubjectLabel((string) $item->policy_type),
|
||||
'subjectDescriptor' => $this->subjectDescriptor((string) $item->policy_type),
|
||||
'displayName' => $this->subjectDisplayName($item),
|
||||
'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.',
|
||||
'not_compared' => $policyTypeCovered
|
||||
? '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,
|
||||
};
|
||||
}
|
||||
@ -642,6 +645,8 @@ private function subjectSummary(array $subject, array $cells): array
|
||||
return [
|
||||
'subjectKey' => $subject['subjectKey'],
|
||||
'policyType' => $subject['policyType'],
|
||||
'governedSubjectLabel' => $subject['governedSubjectLabel'] ?? $this->governedSubjectLabel((string) $subject['policyType']),
|
||||
'subjectDescriptor' => $subject['subjectDescriptor'] ?? $this->subjectDescriptor((string) $subject['policyType']),
|
||||
'displayName' => $subject['displayName'],
|
||||
'baselineExternalId' => $subject['baselineExternalId'],
|
||||
'deviationBreadth' => $this->countStates($cells, ['differ', 'missing']),
|
||||
@ -809,8 +814,13 @@ private function sortRows(array $rows, string $sort): array
|
||||
$rightSubject = $right['subject'] ?? [];
|
||||
|
||||
return match ($sort) {
|
||||
'policy_type' => [(string) ($leftSubject['policyType'] ?? ''), Str::lower((string) ($leftSubject['displayName'] ?? ''))]
|
||||
<=> [(string) ($rightSubject['policyType'] ?? ''), Str::lower((string) ($rightSubject['displayName'] ?? ''))],
|
||||
'policy_type' => [
|
||||
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'] ?? '')]
|
||||
<=> [Str::lower((string) ($rightSubject['displayName'] ?? '')), (string) ($rightSubject['subjectKey'] ?? '')],
|
||||
default => [
|
||||
@ -914,6 +924,8 @@ private function compactResults(array $rows, array $tenantSummaries): array
|
||||
'subjectKey' => (string) ($subject['subjectKey'] ?? ''),
|
||||
'displayName' => $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject',
|
||||
'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,
|
||||
'deviationBreadth' => (int) ($subject['deviationBreadth'] ?? 0),
|
||||
'missingBreadth' => (int) ($subject['missingBreadth'] ?? 0),
|
||||
@ -974,7 +986,7 @@ private function emptyState(
|
||||
if ($renderedRowsCount === 0) {
|
||||
return [
|
||||
'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,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Support\Governance\GovernanceDomainKey;
|
||||
use App\Support\Governance\GovernanceSubjectClass;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use InvalidArgumentException;
|
||||
|
||||
@ -303,6 +304,16 @@ public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = nul
|
||||
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.
|
||||
*
|
||||
@ -321,6 +332,7 @@ public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard =
|
||||
'all_types' => $allTypes,
|
||||
'selected_type_keys' => $allTypes,
|
||||
'foundations_included' => $expanded->foundationTypes !== [],
|
||||
'governed_subjects' => $expanded->subjectDescriptors(),
|
||||
];
|
||||
|
||||
if (! is_string($operation) || $operation === '') {
|
||||
|
||||
@ -10,6 +10,7 @@ final class CompareSubjectProjection
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $additionalLabels
|
||||
* @param array<string, mixed>|null $subjectDescriptor
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $platformSubjectClass,
|
||||
@ -18,6 +19,7 @@ public function __construct(
|
||||
public readonly string $operatorLabel,
|
||||
public readonly ?string $summaryKind = null,
|
||||
public readonly array $additionalLabels = [],
|
||||
public readonly ?array $subjectDescriptor = null,
|
||||
) {
|
||||
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.');
|
||||
@ -31,7 +33,8 @@ public function __construct(
|
||||
* subject_type_key: string,
|
||||
* operator_label: string,
|
||||
* summary_kind: ?string,
|
||||
* additional_labels: array<string, string>
|
||||
* additional_labels: array<string, string>,
|
||||
* subject_descriptor: ?array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
@ -43,6 +46,7 @@ public function toArray(): array
|
||||
'operator_label' => $this->operatorLabel,
|
||||
'summary_kind' => $this->summaryKind,
|
||||
'additional_labels' => $this->additionalLabels,
|
||||
'subject_descriptor' => $this->subjectDescriptor,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@
|
||||
use App\Support\Baselines\SubjectResolver;
|
||||
use App\Support\Governance\GovernanceDomainKey;
|
||||
use App\Support\Governance\GovernanceSubjectClass;
|
||||
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
@ -557,10 +558,30 @@ private function subjectProjection(string $policyType, string $operatorLabel, ?s
|
||||
summaryKind: $summaryKind,
|
||||
additionalLabels: [
|
||||
'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
|
||||
{
|
||||
return InventoryPolicyTypeMeta::isFoundation($policyType)
|
||||
|
||||
39
apps/platform/app/Support/CanonicalOperationType.php
Normal file
39
apps/platform/app/Support/CanonicalOperationType.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -203,17 +203,7 @@ public static function findingGovernanceAttentionStates(): array
|
||||
*/
|
||||
public static function operationTypes(?iterable $types = null): array
|
||||
{
|
||||
$values = collect($types ?? array_keys(OperationCatalog::labels()))
|
||||
->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();
|
||||
return OperationCatalog::filterOptions($types);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
|
||||
final class GovernanceSubjectTaxonomyRegistry
|
||||
class GovernanceSubjectTaxonomyRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<string, list<string>>
|
||||
@ -76,6 +76,50 @@ public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubj
|
||||
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
|
||||
{
|
||||
return array_key_exists(trim($domainKey), self::DOMAIN_CLASSES);
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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.',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
110
apps/platform/app/Support/Governance/PlatformVocabularyTerm.php
Normal file
110
apps/platform/app/Support/Governance/PlatformVocabularyTerm.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||
use App\Support\Governance\RegistryOwnershipDescriptor;
|
||||
use App\Support\OpsUx\OperationSummaryKeys;
|
||||
|
||||
final class OperationCatalog
|
||||
@ -13,51 +17,34 @@ final class OperationCatalog
|
||||
*/
|
||||
public static function labels(): array
|
||||
{
|
||||
return [
|
||||
'policy.sync' => 'Policy sync',
|
||||
'policy.sync_one' => 'Policy sync',
|
||||
'policy.capture_snapshot' => 'Policy snapshot',
|
||||
'policy.delete' => 'Delete policies',
|
||||
'policy.unignore' => 'Restore policies',
|
||||
'policy.export' => 'Export policies to backup',
|
||||
'provider.connection.check' => 'Provider connection check',
|
||||
'inventory_sync' => 'Inventory sync',
|
||||
'compliance.snapshot' => 'Compliance snapshot',
|
||||
'provider.inventory.sync' => 'Inventory sync',
|
||||
'provider.compliance.snapshot' => 'Compliance snapshot',
|
||||
'entra_group_sync' => 'Directory groups sync',
|
||||
'backup_set.add_policies' => 'Backup set update',
|
||||
'backup_set.remove_policies' => 'Backup set update',
|
||||
'backup_set.delete' => 'Archive backup sets',
|
||||
'backup_set.restore' => 'Restore backup sets',
|
||||
'backup_set.force_delete' => 'Delete backup sets',
|
||||
'backup_schedule_run' => 'Backup schedule run',
|
||||
'backup_schedule_retention' => 'Backup schedule retention',
|
||||
'backup_schedule_purge' => 'Backup schedule purge',
|
||||
'restore.execute' => 'Restore execution',
|
||||
'assignments.fetch' => 'Assignment fetch',
|
||||
'assignments.restore' => 'Assignment restore',
|
||||
'ops.reconcile_adapter_runs' => 'Reconcile adapter runs',
|
||||
'directory_role_definitions.sync' => 'Role definitions sync',
|
||||
'restore_run.delete' => 'Delete restore runs',
|
||||
'restore_run.restore' => 'Restore restore runs',
|
||||
'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',
|
||||
];
|
||||
$labels = [];
|
||||
|
||||
foreach (self::operationAliases() as $alias) {
|
||||
$labels[$alias->rawValue] = self::canonicalDefinitions()[$alias->canonicalCode]->displayLabel;
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{
|
||||
* canonical_code: string,
|
||||
* domain_key: ?string,
|
||||
* artifact_family: ?string,
|
||||
* display_label: string,
|
||||
* supports_operator_explanation: bool,
|
||||
* expected_duration_seconds: ?int
|
||||
* }>
|
||||
*/
|
||||
public static function canonicalInventory(): array
|
||||
{
|
||||
$inventory = [];
|
||||
|
||||
foreach (self::canonicalDefinitions() as $canonicalCode => $definition) {
|
||||
$inventory[$canonicalCode] = $definition->toArray();
|
||||
}
|
||||
|
||||
return $inventory;
|
||||
}
|
||||
|
||||
public static function label(string $operationType): string
|
||||
@ -68,34 +55,12 @@ public static function label(string $operationType): string
|
||||
return 'Operation';
|
||||
}
|
||||
|
||||
return self::labels()[$operationType] ?? 'Unknown operation';
|
||||
return self::resolve($operationType)->canonical->displayLabel;
|
||||
}
|
||||
|
||||
public static function expectedDurationSeconds(string $operationType): ?int
|
||||
{
|
||||
return match (trim($operationType)) {
|
||||
'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,
|
||||
};
|
||||
return self::resolve($operationType)->canonical->expectedDurationSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -108,13 +73,7 @@ public static function allowedSummaryKeys(): array
|
||||
|
||||
public static function governanceArtifactFamily(string $operationType): ?string
|
||||
{
|
||||
return match (trim($operationType)) {
|
||||
'baseline_capture' => 'baseline_snapshot',
|
||||
'tenant.evidence.snapshot.generate' => 'evidence_snapshot',
|
||||
'tenant.review.compose' => 'tenant_review',
|
||||
'tenant.review_pack.generate' => 'review_pack',
|
||||
default => null,
|
||||
};
|
||||
return self::resolve($operationType)->canonical->artifactFamily;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$operationType = trim($operationType);
|
||||
return self::resolve($operationType)->canonical->supportsOperatorExplanation;
|
||||
}
|
||||
|
||||
return self::isGovernanceArtifactOperation($operationType)
|
||||
|| $operationType === 'baseline_compare';
|
||||
public static function canonicalCode(string $operationType): string
|
||||
{
|
||||
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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,4 +27,26 @@ public static function values(): array
|
||||
{
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
72
apps/platform/app/Support/OperationTypeAlias.php
Normal file
72
apps/platform/app/Support/OperationTypeAlias.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
56
apps/platform/app/Support/OperationTypeResolution.php
Normal file
56
apps/platform/app/Support/OperationTypeResolution.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,8 @@
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\PlatformReasonFamily;
|
||||
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
enum ExecutionDenialReasonCode: string
|
||||
@ -125,6 +127,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
|
||||
nextSteps: $this->nextSteps(),
|
||||
showNoActionNeeded: false,
|
||||
diagnosticCodeLabel: $this->value,
|
||||
reasonOwnership: new ReasonOwnershipDescriptor(
|
||||
ownerLayer: 'platform_core',
|
||||
ownerNamespace: 'execution_denial',
|
||||
reasonCode: $this->value,
|
||||
platformReasonFamily: PlatformReasonFamily::Authorization,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\PlatformReasonFamily;
|
||||
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
enum LifecycleReconciliationReason: string
|
||||
@ -78,6 +80,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
|
||||
nextSteps: $this->nextSteps(),
|
||||
showNoActionNeeded: false,
|
||||
diagnosticCodeLabel: $this->value,
|
||||
reasonOwnership: new ReasonOwnershipDescriptor(
|
||||
ownerLayer: 'platform_core',
|
||||
ownerNamespace: 'operation_lifecycle',
|
||||
reasonCode: $this->value,
|
||||
platformReasonFamily: PlatformReasonFamily::Execution,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
|
||||
namespace App\Support\Providers;
|
||||
|
||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||
use App\Support\ReasonTranslation\PlatformReasonFamily;
|
||||
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
|
||||
|
||||
final class ProviderReasonCodes
|
||||
{
|
||||
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.');
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -248,6 +248,7 @@ private function envelope(
|
||||
nextSteps: $nextSteps,
|
||||
showNoActionNeeded: false,
|
||||
diagnosticCodeLabel: $reasonCode,
|
||||
reasonOwnership: ProviderReasonCodes::ownershipDescriptor($reasonCode),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\PlatformReasonFamily;
|
||||
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
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>
|
||||
*/
|
||||
@ -92,6 +115,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
|
||||
nextSteps: $this->nextSteps(),
|
||||
showNoActionNeeded: $this->actionability() === 'non_actionable',
|
||||
diagnosticCodeLabel: $this->value,
|
||||
reasonOwnership: new ReasonOwnershipDescriptor(
|
||||
ownerLayer: $this->ownerLayer(),
|
||||
ownerNamespace: $this->ownerNamespace(),
|
||||
reasonCode: $this->value,
|
||||
platformReasonFamily: $this->platformReasonFamily(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Support\Providers\ProviderReasonTranslator;
|
||||
use App\Support\RbacReason;
|
||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class ReasonPresenter
|
||||
{
|
||||
@ -209,6 +210,93 @@ public function trustImpact(?ReasonResolutionEnvelope $envelope): ?string
|
||||
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
|
||||
{
|
||||
return $envelope?->absencePattern;
|
||||
|
||||
@ -22,6 +22,7 @@ public function __construct(
|
||||
public ?string $diagnosticCodeLabel = null,
|
||||
public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value,
|
||||
public ?string $absencePattern = null,
|
||||
public ?ReasonOwnershipDescriptor $reasonOwnership = null,
|
||||
) {
|
||||
if (trim($this->internalCode) === '') {
|
||||
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)
|
||||
? trim((string) $data['absence_pattern'])
|
||||
: (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 === '') {
|
||||
return null;
|
||||
@ -112,6 +121,7 @@ public static function fromArray(array $data): ?self
|
||||
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
|
||||
trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value,
|
||||
absencePattern: $absencePattern !== '' ? $absencePattern : null,
|
||||
reasonOwnership: $reasonOwnership,
|
||||
);
|
||||
}
|
||||
|
||||
@ -130,6 +140,23 @@ public function withNextSteps(array $nextSteps): self
|
||||
diagnosticCodeLabel: $this->diagnosticCodeLabel,
|
||||
trustImpact: $this->trustImpact,
|
||||
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;
|
||||
}
|
||||
|
||||
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}>
|
||||
*/
|
||||
@ -209,9 +256,18 @@ public function toLegacyNextSteps(): array
|
||||
* scope: string
|
||||
* }>,
|
||||
* show_no_action_needed: bool,
|
||||
* diagnostic_code_label: string
|
||||
* diagnostic_code_label: 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
|
||||
@ -229,6 +285,10 @@ public function toArray(): array
|
||||
'diagnostic_code_label' => $this->diagnosticCode(),
|
||||
'trust_impact' => $this->trustImpact,
|
||||
'absence_pattern' => $this->absencePattern,
|
||||
'reason_owner' => $this->reasonOwnership?->toArray(),
|
||||
'owner_layer' => $this->ownerLayer(),
|
||||
'owner_namespace' => $this->ownerNamespace(),
|
||||
'platform_reason_family' => $this->platformReasonFamily(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
@ -27,6 +28,7 @@ final class ReasonTranslator
|
||||
public function __construct(
|
||||
private readonly ProviderReasonTranslator $providerReasonTranslator,
|
||||
private readonly FallbackReasonTranslator $fallbackReasonTranslator,
|
||||
private readonly PlatformVocabularyGlossary $glossary,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -44,7 +46,7 @@ public function translate(
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$envelope = match (true) {
|
||||
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
|
||||
$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),
|
||||
@ -62,6 +64,36 @@ public function translate(
|
||||
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $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(),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,10 @@
|
||||
|
||||
namespace App\Support\Tenants;
|
||||
|
||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\PlatformReasonFamily;
|
||||
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
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>
|
||||
*/
|
||||
@ -102,6 +125,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
|
||||
nextSteps: $this->nextSteps(),
|
||||
showNoActionNeeded: $this->actionability() === 'non_actionable',
|
||||
diagnosticCodeLabel: $this->value,
|
||||
reasonOwnership: new ReasonOwnershipDescriptor(
|
||||
ownerLayer: $this->ownerLayer(),
|
||||
ownerNamespace: $this->ownerNamespace(),
|
||||
reasonCode: $this->value,
|
||||
platformReasonFamily: $this->platformReasonFamily(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\PlatformReasonFamily;
|
||||
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
@ -22,6 +24,9 @@ public function __construct(
|
||||
public string $trustImpact,
|
||||
public ?string $absencePattern,
|
||||
public array $nextSteps = [],
|
||||
public ?string $ownerLayer = null,
|
||||
public ?string $ownerNamespace = null,
|
||||
public ?string $platformReasonFamily = null,
|
||||
) {}
|
||||
|
||||
public static function fromReasonResolutionEnvelope(
|
||||
@ -44,11 +49,32 @@ public static function fromReasonResolutionEnvelope(
|
||||
static fn (NextStepOption $nextStep): string => $nextStep->label,
|
||||
$reason->nextSteps,
|
||||
)),
|
||||
ownerLayer: $reason->ownerLayer(),
|
||||
ownerNamespace: $reason->ownerNamespace(),
|
||||
platformReasonFamily: $reason->platformReasonFamily(),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
internalCode: $this->reasonCode ?? 'artifact_truth_reason',
|
||||
operatorLabel: $this->operatorLabel ?? 'Operator attention required',
|
||||
@ -61,6 +87,7 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
|
||||
diagnosticCodeLabel: $this->diagnosticCode,
|
||||
trustImpact: $this->trustImpact !== '' ? $this->trustImpact : TrustworthinessLevel::LimitedConfidence->value,
|
||||
absencePattern: $this->absencePattern,
|
||||
reasonOwnership: $reasonOwnership,
|
||||
);
|
||||
}
|
||||
|
||||
@ -73,7 +100,10 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
|
||||
* diagnosticCode: ?string,
|
||||
* trustImpact: string,
|
||||
* absencePattern: ?string,
|
||||
* nextSteps: array<int, string>
|
||||
* nextSteps: array<int, string>,
|
||||
* ownerLayer: ?string,
|
||||
* ownerNamespace: ?string,
|
||||
* platformReasonFamily: ?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
@ -87,6 +117,9 @@ public function toArray(): array
|
||||
'trustImpact' => $this->trustImpact,
|
||||
'absencePattern' => $this->absencePattern,
|
||||
'nextSteps' => $this->nextSteps,
|
||||
'ownerLayer' => $this->ownerLayer,
|
||||
'ownerNamespace' => $this->ownerNamespace,
|
||||
'platformReasonFamily' => $this->platformReasonFamily,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' => [
|
||||
'intune_write_gate' => [
|
||||
'enabled' => (bool) env('TENANTPILOT_INTUNE_WRITE_GATE_ENABLED', true),
|
||||
|
||||
@ -30,10 +30,10 @@
|
||||
'badge_evidence_gaps' => 'Evidence gaps: :count',
|
||||
'evidence_gaps_tooltip' => 'Top gaps: :summary',
|
||||
'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_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_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.',
|
||||
@ -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_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_policy_type' => 'Policy type',
|
||||
'evidence_gap_policy_type' => 'Governed subject',
|
||||
'evidence_gap_subject_class' => 'Subject class',
|
||||
'evidence_gap_outcome' => 'Outcome',
|
||||
'evidence_gap_next_action' => 'Next action',
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
@endphp
|
||||
|
||||
<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"
|
||||
collapsible
|
||||
:collapsed="(bool) ($group['initiallyCollapsed'] ?? true)"
|
||||
@ -84,7 +84,7 @@
|
||||
{{ $item['label'] ?? 'Snapshot item' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $item['typeLabel'] ?? 'Policy type' }}
|
||||
{{ $item['typeLabel'] ?? 'Governed subject family' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -22,14 +22,14 @@
|
||||
<div class="space-y-3">
|
||||
@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">
|
||||
No captured policy types are available in this snapshot.
|
||||
No captured governed subjects are available in this snapshot.
|
||||
</div>
|
||||
@else
|
||||
<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">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/50">
|
||||
<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">Fidelity</th>
|
||||
<th class="px-4 py-3">Coverage state</th>
|
||||
@ -47,7 +47,7 @@
|
||||
|
||||
<tr class="align-top">
|
||||
<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 class="px-4 py-3 text-gray-700 dark:text-gray-200">
|
||||
{{ (int) ($row['itemCount'] ?? 0) }}
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
<div class="space-y-3">
|
||||
@foreach ($groupPayloads as $group)
|
||||
@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'] : [];
|
||||
@endphp
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
$contextLinks = is_array($state['context_links'] ?? null) ? $state['context_links'] : [];
|
||||
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
|
||||
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
|
||||
$reasonSemantics = is_array($state['reason_semantics'] ?? null) ? $state['reason_semantics'] : [];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -31,6 +32,20 @@
|
||||
</div>
|
||||
@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">
|
||||
@foreach ($metrics as $metric)
|
||||
@php
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
|
||||
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
|
||||
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
|
||||
$reasonSemantics = is_array($reasonSemantics ?? null) ? $reasonSemantics : null;
|
||||
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
|
||||
$matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null;
|
||||
$arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix');
|
||||
@ -117,6 +118,24 @@
|
||||
@endif
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@ -340,7 +340,7 @@
|
||||
@if ($policyTypeOptions !== [])
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ count($policyTypeOptions) }} searchable policy types
|
||||
{{ count($policyTypeOptions) }} searchable governed subjects
|
||||
</x-filament::badge>
|
||||
@if ($hiddenAssignedTenantCount > 0)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
@ -507,7 +507,7 @@
|
||||
{{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $result['policyType'] ?? 'Unknown policy type' }}
|
||||
{{ $result['governedSubjectLabel'] ?? ($result['policyType'] ?? 'Unknown governed subject') }}
|
||||
</div>
|
||||
@if (filled($result['baselineExternalId'] ?? null))
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
@ -715,7 +715,7 @@
|
||||
{{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $subject['policyType'] ?? 'Unknown policy type' }}
|
||||
{{ $subject['governedSubjectLabel'] ?? ($subject['policyType'] ?? 'Unknown governed subject') }}
|
||||
</div>
|
||||
@if (filled($subject['baselineExternalId'] ?? null))
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
/** @var bool $canManage */
|
||||
/** @var ?string $downloadUrl */
|
||||
/** @var ?string $failedReason */
|
||||
/** @var ?string $failedReasonDetail */
|
||||
/** @var ?array<string, mixed> $failedReasonSemantics */
|
||||
/** @var ?string $reviewUrl */
|
||||
|
||||
$badgeSpec = $statusEnum ? BadgeCatalog::spec(BadgeDomain::ReviewPackStatus, $statusEnum->value) : null;
|
||||
@ -133,11 +135,25 @@
|
||||
</div>
|
||||
|
||||
@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 }}
|
||||
</div>
|
||||
@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' }}
|
||||
·
|
||||
Platform reason family: {{ $failedReasonSemantics['family_label'] ?? 'Compatibility' }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ $pack->updated_at?->diffForHumans() ?? '—' }}
|
||||
</div>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\RbacReason;
|
||||
@ -32,3 +34,24 @@
|
||||
->and(TenantOperabilityReasonCode::TenantAlreadyArchived->toReasonResolutionEnvelope()->guidanceText())->toBe('No action needed.')
|
||||
->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,
|
||||
]);
|
||||
});
|
||||
|
||||
@ -42,7 +42,9 @@
|
||||
->assertSuccessful()
|
||||
->assertSee('Permission required')
|
||||
->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 {
|
||||
|
||||
@ -138,6 +138,13 @@
|
||||
$opService,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
|
||||
expect(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.matched_scope_entries.0.domain_key'))->toBe('intune')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.execution_diagnostics.rbac_role_definitions.total_compared'))->toBe(0);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('subject_external_id', (string) $policy->external_id)
|
||||
|
||||
@ -130,6 +130,9 @@
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
expect(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.state_counts.drift'))->toBe(3);
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$countsByChangeType = $context['findings']['counts_by_change_type'] ?? null;
|
||||
|
||||
@ -123,6 +123,8 @@
|
||||
$run->refresh();
|
||||
|
||||
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBeNull()
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1)
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.foundation_not_policy_backed'))->toBe(1);
|
||||
|
||||
|
||||
@ -215,7 +215,7 @@
|
||||
->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()]['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()]['freshnessState'] ?? null)->toBe('stale')
|
||||
->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence')
|
||||
|
||||
@ -85,7 +85,9 @@
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(0);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(0)
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported');
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoSubjectsInScope->value);
|
||||
});
|
||||
|
||||
@ -200,7 +202,10 @@
|
||||
|
||||
$compareRun->refresh();
|
||||
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(1);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(1)
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.state_counts.no_drift'))->toBe(1);
|
||||
expect(data_get($compareRun->context, 'result.findings_total'))->toBe(0);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value);
|
||||
});
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
use App\Services\Baselines\BaselineCaptureService;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
@ -85,7 +87,7 @@ function appendBrokenFoundationSupportConfig(): void
|
||||
Bus::assertDispatched(CompareBaselineToTenantJob::class);
|
||||
});
|
||||
|
||||
it('persists the same truthful scope capability decisions before dispatching capture work', function (): void {
|
||||
it('blocks capture work when the scope still contains unsupported types, while preserving truthful capability context', function (): void {
|
||||
Bus::fake();
|
||||
appendBrokenFoundationSupportConfig();
|
||||
|
||||
@ -102,10 +104,13 @@ function appendBrokenFoundationSupportConfig(): void
|
||||
|
||||
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeTrue();
|
||||
$scope = $profile->normalizedScope()->toEffectiveScopeContext(
|
||||
app(BaselineSupportCapabilityGuard::class),
|
||||
'capture',
|
||||
);
|
||||
|
||||
$run = $result['run'];
|
||||
$scope = data_get($run->context, 'effective_scope');
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['reason_code'] ?? null)->toBe(BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE);
|
||||
|
||||
expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag'])
|
||||
->and(data_get($scope, 'limited_types'))->toBe(['roleScopeTag'])
|
||||
@ -117,5 +122,5 @@ function appendBrokenFoundationSupportConfig(): void
|
||||
->and(data_get($scope, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config')
|
||||
->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull();
|
||||
|
||||
Bus::assertDispatched(CaptureBaselineSnapshotJob::class);
|
||||
Bus::assertNotDispatched(CaptureBaselineSnapshotJob::class);
|
||||
});
|
||||
|
||||
@ -362,18 +362,11 @@ public function compare(
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeGovernanceSubjectTaxonomyRegistry
|
||||
final class FakeGovernanceSubjectTaxonomyRegistry extends GovernanceSubjectTaxonomyRegistry
|
||||
{
|
||||
private readonly GovernanceSubjectTaxonomyRegistry $inner;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->inner = new GovernanceSubjectTaxonomyRegistry;
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return array_values(array_merge($this->inner->all(), [
|
||||
return array_values(array_merge(parent::all(), [
|
||||
new GovernanceSubjectType(
|
||||
domainKey: GovernanceDomainKey::Entra,
|
||||
subjectClass: GovernanceSubjectClass::Control,
|
||||
@ -389,66 +382,4 @@ public function all(): array
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
public function active(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->all(),
|
||||
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->active,
|
||||
));
|
||||
}
|
||||
|
||||
public function activeLegacyBucketKeys(string $legacyBucket): array
|
||||
{
|
||||
$subjectTypes = array_filter(
|
||||
$this->active(),
|
||||
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->legacyBucket === $legacyBucket,
|
||||
);
|
||||
|
||||
$keys = array_map(
|
||||
static fn (GovernanceSubjectType $subjectType): string => $subjectType->subjectTypeKey,
|
||||
$subjectTypes,
|
||||
);
|
||||
|
||||
sort($keys, SORT_STRING);
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubjectType
|
||||
{
|
||||
foreach ($this->all() as $subjectType) {
|
||||
if ($subjectType->domainKey->value !== trim($domainKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($subjectType->subjectTypeKey !== trim($subjectTypeKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $subjectType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function isKnownDomain(string $domainKey): bool
|
||||
{
|
||||
return $this->inner->isKnownDomain($domainKey);
|
||||
}
|
||||
|
||||
public function allowsSubjectClass(string $domainKey, string $subjectClass): bool
|
||||
{
|
||||
return $this->inner->allowsSubjectClass($domainKey, $subjectClass);
|
||||
}
|
||||
|
||||
public function supportsFilters(string $domainKey, string $subjectClass): bool
|
||||
{
|
||||
return $this->inner->supportsFilters($domainKey, $subjectClass);
|
||||
}
|
||||
|
||||
public function groupLabel(string $domainKey, string $subjectClass): string
|
||||
{
|
||||
return $this->inner->groupLabel($domainKey, $subjectClass);
|
||||
}
|
||||
}
|
||||
@ -80,7 +80,7 @@ function baselineCompareEvidenceGapBuckets(): array
|
||||
expect($table->isSearchable())->toBeTrue();
|
||||
expect($table->getDefaultSortColumn())->toBe('reason_label');
|
||||
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('resolution_outcome_label')?->isSearchable())->toBeTrue();
|
||||
expect($table->getColumn('operator_action_category_label')?->isSearchable())->toBeTrue();
|
||||
@ -90,7 +90,7 @@ function baselineCompareEvidenceGapBuckets(): array
|
||||
->assertSee('WiFi-Corp-Profile')
|
||||
->assertSee('Deleted-Policy-ABC')
|
||||
->assertSee('Reason')
|
||||
->assertSee('Policy type')
|
||||
->assertSee('Governed subject')
|
||||
->assertSee('Subject class')
|
||||
->assertSee('Outcome')
|
||||
->assertSee('Next action')
|
||||
@ -119,6 +119,7 @@ function baselineCompareEvidenceGapBuckets(): array
|
||||
->assertSee('Retired-Compliance-Policy')
|
||||
->assertDontSee('VPN-Always-On')
|
||||
->filterTable('policy_type', 'deviceCompliancePolicy')
|
||||
->assertSee('Device Compliance')
|
||||
->assertSee('Retired-Compliance-Policy')
|
||||
->assertDontSee('Deleted-Policy-ABC')
|
||||
->filterTable('operator_action_category', 'run_policy_sync_or_backup')
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\ExplanationFamily;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
@ -74,14 +75,22 @@
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
$explanation = $stats->operatorExplanation();
|
||||
$summary = $stats->summaryAssessment();
|
||||
$reasonSemantics = app(ReasonPresenter::class)->semantics(
|
||||
app(ReasonPresenter::class)->forArtifactTruth(BaselineCompareReasonCode::CoverageUnproven->value, 'artifact_truth'),
|
||||
);
|
||||
|
||||
expect($explanation->family)->toBe(ExplanationFamily::SuppressedOutput);
|
||||
expect($reasonSemantics)->not->toBeNull();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BaselineCompareLanding::class)
|
||||
->assertSee($summary->headline)
|
||||
->assertSee($explanation->trustworthinessLabel())
|
||||
->assertSee($summary->nextActionLabel())
|
||||
->assertSee('Reason owner')
|
||||
->assertSee($reasonSemantics['owner_label'])
|
||||
->assertSee('Platform reason family')
|
||||
->assertSee($reasonSemantics['family_label'])
|
||||
->assertSee('Findings shown')
|
||||
->assertSee('Evidence gaps');
|
||||
});
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->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('Inventory metadata')
|
||||
->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'))
|
||||
->assertOk()
|
||||
->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('A fallback renderer is being used for this item.');
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
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');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
@ -93,13 +93,14 @@
|
||||
->assertSee('Capture timing')
|
||||
->assertSee('Related context')
|
||||
->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('Bitlocker Require')
|
||||
->assertSee('Mystery Policy')
|
||||
->assertSee('Intune RBAC Role Definition')
|
||||
->assertSee('Device Compliance')
|
||||
->assertSee('Mystery Policy Type')
|
||||
->assertSee('Governed subject')
|
||||
->assertDontSee('Intune RBAC Role Definition References');
|
||||
|
||||
$this->actingAs($user)
|
||||
|
||||
@ -77,7 +77,7 @@
|
||||
|
||||
$this->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->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))
|
||||
->assertOk()
|
||||
|
||||
@ -9,8 +9,10 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
|
||||
use App\Support\Operations\ExecutionDenialReasonCode;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
@ -303,6 +305,47 @@ function baselineCompareGapContext(array $overrides = []): array
|
||||
->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 {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -340,7 +383,7 @@ function baselineCompareGapContext(array $overrides = []): array
|
||||
->assertSee('2 affected')
|
||||
->assertSee('WiFi-Corp-Profile')
|
||||
->assertSee('Deleted-Policy-ABC')
|
||||
->assertSee('Policy type')
|
||||
->assertSee('Governed subject')
|
||||
->assertSee('Subject class')
|
||||
->assertSee('Outcome')
|
||||
->assertSee('Next action')
|
||||
|
||||
@ -160,11 +160,49 @@ function operationRunFilterIndicatorLabels($component): array
|
||||
|
||||
expect($filter)->not->toBeNull();
|
||||
expect($filter?->getOptions())->toBe([
|
||||
'inventory_sync' => 'Inventory sync',
|
||||
'inventory.sync' => 'Inventory 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 {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -6,6 +6,10 @@
|
||||
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
|
||||
expect($compareJob)->toBeString();
|
||||
expect($compareJob)->toContain('CurrentStateHashResolver');
|
||||
expect($compareJob)->toContain('compareStrategyRegistry->select(');
|
||||
expect($compareJob)->toContain('compareStrategyRegistry->resolve(');
|
||||
expect($compareJob)->toContain('$strategy->compare(');
|
||||
expect($compareJob)->not->toContain('computeDrift(');
|
||||
expect($compareJob)->not->toContain('->fingerprint(');
|
||||
expect($compareJob)->not->toContain('::fingerprint(');
|
||||
|
||||
|
||||
@ -7,6 +7,24 @@
|
||||
'PolicyNormalizer',
|
||||
'VersionDiff',
|
||||
'flattenForDiff',
|
||||
'computeDrift(',
|
||||
'effectiveBaselineHash(',
|
||||
'resolveBaselinePolicyVersionId(',
|
||||
'selectSummaryKind(',
|
||||
'buildDriftEvidenceContract(',
|
||||
'buildRoleDefinitionEvidencePayload(',
|
||||
'resolveRoleDefinitionVersion(',
|
||||
'fallbackRoleDefinitionNormalized(',
|
||||
'roleDefinitionChangedKeys(',
|
||||
'roleDefinitionPermissionKeys(',
|
||||
'resolveRoleDefinitionDiff(',
|
||||
'severityForRoleDefinitionDiff(',
|
||||
'BaselinePolicyVersionResolver',
|
||||
'DriftHasher',
|
||||
'SettingsNormalizer',
|
||||
'AssignmentsNormalizer',
|
||||
'ScopeTagsNormalizer',
|
||||
'IntuneRoleDefinitionNormalizer',
|
||||
];
|
||||
|
||||
$captureForbiddenTokens = [
|
||||
@ -20,6 +38,9 @@
|
||||
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
|
||||
expect($compareJob)->toBeString();
|
||||
expect($compareJob)->toContain('CurrentStateHashResolver');
|
||||
expect($compareJob)->toContain('compareStrategyRegistry->select(');
|
||||
expect($compareJob)->toContain('compareStrategyRegistry->resolve(');
|
||||
expect($compareJob)->toContain('$strategy->compare(');
|
||||
|
||||
foreach ($compareForbiddenTokens as $token) {
|
||||
expect($compareJob)->not->toContain($token);
|
||||
|
||||
@ -61,3 +61,21 @@
|
||||
->assertSee('Back to Operations')
|
||||
->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');
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
|
||||
|
||||
@ -40,3 +41,15 @@
|
||||
'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);
|
||||
});
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
@ -184,6 +186,51 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
->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 ───────────────────────────────────────────
|
||||
|
||||
it('shows generate action for an expired pack', function (): void {
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
@ -32,6 +33,9 @@
|
||||
|
||||
$truth = app(ArtifactTruthPresenter::class)->forTenantReview($review);
|
||||
$explanation = $truth->operatorExplanation;
|
||||
$reasonSemantics = app(ReasonPresenter::class)->semantics($truth->reason?->toReasonResolutionEnvelope());
|
||||
|
||||
expect($reasonSemantics)->not->toBeNull();
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
@ -39,7 +43,11 @@
|
||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||
->assertOk()
|
||||
->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();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
@ -43,3 +43,11 @@
|
||||
static fn ($subjectType): bool => $subjectType->domainKey === GovernanceDomainKey::Entra,
|
||||
))->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']);
|
||||
});
|
||||
@ -108,6 +108,7 @@
|
||||
->and(data_get($rendered, 'snapshot.overallFidelity'))->toBe('partial')
|
||||
->and(data_get($rendered, 'snapshot.overallGapCount'))->toBe(1)
|
||||
->and($rendered['summaryRows'])->toHaveCount(3)
|
||||
->and(data_get($rendered, 'summaryRows.0.subjectDescriptor.platform_noun'))->toBe('Governed subject')
|
||||
->and(collect($rendered['groups'])->pluck('label')->all())
|
||||
->toContain('Intune RBAC Role Definition', 'Device Compliance', 'Mystery Policy Type')
|
||||
->and(data_get($rendered, 'technicalDetail.defaultCollapsed'))->toBeTrue();
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -29,6 +29,10 @@
|
||||
expect($envelope)->not->toBeNull()
|
||||
->and($envelope?->operatorLabel)->toBe('Dedicated credentials required')
|
||||
->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]['url'] ?? null)->toContain('/provider-connections/');
|
||||
});
|
||||
@ -39,5 +43,6 @@
|
||||
expect($envelope)->not->toBeNull()
|
||||
->and($envelope?->operatorLabel)->toBe('Provider configuration needs review')
|
||||
->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.');
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||
use App\Support\RbacReason;
|
||||
|
||||
it('translates manual RBAC assignment reasons into operator guidance', function (): void {
|
||||
@ -10,6 +11,10 @@
|
||||
expect($envelope->operatorLabel)->toBe('Manual role assignment required')
|
||||
->and($envelope->actionability)->toBe('prerequisite_missing')
|
||||
->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.');
|
||||
});
|
||||
|
||||
@ -18,5 +23,6 @@
|
||||
|
||||
expect($envelope->actionability)->toBe('non_actionable')
|
||||
->and($envelope->operatorLabel)->toBe('RBAC API unsupported')
|
||||
->and($envelope->ownerNamespace())->toBe('rbac.intune')
|
||||
->and($envelope->guidanceText())->toBe('No action needed.');
|
||||
});
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
|
||||
use App\Support\ReasonTranslation\FallbackReasonTranslator;
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\PlatformReasonFamily;
|
||||
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
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?->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);
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||
use App\Support\Tenants\TenantOperabilityReasonCode;
|
||||
|
||||
it('marks already archived tenant states as non-actionable while preserving diagnostics', function (): void {
|
||||
@ -18,5 +19,6 @@
|
||||
|
||||
expect($envelope->operatorLabel)->toBe('Permission required')
|
||||
->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.');
|
||||
});
|
||||
|
||||
@ -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.
|
||||
@ -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
|
||||
247
specs/204-platform-core-vocabulary-hardening/data-model.md
Normal file
247
specs/204-platform-core-vocabulary-hardening/data-model.md
Normal 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.
|
||||
289
specs/204-platform-core-vocabulary-hardening/plan.md
Normal file
289
specs/204-platform-core-vocabulary-hardening/plan.md
Normal 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.
|
||||
164
specs/204-platform-core-vocabulary-hardening/quickstart.md
Normal file
164
specs/204-platform-core-vocabulary-hardening/quickstart.md
Normal 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.
|
||||
103
specs/204-platform-core-vocabulary-hardening/research.md
Normal file
103
specs/204-platform-core-vocabulary-hardening/research.md
Normal 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.
|
||||
402
specs/204-platform-core-vocabulary-hardening/spec.md
Normal file
402
specs/204-platform-core-vocabulary-hardening/spec.md
Normal 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.
|
||||
247
specs/204-platform-core-vocabulary-hardening/tasks.md
Normal file
247
specs/204-platform-core-vocabulary-hardening/tasks.md
Normal 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.
|
||||
35
specs/205-compare-job-cleanup/checklists/requirements.md
Normal file
35
specs/205-compare-job-cleanup/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-14
|
||||
**Feature**: [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 passed on 2026-04-14 after the initial drafting pass.
|
||||
- The feature is an internal cleanup, so user value is expressed through architectural honesty, review speed, and regression safety rather than a new operator-facing workflow.
|
||||
@ -0,0 +1,273 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Compare Job Legacy Drift Cleanup Internal Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for the unchanged baseline compare start and execution path after legacy drift deletion
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 205. No new HTTP
|
||||
controllers or routes are introduced. The paths below identify logical
|
||||
service, job, and guard boundaries that must remain true after the dead
|
||||
pre-strategy drift path is removed from CompareBaselineToTenantJob.
|
||||
x-logical-artifact: true
|
||||
x-compare-job-cleanup-consumers:
|
||||
- surface: baseline.compare.start
|
||||
sourceFiles:
|
||||
- apps/platform/app/Services/Baselines/BaselineCompareService.php
|
||||
- apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
|
||||
mustRemainTrue:
|
||||
- compare_start_remains_enqueue_only
|
||||
- deterministic_strategy_selection_recorded_in_run_context
|
||||
- no_legacy_compare_fallback_at_start
|
||||
- surface: baseline.compare.execution
|
||||
sourceFiles:
|
||||
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||
- apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php
|
||||
- apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php
|
||||
mustConsume:
|
||||
- supported_strategy_selection
|
||||
- strategy_compare_result
|
||||
- normalized_strategy_subject_results
|
||||
- no_legacy_compute_drift_fallback
|
||||
- surface: baseline.compare.findings
|
||||
sourceFiles:
|
||||
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||
mustRemainTrue:
|
||||
- finding_lifecycle_unchanged
|
||||
- summary_and_gap_counts_derived_from_strategy_results
|
||||
- warning_outcomes_unchanged
|
||||
- reason_translation_unchanged
|
||||
- operation_run_completion_semantics_unchanged
|
||||
- surface: baseline.compare.guard
|
||||
sourceFiles:
|
||||
- apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php
|
||||
- apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php
|
||||
mustEnforce:
|
||||
- removed_legacy_methods_stay_absent
|
||||
- orchestration_file_has_one_compare_engine
|
||||
- surface: baseline.compare.run-guards
|
||||
sourceFiles:
|
||||
- apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php
|
||||
- apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php
|
||||
- apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php
|
||||
- apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
|
||||
mustEnforce:
|
||||
- baseline_compare_run_lifecycle_semantics_unchanged
|
||||
- summary_count_keys_remain_whitelisted
|
||||
- compare_run_context_updates_remain_valid
|
||||
paths:
|
||||
/internal/tenants/{tenant}/baseline-profiles/{profile}/compare:
|
||||
post:
|
||||
summary: Start baseline compare using the existing strategy-selected flow only
|
||||
operationId: startBaselineCompareWithoutLegacyFallback
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: profile
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareLaunchRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Compare accepted and queued with the strategy-owned execution path only
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-run+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareLaunchEnvelope'
|
||||
'422':
|
||||
description: Existing unsupported or mixed-scope preconditions prevented compare from starting
|
||||
'403':
|
||||
description: Actor is in scope but lacks compare-start capability
|
||||
'404':
|
||||
description: Tenant or baseline profile is outside actor scope
|
||||
/internal/operation-runs/{run}/baseline-compare/execute:
|
||||
post:
|
||||
summary: Execute baseline compare through strategy selection and strategy compare only
|
||||
operationId: executeBaselineCompareJobWithoutLegacyFallback
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Existing compare run completed through the strategy-owned path with no legacy drift fallback
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-execution+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareExecutionEnvelope'
|
||||
'409':
|
||||
description: Existing snapshot, coverage, or strategy preconditions blocked execution
|
||||
/internal/guards/baseline-compare/no-legacy-drift:
|
||||
get:
|
||||
summary: Static invariant proving the orchestration file no longer retains the pre-strategy drift implementation
|
||||
operationId: assertNoLegacyBaselineCompareJobPath
|
||||
responses:
|
||||
'200':
|
||||
description: Guard passes because the removed legacy methods are absent from the compare job
|
||||
content:
|
||||
application/vnd.tenantpilot.compare-job-guard+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LegacyDriftGuardResult'
|
||||
components:
|
||||
schemas:
|
||||
CompareLaunchRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- baseline_snapshot_id
|
||||
- effective_scope
|
||||
properties:
|
||||
baseline_snapshot_id:
|
||||
type: integer
|
||||
effective_scope:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
origin:
|
||||
type: string
|
||||
enum:
|
||||
- tenant_profile
|
||||
- compare_matrix
|
||||
- other_existing_surface
|
||||
SupportedStrategySelection:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- selection_state
|
||||
- strategy_key
|
||||
- operator_reason
|
||||
properties:
|
||||
selection_state:
|
||||
type: string
|
||||
enum:
|
||||
- supported
|
||||
strategy_key:
|
||||
type: string
|
||||
example: intune_policy
|
||||
operator_reason:
|
||||
type: string
|
||||
diagnostics:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
CompareLaunchEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- run_id
|
||||
- operation_type
|
||||
- execution_mode
|
||||
- selected_strategy
|
||||
- legacy_drift_path_present
|
||||
properties:
|
||||
run_id:
|
||||
type: integer
|
||||
operation_type:
|
||||
type: string
|
||||
enum:
|
||||
- baseline_compare
|
||||
execution_mode:
|
||||
type: string
|
||||
enum:
|
||||
- queued
|
||||
selected_strategy:
|
||||
$ref: '#/components/schemas/SupportedStrategySelection'
|
||||
legacy_drift_path_present:
|
||||
type: boolean
|
||||
const: false
|
||||
CompareExecutionEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- run_id
|
||||
- compare_source
|
||||
- selected_strategy_key
|
||||
- no_legacy_compute_drift
|
||||
- persisted_truths
|
||||
properties:
|
||||
run_id:
|
||||
type: integer
|
||||
compare_source:
|
||||
type: string
|
||||
enum:
|
||||
- strategy_only
|
||||
selected_strategy_key:
|
||||
type: string
|
||||
example: intune_policy
|
||||
no_legacy_compute_drift:
|
||||
type: boolean
|
||||
const: true
|
||||
persisted_truths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- operation_runs
|
||||
- findings
|
||||
- baseline_compare.context
|
||||
outputs_preserved:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
finding_lifecycle:
|
||||
type: boolean
|
||||
const: true
|
||||
summary_counts:
|
||||
type: boolean
|
||||
const: true
|
||||
gap_handling:
|
||||
type: boolean
|
||||
const: true
|
||||
warning_outcomes:
|
||||
type: boolean
|
||||
const: true
|
||||
reason_translation:
|
||||
type: boolean
|
||||
const: true
|
||||
run_completion:
|
||||
type: boolean
|
||||
const: true
|
||||
LegacyDriftGuardResult:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- status
|
||||
- compare_job_path
|
||||
- forbidden_method_names
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- pass
|
||||
compare_job_path:
|
||||
type: string
|
||||
example: apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||
forbidden_method_names:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- computeDrift
|
||||
- effectiveBaselineHash
|
||||
- resolveBaselinePolicyVersionId
|
||||
- selectSummaryKind
|
||||
- buildDriftEvidenceContract
|
||||
- buildRoleDefinitionEvidencePayload
|
||||
- resolveRoleDefinitionVersion
|
||||
- fallbackRoleDefinitionNormalized
|
||||
- roleDefinitionChangedKeys
|
||||
- roleDefinitionPermissionKeys
|
||||
- resolveRoleDefinitionDiff
|
||||
- severityForRoleDefinitionDiff
|
||||
invariant:
|
||||
type: string
|
||||
example: compare orchestration retains one live strategy-driven execution path
|
||||
131
specs/205-compare-job-cleanup/data-model.md
Normal file
131
specs/205-compare-job-cleanup/data-model.md
Normal file
@ -0,0 +1,131 @@
|
||||
# Data Model: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new top-level persisted entity and no new runtime or product-facing contract. It removes an obsolete implementation branch from `CompareBaselineToTenantJob` and preserves the existing persisted truths and compare contracts that already drive the live strategy-based compare flow. The OpenAPI document in `contracts/` is a planning-only logical artifact that records invariants for this cleanup; it does not define a new runtime integration surface.
|
||||
|
||||
## Existing Persisted Truth Reused Without Change
|
||||
|
||||
### Workspace-owned baseline truth
|
||||
|
||||
- `baseline_profiles`
|
||||
- `baseline_snapshots`
|
||||
- `baseline_snapshot_items`
|
||||
- Canonical baseline scope payload already stored in profile and run context
|
||||
|
||||
These remain the baseline reference truth that compare reads.
|
||||
|
||||
### Tenant-owned current-state and operational truth
|
||||
|
||||
- `inventory_items`
|
||||
- `operation_runs` for `baseline_compare`
|
||||
- findings written by the baseline compare lifecycle
|
||||
- existing run-context JSON such as `baseline_compare`, `findings`, and `result`
|
||||
|
||||
These remain the long-lived operational truths written or consumed by compare.
|
||||
|
||||
### Existing evidence inputs reused without change
|
||||
|
||||
- policy-version content evidence
|
||||
- inventory meta evidence
|
||||
- current-state hash resolution
|
||||
- coverage and gap context already recorded in the compare run
|
||||
|
||||
Spec 205 changes none of these inputs; it only removes a dead alternate computation path.
|
||||
|
||||
## Existing Internal Contracts Preserved
|
||||
|
||||
### Compare orchestration path
|
||||
|
||||
The live orchestration path remains:
|
||||
|
||||
1. `CompareBaselineToTenantJob::handle()`
|
||||
2. `CompareStrategyRegistry::select(...)`
|
||||
3. `CompareStrategyRegistry::resolve(...)`
|
||||
4. `strategy->compare(...)`
|
||||
5. `normalizeStrategySubjectResults(...)`
|
||||
6. finding upsert, summary aggregation, gap handling, and run completion
|
||||
|
||||
No new branch, fallback path, or second engine is introduced.
|
||||
|
||||
### CompareStrategySelection
|
||||
|
||||
Existing selection metadata remains unchanged and continues to be written into the compare run context.
|
||||
|
||||
| Field | Purpose | Change in Spec 205 |
|
||||
|------|---------|--------------------|
|
||||
| `selection_state` | Supported vs unsupported strategy state | unchanged |
|
||||
| `strategy_key` | Active compare strategy family | unchanged |
|
||||
| `diagnostics` | Secondary strategy selection detail | unchanged |
|
||||
|
||||
### CompareOrchestrationContext
|
||||
|
||||
Existing strategy input context remains unchanged.
|
||||
|
||||
| Field | Purpose | Change in Spec 205 |
|
||||
|------|---------|--------------------|
|
||||
| `workspace_id` | Workspace scope for compare run | unchanged |
|
||||
| `tenant_id` | Tenant scope for compare run | unchanged |
|
||||
| `baseline_profile_id` | Baseline profile reference | unchanged |
|
||||
| `baseline_snapshot_id` | Snapshot reference | unchanged |
|
||||
| `operation_run_id` | Run identity | unchanged |
|
||||
| `normalized_scope` | Canonical scope payload | unchanged |
|
||||
| `coverage_context` | Coverage and unsupported-type context | unchanged |
|
||||
|
||||
### CompareSubjectResult and CompareFindingCandidate
|
||||
|
||||
Existing per-subject compare results and finding projection contracts remain unchanged.
|
||||
|
||||
| Contract | Purpose | Change in Spec 205 |
|
||||
|----------|---------|--------------------|
|
||||
| `CompareSubjectResult` | Strategy-owned per-subject compare outcome | unchanged |
|
||||
| `CompareFindingCandidate` | Strategy-neutral finding mutation payload | unchanged |
|
||||
|
||||
### OperationRun compare context
|
||||
|
||||
The compare run continues to record current strategy, evidence coverage, gap counts, fidelity, reason translation, and result summaries inside the existing context structure. Spec 205 does not add, remove, or rename run-context fields.
|
||||
|
||||
### Finding lifecycle output
|
||||
|
||||
Finding severity, change type, recurrence key, evidence fidelity, timestamps, reopen behavior, and auto-close behavior remain unchanged. Spec 205 only preserves the live path that already feeds these outputs.
|
||||
|
||||
## Deleted Internal Cluster
|
||||
|
||||
Current repository inspection confirms one dead implementation cluster anchored by `computeDrift()` inside `CompareBaselineToTenantJob`, plus exclusive helpers clustered beneath it. The current candidate delete set includes:
|
||||
|
||||
- `computeDrift()`
|
||||
- `effectiveBaselineHash()`
|
||||
- `resolveBaselinePolicyVersionId()`
|
||||
- `selectSummaryKind()`
|
||||
- `buildDriftEvidenceContract()`
|
||||
- `buildRoleDefinitionEvidencePayload()`
|
||||
- `resolveRoleDefinitionVersion()`
|
||||
- `fallbackRoleDefinitionNormalized()`
|
||||
- `roleDefinitionChangedKeys()`
|
||||
- `roleDefinitionPermissionKeys()`
|
||||
- `resolveRoleDefinitionDiff()`
|
||||
- `severityForRoleDefinitionDiff()`
|
||||
|
||||
The final delete list is confirmed by call-graph inspection during implementation. Any method still used by the live orchestration path remains out of scope.
|
||||
|
||||
## Relationships
|
||||
|
||||
- One `baseline_compare` run selects one supported strategy.
|
||||
- One selected strategy processes many compare subjects.
|
||||
- One `CompareSubjectResult` may yield zero or one `CompareFindingCandidate`.
|
||||
- Existing finding and summary writers consume the strategy result contracts directly.
|
||||
- The legacy drift cluster is not part of any required runtime relationship after Spec 203 and is therefore removed.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. `CompareBaselineToTenantJob::handle()` must not call `computeDrift()` or any helper used exclusively by that legacy path.
|
||||
2. Compare execution must continue to run through strategy selection, strategy resolution, and `strategy->compare(...)`.
|
||||
3. Existing `OperationRun` status, outcome, summary-count, and context semantics must remain unchanged.
|
||||
4. Existing finding lifecycle behavior must remain driven by normalized strategy subject results.
|
||||
5. No new persistence, contract, or state family may be introduced as part of the cleanup.
|
||||
|
||||
## State Transitions
|
||||
|
||||
No new state transition is introduced.
|
||||
|
||||
Existing compare run transitions such as queued -> running -> completed or blocked remain unchanged, and finding lifecycle transitions remain governed by the current writers and services.
|
||||
198
specs/205-compare-job-cleanup/plan.md
Normal file
198
specs/205-compare-job-cleanup/plan.md
Normal file
@ -0,0 +1,198 @@
|
||||
# Implementation Plan: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
**Branch**: `205-compare-job-cleanup` | **Date**: 2026-04-14 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/spec.md`
|
||||
|
||||
**Note**: This plan treats Spec 205 as a mechanical closure cleanup. It removes only the dead pre-strategy drift-compute path from `CompareBaselineToTenantJob`, keeps the current strategy-driven compare execution unchanged, and uses focused regression plus guard coverage to prove no behavior drift.
|
||||
|
||||
## Summary
|
||||
|
||||
Delete `computeDrift()` and its exclusive helper cluster from `CompareBaselineToTenantJob`, preserve the existing `CompareStrategyRegistry` -> `IntuneCompareStrategy` execution path, remove dead imports and misleading internal descriptions that survive only because of the retained legacy block, and verify unchanged behavior through focused compare execution, finding, and guard tests.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services
|
||||
**Storage**: PostgreSQL via existing baseline snapshots, baseline snapshot items, inventory items, `operation_runs`, findings, and current run-context JSON; no new storage planned
|
||||
**Testing**: Pest feature and guard tests run through Laravel Sail, with focused compare execution and file-content guard coverage
|
||||
**Target Platform**: Laravel web application under `apps/platform` with queue-backed compare execution in Sail/Docker
|
||||
**Project Type**: web application in a monorepo (`apps/platform` plus `apps/website`)
|
||||
**Performance Goals**: Preserve current compare start latency and compare job throughput, add no new remote calls or DB writes, and keep operator-facing compare and monitoring surfaces behaviorally unchanged
|
||||
**Constraints**: No behavior change, no new abstraction or persistence, no operator-facing surface changes, no `OperationRun` lifecycle changes, and keep the PR mechanically small and reviewable
|
||||
**Scale/Scope**: One queued compare job, one compare start service boundary, one active strategy registry, one active Intune strategy, existing finding and run writers, and a small focused regression slice
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing because the feature removes code without altering scope, auth, persistence, or UI contracts.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Inventory-first / snapshots-second | PASS | PASS | Compare still reads existing workspace baseline snapshots and inventory-backed current state; no new compare truth is introduced. |
|
||||
| Read/write separation | PASS | PASS | Existing compare runs still write only current run, finding, and audit truth; the cleanup adds no new write path. |
|
||||
| Graph contract path | PASS | PASS | No new Microsoft Graph path or contract is introduced. |
|
||||
| Deterministic capabilities | PASS | PASS | Existing strategy selection and capability behavior remain unchanged because the registry and strategy classes stay intact. |
|
||||
| Workspace + tenant isolation | PASS | PASS | No workspace, tenant, or route-scope behavior changes are planned. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | No authorization rules, capability checks, or cross-plane behavior are changed. |
|
||||
| Run observability / Ops-UX | PASS | PASS | Existing `baseline_compare` run creation, summary counts, and completion semantics remain authoritative and unchanged. |
|
||||
| Data minimization | PASS | PASS | No new persisted diagnostics or helper truth is added; dead internal code is removed instead. |
|
||||
| Proportionality / anti-bloat | PASS | PASS | The feature deletes an obsolete path and introduces no new structure. |
|
||||
| No premature abstraction | PASS | PASS | No new factory, resolver, registry, strategy, or support layer is introduced. |
|
||||
| Persisted truth / behavioral state | PASS | PASS | No new table, stored artifact, status family, or reason family is added. |
|
||||
| UI semantics / few layers | PASS | PASS | No new presentation layer or surface behavior is introduced. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | No Filament or Livewire API changes are part of this cleanup. |
|
||||
| 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 searchable resource or search behavior is touched. |
|
||||
| Destructive action safety | PASS | PASS | No destructive action is added or changed. |
|
||||
| Asset strategy | PASS | PASS | No new assets are introduced and existing `filament:assets` deployment behavior remains unchanged. |
|
||||
|
||||
## Filament-Specific Compliance Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: Unchanged. The feature touches no Filament surface or Livewire component and does not introduce legacy APIs.
|
||||
- **Provider registration location**: Unchanged. If any panel/provider review is needed later, Laravel 11+ still requires `bootstrap/providers.php`.
|
||||
- **Global search**: No globally searchable resource is added or changed.
|
||||
- **Destructive actions**: No destructive action is introduced; existing confirmation and authorization rules remain untouched.
|
||||
- **Asset strategy**: No new panel or shared assets are required. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
- **Testing plan**: Focus on the required compare cleanup pack only: compare execution and findings regressions, gap and reason-code coverage, `Spec116OneEngineGuardTest`, `Spec118NoLegacyBaselineDriftGuardTest`, existing `OperationRun` and summary-count guards, and the enqueue-path matrix action regression. No new page, widget, relation manager, or action surface coverage is required.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Treat the current `CompareStrategyRegistry` -> `IntuneCompareStrategy` execution path as the only supported compare engine.
|
||||
- Delete the dead `computeDrift()` cluster rather than retaining it as deprecated or archived code.
|
||||
- Preserve `CompareSubjectResult`, finding upsert, summary aggregation, gap handling, and run completion semantics exactly as they currently operate through the live strategy path.
|
||||
- Use one focused compare pack covering execution fidelity, finding lifecycle, gap and reason outcomes, `OperationRun` lifecycle guards, summary-count guards, and the no-legacy orchestration guard as the minimum reliable regression slice.
|
||||
- Keep the contract artifact logical and internal, documenting invariants of the unchanged execution boundary instead of inventing a new external API.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/`:
|
||||
|
||||
- `research.md`: cleanup decisions, rationale, and rejected alternatives
|
||||
- `data-model.md`: existing persisted truth and internal compare contracts preserved by the cleanup
|
||||
- `contracts/compare-job-legacy-drift-cleanup.logical.openapi.yaml`: logical internal contract for the unchanged compare start and execution boundaries plus the no-legacy guard invariant
|
||||
- `quickstart.md`: implementation and verification order for the cleanup
|
||||
|
||||
Design decisions:
|
||||
|
||||
- `CompareBaselineToTenantJob` remains the compare execution entry point, but only the live orchestration methods stay after cleanup.
|
||||
- `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CompareStrategySelection`, `CompareOrchestrationContext`, `CompareSubjectResult`, and `CompareFindingCandidate` remain reused unchanged.
|
||||
- Existing persisted truth in baseline snapshots, inventory, findings, and `operation_runs` remains authoritative; no migration or compatibility layer is added.
|
||||
- Guard coverage remains the explicit enforcement point preventing legacy drift computation from re-entering the orchestration file.
|
||||
- Existing `OperationRun` lifecycle and summary-count guards remain part of the required verification surface because the cleanup still edits the compare executor.
|
||||
- No route, UI, RBAC, or `OperationRun` design change is planned.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/205-compare-job-cleanup/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── spec.md
|
||||
├── contracts/
|
||||
│ └── compare-job-legacy-drift-cleanup.logical.openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Jobs/
|
||||
│ │ └── CompareBaselineToTenantJob.php
|
||||
│ ├── Services/
|
||||
│ │ └── Baselines/
|
||||
│ │ ├── BaselineCompareService.php
|
||||
│ │ ├── CurrentStateHashResolver.php
|
||||
│ │ └── Evidence/
|
||||
│ └── Support/
|
||||
│ └── Baselines/
|
||||
│ └── Compare/
|
||||
│ ├── CompareStrategyRegistry.php
|
||||
│ ├── CompareStrategySelection.php
|
||||
│ ├── CompareSubjectResult.php
|
||||
│ ├── CompareFindingCandidate.php
|
||||
│ └── IntuneCompareStrategy.php
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── BaselineDriftEngine/
|
||||
│ │ └── FindingFidelityTest.php
|
||||
│ ├── Baselines/
|
||||
│ │ ├── BaselineCompareFindingsTest.php
|
||||
│ │ ├── BaselineCompareGapClassificationTest.php
|
||||
│ │ ├── BaselineCompareWhyNoFindingsReasonCodeTest.php
|
||||
│ │ └── BaselineCompareMatrixCompareAllActionTest.php
|
||||
│ └── Guards/
|
||||
│ ├── Spec116OneEngineGuardTest.php
|
||||
│ ├── Spec118NoLegacyBaselineDriftGuardTest.php
|
||||
│ └── OperationLifecycleOpsUxGuardTest.php
|
||||
│ ├── Operations/
|
||||
│ │ └── BaselineOperationRunGuardTest.php
|
||||
│ └── OpsUx/
|
||||
│ ├── OperationSummaryKeysSpecTest.php
|
||||
│ └── SummaryCountsWhitelistTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the cleanup inside the existing compare orchestration file and current compare regression surfaces. No new namespace, support layer, or package structure is introduced.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution exception or complexity justification is required. Spec 205 removes an obsolete implementation branch and introduces no new persistence, abstraction, state family, or semantic framework.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
Not triggered. This feature introduces no new enum or status family, DTO or presenter layer, persisted artifact, interface or registry, or cross-domain taxonomy.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A - Confirm dead call graph
|
||||
|
||||
- Confirm that `handle()` no longer reaches `computeDrift()` or its candidate helper cluster.
|
||||
- Confirm that the live path still runs through strategy selection, strategy resolution, `strategy->compare(...)`, normalization, finding upsert, and run completion.
|
||||
- Record the final helper delete list before editing to avoid removing shared orchestration methods.
|
||||
|
||||
### Phase B - Delete the legacy drift cluster
|
||||
|
||||
- Remove `computeDrift()` and every helper method used exclusively by that legacy path.
|
||||
- Remove only the imports, comments, and internal descriptions that become dead because of the delete.
|
||||
- Keep shared orchestration helpers such as evidence resolution, result normalization, summary aggregation, gap merging, and finding lifecycle methods untouched.
|
||||
|
||||
### Phase C - Preserve the live orchestration contract
|
||||
|
||||
- Leave `BaselineCompareService`, `CompareStrategyRegistry`, and `IntuneCompareStrategy` behavior unchanged unless a direct compile or test failure requires a minimal follow-up.
|
||||
- Preserve the existing `baseline_compare` run context shape, summary count rules, gap handling, reason translation, and finding lifecycle semantics.
|
||||
- Avoid any naming sweep, contract redesign, or opportunistic cleanup outside the dead cluster.
|
||||
|
||||
### Phase D - Guard and regression verification
|
||||
|
||||
- Keep or tighten the existing no-legacy guard so the removed path cannot silently re-enter the orchestration file.
|
||||
- Run focused compare execution, gap and reason-code regression, and `OperationRun` lifecycle and summary-count guard tests to prove the delete is mechanically safe.
|
||||
- Format the touched PHP files with Pint after the cleanup is implemented.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| A supposedly dead helper is still used by the live orchestration path | High | Medium | Confirm call sites before deletion and keep the initial regression pack focused on execution, findings, gap and reason outcomes, and run-lifecycle guards. |
|
||||
| The cleanup grows into a broader refactor while the job file is open | Medium | Medium | Constrain edits to dead methods, direct import fallout, and guard or test changes required by the delete. |
|
||||
| Existing guard tests are too weak or too token-specific to prevent reintroduction | Medium | Medium | Reuse `Spec118NoLegacyBaselineDriftGuardTest` and extend it with the removed method names only if the current assertions do not cover the dead-path cluster clearly enough. |
|
||||
| A regression test depends on deleted internal structure rather than behavior | Medium | Low | Update such tests to assert live compare outcomes and orchestration invariants rather than private helper presence. |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Run `tests/Feature/BaselineDriftEngine/FindingFidelityTest.php` as the primary execution and evidence-fidelity regression slice.
|
||||
- Run `tests/Feature/Baselines/BaselineCompareFindingsTest.php` to protect finding generation, recurrence, summary counts, and run completion outcomes.
|
||||
- Run `tests/Feature/Baselines/BaselineCompareGapClassificationTest.php` and `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php` to protect gap handling, warning outcomes, and reason translation behavior.
|
||||
- Run `tests/Feature/Guards/Spec116OneEngineGuardTest.php` to keep the one-engine orchestration invariant explicit while the dead fallback cluster is removed.
|
||||
- Run `tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, and `tests/Feature/OpsUx/SummaryCountsWhitelistTest.php` to keep `OperationRun` lifecycle and summary-count guarantees intact.
|
||||
- Run `tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` to lock the orchestration boundary against legacy drift helper re-entry.
|
||||
- Run `tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php` as the required enqueue-path regression slice for the focused cleanup pack.
|
||||
- Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` after the code change.
|
||||
101
specs/205-compare-job-cleanup/quickstart.md
Normal file
101
specs/205-compare-job-cleanup/quickstart.md
Normal file
@ -0,0 +1,101 @@
|
||||
# Quickstart: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
## Goal
|
||||
|
||||
Remove the obsolete pre-strategy drift-compute cluster from `CompareBaselineToTenantJob` while keeping the current strategy-driven compare workflow, finding lifecycle, and run semantics unchanged.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Work on branch `205-compare-job-cleanup`.
|
||||
2. Ensure the platform containers are available:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
3. Keep Spec 203's strategy extraction artifacts available because the cleanup assumes that strategy-driven compare execution is already the live path.
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
### 1. Confirm the live call graph before editing
|
||||
|
||||
Verify the current live path and the candidate legacy cluster:
|
||||
|
||||
```bash
|
||||
cd apps/platform && rg -n "compareStrategyRegistry->select|compareStrategyRegistry->resolve|strategy->compare" app/Jobs/CompareBaselineToTenantJob.php app/Services/Baselines/BaselineCompareService.php
|
||||
cd apps/platform && rg -n "computeDrift|effectiveBaselineHash|resolveBaselinePolicyVersionId|selectSummaryKind|buildDriftEvidenceContract|buildRoleDefinitionEvidencePayload|resolveRoleDefinitionVersion|fallbackRoleDefinitionNormalized|roleDefinitionChangedKeys|roleDefinitionPermissionKeys|resolveRoleDefinitionDiff|severityForRoleDefinitionDiff" app/Jobs/CompareBaselineToTenantJob.php
|
||||
```
|
||||
|
||||
If additional exclusive helpers are found adjacent to the dead cluster, add them to the delete list only after confirming they are not used by the live path.
|
||||
|
||||
### 2. Lock the current behavior with the focused regression slice
|
||||
|
||||
Run the minimum reliable compare pack before deleting anything:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BaselineDriftEngine/FindingFidelityTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec116OneEngineGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Operations/BaselineOperationRunGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php
|
||||
```
|
||||
|
||||
Run the enqueue-path slice as part of the required focused pack:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
|
||||
```
|
||||
|
||||
### 3. Delete the legacy drift cluster only
|
||||
|
||||
Remove:
|
||||
|
||||
- `computeDrift()`
|
||||
- helper methods used exclusively by that path
|
||||
- imports and internal descriptions that only exist because of those methods
|
||||
|
||||
Do not redesign `CompareStrategyRegistry`, `IntuneCompareStrategy`, run-context shapes, or finding lifecycle behavior while the job file is open.
|
||||
|
||||
### 4. Tighten or preserve the no-legacy guard
|
||||
|
||||
If the current guard does not explicitly block the removed helper names, extend it minimally so CI fails if the legacy drift cluster reappears in `CompareBaselineToTenantJob`.
|
||||
|
||||
### 5. Re-run the focused regression slice
|
||||
|
||||
After the delete, re-run the same focused pack:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BaselineDriftEngine/FindingFidelityTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec116OneEngineGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Operations/BaselineOperationRunGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php
|
||||
```
|
||||
|
||||
Re-run the enqueue-path slice as part of the same focused pack:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
|
||||
```
|
||||
|
||||
## Final Validation
|
||||
|
||||
1. Format touched PHP files:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
2. Re-check that the live compare path still flows through strategy selection and `strategy->compare(...)`.
|
||||
3. Confirm the compare run still completes with the same operator-visible outcome, gap and warning semantics, reason translation, and finding behavior as before.
|
||||
4. Keep the PR limited to dead-path deletion, direct fallout cleanup, and the minimal regression or guard updates required by the delete.
|
||||
41
specs/205-compare-job-cleanup/research.md
Normal file
41
specs/205-compare-job-cleanup/research.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Research: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
## Decision 1: Treat the strategy-driven compare path as the only authoritative execution engine
|
||||
|
||||
- **Decision**: Use the existing `CompareStrategyRegistry` -> `IntuneCompareStrategy` path as the sole supported compare execution boundary.
|
||||
- **Rationale**: Current code inspection shows `CompareBaselineToTenantJob::handle()` selecting a strategy, resolving it, and calling `strategy->compare(...)` before normalizing subject results and writing findings. No productive call path from `handle()` reaches the retained monolithic `computeDrift()` block.
|
||||
- **Alternatives considered**:
|
||||
- Keep the legacy block as a documented fallback. Rejected because it leaves the file structurally dishonest and suggests a second engine still exists.
|
||||
- Add a feature flag between strategy and legacy execution. Rejected because there is no legitimate second execution mode left to preserve.
|
||||
|
||||
## Decision 2: Delete the dead drift cluster instead of archiving or deprecating it
|
||||
|
||||
- **Decision**: Remove `computeDrift()` and its exclusive helper cluster directly from `CompareBaselineToTenantJob`.
|
||||
- **Rationale**: The retained cluster duplicates pre-strategy compare logic that has already been extracted into the active strategy implementation. Keeping it in place continues to mislead reviewers and inflates the orchestration file without operational value.
|
||||
- **Alternatives considered**:
|
||||
- Move the dead methods to a trait or archive class. Rejected because it preserves confusion and ownership cost without any runtime benefit.
|
||||
- Leave the methods in place with a deprecation comment. Rejected because dead code still obscures the real call graph even when labeled.
|
||||
|
||||
## Decision 3: Preserve existing compare contracts, findings, and run semantics unchanged
|
||||
|
||||
- **Decision**: Keep `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CompareStrategySelection`, `CompareSubjectResult`, `CompareFindingCandidate`, existing finding writers, and existing run context semantics unchanged.
|
||||
- **Rationale**: Spec 205 is a closure cleanup, not a second strategy extraction spec. The safest path is deletion of dead code while leaving the live contracts and persisted truths untouched.
|
||||
- **Alternatives considered**:
|
||||
- Fold in additional compare refactors while editing the job. Rejected because that turns a narrow cleanup into a mixed review.
|
||||
- Rename or reframe current compare contracts for symmetry. Rejected because it is unrelated to dead-path removal.
|
||||
|
||||
## Decision 4: Use a focused compare plus run-guard pack as the minimum regression slice
|
||||
|
||||
- **Decision**: Validate the cleanup with `FindingFidelityTest`, `BaselineCompareFindingsTest`, `BaselineCompareGapClassificationTest`, `BaselineCompareWhyNoFindingsReasonCodeTest`, `Spec116OneEngineGuardTest`, `OperationLifecycleOpsUxGuardTest`, `BaselineOperationRunGuardTest`, `OperationSummaryKeysSpecTest`, `SummaryCountsWhitelistTest`, `Spec118NoLegacyBaselineDriftGuardTest`, and `BaselineCompareMatrixCompareAllActionTest` as the required focused regression pack.
|
||||
- **Rationale**: `FindingFidelityTest` exercises the compare execution path and evidence selection behavior, `BaselineCompareFindingsTest` protects finding lifecycle and summary outcomes, the gap and reason-code tests protect warning and reason semantics, `Spec116OneEngineGuardTest` keeps the one-engine orchestration invariant explicit, the `OperationRun` and summary-count guards protect lifecycle invariants, and the legacy guard keeps helper re-entry visible in CI. Together they provide high confidence for a mechanical delete without requiring a broad slow suite.
|
||||
- **Alternatives considered**:
|
||||
- Run the full baseline compare suite for every cleanup iteration. Rejected as optional rather than required for a small internal delete.
|
||||
- Skip targeted tests and rely only on formatting or static inspection. Rejected as insufficient confidence.
|
||||
|
||||
## Decision 5: Keep the planning artifacts logical and invariant-focused
|
||||
|
||||
- **Decision**: Document the cleanup through a logical internal contract and a no-new-entity data model rather than inventing cleanup-specific services, APIs, or persistence.
|
||||
- **Rationale**: The plan workflow still needs explicit design artifacts, but Spec 205 adds no new feature surface. The correct documentation shape is therefore an invariant record of the unchanged compare boundaries after dead-code deletion.
|
||||
- **Alternatives considered**:
|
||||
- Skip the contract artifact entirely because no new endpoint exists. Rejected because the planning workflow requires a contract deliverable.
|
||||
- Invent a cleanup-specific service or endpoint in the design docs. Rejected because it would introduce fake architecture not warranted by the spec.
|
||||
162
specs/205-compare-job-cleanup/spec.md
Normal file
162
specs/205-compare-job-cleanup/spec.md
Normal file
@ -0,0 +1,162 @@
|
||||
# Feature Specification: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
**Feature Branch**: `205-compare-job-cleanup`
|
||||
**Created**: 2026-04-14
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Compare Job Legacy Drift Path Cleanup"
|
||||
|
||||
- **Type**: Cleanup / closure hardening
|
||||
- **Priority**: Medium
|
||||
- **Depends on**: Spec 203 - Baseline Compare Engine Strategy Extraction
|
||||
- **Related to**: Spec 202 - Governance Subject Taxonomy and Baseline Scope V2; Spec 204 - Platform Core Vocabulary Hardening
|
||||
- **Recommended timing**: Immediate close-out before the next expansion-focused strand
|
||||
- **Blocks**: No strategic work
|
||||
- **Does not block**: Further platform work if completed as a short closure PR
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: The baseline compare orchestration unit still retains an obsolete pre-strategy drift computation block that no longer reflects how compare execution actually works.
|
||||
- **Today's failure**: Contributors and reviewers must spend time proving which compare path is live, while architecture audits still see false monolithic coupling that has already been structurally replaced.
|
||||
- **User-visible improvement**: No operator workflow changes, but the codebase becomes more trustworthy, easier to audit, and faster to maintain because the live compare architecture is no longer obscured by dead logic.
|
||||
- **Smallest enterprise-capable version**: Remove the dead legacy drift block and its exclusively related helpers, clean direct fallout such as unused dependencies and misleading internal descriptions, and confirm that the current strategy-driven compare behavior remains unchanged.
|
||||
- **Explicit non-goals**: No new abstraction, no naming sweep, no evidence-contract redesign, no schema change, no UI change, no new strategy, and no opportunistic follow-up refactor.
|
||||
- **Permanent complexity imported**: None beyond minimal regression coverage or comment cleanup needed to lock in the deletion.
|
||||
- **Why now**: Specs 202, 203, and 204 already established the current architecture. Leaving the old drift block behind keeps the pre-expansion foundation structurally dishonest even though the behavioral migration is complete.
|
||||
- **Why not local**: A narrower action than deletion would still leave the same dead-path ambiguity in place, so the architectural trust gap would remain.
|
||||
- **Approval class**: Cleanup
|
||||
- **Red flags triggered**: Scope-creep risk if broader naming or architecture work is mixed into the cleanup.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 1 | Wiederverwendung: 1 | **Gesamt: 10/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace, tenant
|
||||
- **Primary Routes**:
|
||||
- No new or changed routes
|
||||
- Existing verification anchors remain:
|
||||
- `/admin/t/{tenant}/baseline-compare`
|
||||
- `/admin/operations`
|
||||
- `/admin/operations/{run}`
|
||||
- **Data Ownership**:
|
||||
- Existing tenant-owned compare runs, findings, summaries, and warnings remain authoritative.
|
||||
- No new persistence, ownership boundary, or data shape is introduced.
|
||||
- **RBAC**:
|
||||
- Existing compare, monitoring, and tenant access rules remain unchanged.
|
||||
- No new membership rule, capability, or operator-facing action is introduced.
|
||||
- No destructive action behavior changes are included.
|
||||
|
||||
## Assumptions & Dependencies
|
||||
|
||||
- Spec 203 already made strategy-driven compare execution the authoritative live path for the baseline compare orchestration unit.
|
||||
- The retained legacy drift block is not part of the productive call graph and can be removed without functional redesign.
|
||||
- Any test or inspection logic that still depends on deleted internal helper structure is considered stale and may be narrowed to current observable behavior.
|
||||
- Successful completion depends on focused regression coverage for strategy dispatch, compare execution, finding lifecycle behavior, summary computation, gap handling, warning handling, and run completion.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Read the live compare architecture without dead-path noise (Priority: P1)
|
||||
|
||||
As a contributor reviewing baseline compare behavior, I want the orchestration unit to show only the active compare path so that I can understand current architecture without first disproving a retained legacy path.
|
||||
|
||||
**Why this priority**: This is the core value of the cleanup. If the dead path remains visible, the repository continues to teach the wrong architecture.
|
||||
|
||||
**Independent Test**: Inspect the orchestration unit after cleanup and confirm that only the active strategy-driven path remains while regression checks still pass.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the compare orchestration unit currently contains both live orchestration and retained legacy drift remnants, **When** a contributor reviews the file after cleanup, **Then** they can trace one active compare execution path without encountering a parallel legacy implementation.
|
||||
2. **Given** a reviewer follows the productive compare call graph after cleanup, **When** they inspect the orchestration flow, **Then** the repository no longer suggests that the removed pre-strategy drift logic is still active.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Preserve current compare behavior while removing dead code (Priority: P1)
|
||||
|
||||
As a product maintainer, I want the dead-code removal to leave compare behavior unchanged so that the cleanup can merge as a safe closure PR rather than another hidden refactor.
|
||||
|
||||
**Why this priority**: The cleanup is only valuable if it preserves the current compare lifecycle and does not force a second architecture review.
|
||||
|
||||
**Independent Test**: Run focused automated regression checks for the current compare flow and confirm that expected outcomes remain unchanged after the delete.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** existing baseline compare regression coverage, **When** the cleanup lands, **Then** strategy selection, compare execution, finding generation, summary computation, gap handling, warning handling, recurrence behavior, and run completion remain green.
|
||||
2. **Given** a compare run that already uses the current strategy infrastructure, **When** it executes after cleanup, **Then** it produces the same class of persisted results and operator-observable outcomes as before.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep the review diff mechanically narrow (Priority: P2)
|
||||
|
||||
As a reviewer, I want the cleanup diff to stay limited to dead-path deletion and its direct fallout so that I can approve it quickly without re-reviewing unrelated architecture decisions.
|
||||
|
||||
**Why this priority**: The main delivery risk is not deletion itself, but that the cleanup grows into an opportunistic mixed refactor.
|
||||
|
||||
**Independent Test**: Inspect the resulting PR scope and confirm that it is limited to `CompareBaselineToTenantJob`, the focused compare guard and regression files that prove the delete is safe, and only direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy` if the implementation explicitly justifies it.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** nearby follow-up ideas exist, **When** the cleanup is implemented, **Then** the final diff touches `CompareBaselineToTenantJob`, the focused compare guard and regression files, and no other production file except a direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy`.
|
||||
2. **Given** adjacent imports, comments, or docblocks still imply the removed path exists, **When** the cleanup finishes, **Then** only those directly obsolete remnants are adjusted and no unrelated rename, schema, or UI work appears in the same PR.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A seemingly legacy helper still has an indirect productive call site or compatibility responsibility.
|
||||
- A regression test or inspection helper still asserts deleted internal structure instead of current compare behavior.
|
||||
- Summary, warning, or finding behavior depends on normalization that must remain preserved through the active path even after the dead block is removed.
|
||||
- Internal comments or docblocks still describe a fallback or alternate drift path that no longer exists.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature does not introduce a new external integration, new write pathway, or new long-running workflow. It removes dead internal compare logic from an existing execution unit and keeps existing tenant isolation, run observability, and audit behavior unchanged. Regression coverage must prove there is no behavior change.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The cleanup introduces no new persistence, abstraction, state family, or semantic layer. The narrowest correct implementation is deletion of the dead path and cleanup of its direct fallout.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing run creation, lifecycle ownership, summary-count rules, and three-surface feedback behavior remain unchanged. Any touched regression coverage must continue to protect the current compare run lifecycle.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** No authorization behavior changes are part of this feature. Existing workspace and tenant access rules, including current `404` and `403` behavior, remain untouched.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Not applicable; no status or badge semantics change.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** Not applicable; no Filament or Blade surface changes are introduced.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing labels remain unchanged. Only misleading internal comments or docblocks may be corrected.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** No new or changed operator-facing decision surface is introduced.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** No surface or action changes are in scope.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** Not applicable; no header, row, or bulk action structure changes are introduced.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Not applicable; no operator-facing surface is added or materially refactored.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature removes redundant legacy logic instead of adding a new interpretation layer. Tests stay focused on behavior and architectural truth rather than thin indirection.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** Not applicable.
|
||||
|
||||
**Constitution alignment (UX-001 - Layout & Information Architecture):** Not applicable.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-205-001 Single active compare path**: The baseline compare orchestration unit MUST retain only the active strategy-driven compare execution path.
|
||||
- **FR-205-002 Legacy drift removal**: The obsolete pre-strategy drift computation block retained in the orchestration unit MUST be removed.
|
||||
- **FR-205-003 Helper cleanup**: Any helper method, local utility, or internal dependency used exclusively by the removed legacy path MUST also be removed.
|
||||
- **FR-205-004 Truthful dependency surface**: After cleanup, imports, comments, and docblocks in the orchestration unit MUST reflect only currently active dependencies and behavior.
|
||||
- **FR-205-005 No behavioral reshaping**: The cleanup MUST NOT change strategy selection, compare execution, finding generation, summary computation, gap handling, warning handling, recurrence behavior, reason handling, or run completion behavior.
|
||||
- **FR-205-006 No speculative follow-up work**: The cleanup MUST NOT introduce new abstractions, naming generalizations, schema changes, UI changes, or unrelated refactors.
|
||||
- **FR-205-007 Regression proof**: Automated regression coverage MUST demonstrate that the active strategy-driven compare path still executes correctly after the cleanup.
|
||||
- **FR-205-008 Call-graph safety**: Before the cleanup is considered complete, the removed legacy path and its exclusive helpers MUST have no remaining productive call sites in the surrounding production code.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-205-001 Reviewability**: The resulting change set MUST be limited to `CompareBaselineToTenantJob`, the focused compare guard and regression files needed to prove delete safety, and only direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy` when explicitly justified.
|
||||
- **NFR-205-002 Architectural honesty**: After cleanup, an architecture review of the compare orchestration unit MUST find one authoritative compare execution path rather than a retained parallel legacy implementation.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-205-001**: A contributor can inspect the compare orchestration unit and identify a single active compare execution path without needing to rule out a retained parallel legacy path.
|
||||
- **SC-205-002**: Focused automated checks covering the current strategy-driven compare flow pass after the cleanup with no newly introduced failures.
|
||||
- **SC-205-003**: Review of the cleanup diff shows touched files limited to `CompareBaselineToTenantJob`, the focused compare guard and regression files, and at most a direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy`.
|
||||
- **SC-205-004**: Post-cleanup architecture review no longer reports a retained pre-strategy drift computation block in the compare orchestration unit.
|
||||
194
specs/205-compare-job-cleanup/tasks.md
Normal file
194
specs/205-compare-job-cleanup/tasks.md
Normal file
@ -0,0 +1,194 @@
|
||||
# Tasks: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
**Input**: Design documents from `/specs/205-compare-job-cleanup/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/compare-job-legacy-drift-cleanup.logical.openapi.yaml`, `quickstart.md`
|
||||
|
||||
**Tests**: Required. This cleanup changes runtime compare orchestration code in `CompareBaselineToTenantJob` and must keep the current strategy-driven compare path green through focused Pest regression and guard coverage.
|
||||
**Operations**: Existing `baseline_compare` `OperationRun` behavior remains unchanged. No new run type, feedback surface, or monitoring path is introduced.
|
||||
**RBAC**: No authorization change is in scope. Existing compare and monitoring permissions remain authoritative, and tasks must avoid introducing RBAC drift while touching the orchestration file.
|
||||
**Operator Surfaces**: No operator-facing surface change is in scope. Existing tenant compare and monitoring routes remain verification anchors only.
|
||||
**Filament UI Action Surfaces**: No Filament resource, page, relation manager, or action-hierarchy change is planned.
|
||||
**Proportionality**: This spec removes dead code only and must not introduce new abstractions, persistence, or semantic layers.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so the cleanup can be implemented and verified in narrow, reviewable increments. Recommended delivery order is `US1 -> US2 -> US3`, with `US1 + US2` forming the practical merge-ready slice.
|
||||
|
||||
## Phase 1: Setup (Shared Baseline)
|
||||
|
||||
**Purpose**: Capture the full required pre-cleanup regression baseline and inspect the active compare boundary before editing the orchestration file.
|
||||
|
||||
- [X] T001 [P] Capture the required pre-cleanup regression baseline by running `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
||||
- [X] T002 [P] Inspect the live compare dispatch and candidate legacy helper cluster in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` and `apps/platform/app/Services/Baselines/BaselineCompareService.php`
|
||||
|
||||
**Checkpoint**: The team has a known-good full focused baseline and a confirmed starting map of the live compare path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Call-Graph Confirmation)
|
||||
|
||||
**Purpose**: Confirm the dead-vs-live method boundary so the cleanup deletes only unreachable logic.
|
||||
|
||||
**CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T003 [P] Map exclusive callers for `computeDrift()` and its adjacent helper cluster in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T004 [P] Review `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php` and `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` to confirm the live strategy contract needs no structural change for this cleanup
|
||||
|
||||
**Checkpoint**: The delete list is confirmed and the live strategy-owned path is explicitly out of scope for redesign.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Read the live compare architecture without dead-path noise (Priority: P1) MVP
|
||||
|
||||
**Goal**: Remove the retained monolithic drift-compute path so the compare job shows one real execution engine instead of a parallel historical implementation.
|
||||
|
||||
**Independent Test**: Inspect `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` after cleanup and confirm that the live compare path still flows through strategy selection and `strategy->compare(...)`, while the legacy helper names are absent and the guard suite passes.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
> **NOTE**: Update these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T005 [P] [US1] Extend legacy helper absence assertions in `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`
|
||||
- [X] T006 [P] [US1] Reconfirm one-engine orchestration guard coverage in `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T007 [US1] Remove `computeDrift()` and its exclusive helper cluster from `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T008 [US1] Remove dead imports and stale fallback comments or docblocks left by the deleted cluster in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T009 [US1] Re-run the guard coverage in `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php` and `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` against `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
|
||||
**Checkpoint**: The compare job is structurally honest again and the guard suite blocks reintroduction of the deleted legacy path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Preserve current compare behavior while removing dead code (Priority: P1)
|
||||
|
||||
**Goal**: Prove that the cleanup leaves strategy selection, compare execution, finding lifecycle behavior, summary computation, gap handling, warning handling, reason translation, and run completion unchanged.
|
||||
|
||||
**Independent Test**: Run the required focused regression slice and confirm that `FindingFidelityTest`, `BaselineCompareFindingsTest`, `BaselineCompareGapClassificationTest`, `BaselineCompareWhyNoFindingsReasonCodeTest`, `Spec116OneEngineGuardTest`, `Spec118NoLegacyBaselineDriftGuardTest`, `OperationLifecycleOpsUxGuardTest`, `BaselineOperationRunGuardTest`, `OperationSummaryKeysSpecTest`, `SummaryCountsWhitelistTest`, and the matrix enqueue-path check all remain green after the delete.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
> **NOTE**: Update these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T010 [P] [US2] Tighten strategy-driven execution assertions in `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`
|
||||
- [X] T011 [P] [US2] Tighten finding lifecycle, recurrence, and summary outcome assertions in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
|
||||
- [X] T012 [P] [US2] Tighten gap classification, warning-outcome, and reason-code assertions in `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
|
||||
- [X] T013 [P] [US2] Reconfirm `OperationRun` lifecycle and summary-count guard coverage in `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, and `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T014 [US2] Keep live compare behavior unchanged while reconciling any cleanup fallout in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`; do not modify `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php` or `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` unless a blocker proves the smallest direct fix is required
|
||||
- [X] T015 [US2] Run the required focused regression slice in `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
||||
|
||||
**Checkpoint**: The cleanup is behaviorally safe and the current compare lifecycle still works through the strategy-owned path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Keep the review diff mechanically narrow (Priority: P2)
|
||||
|
||||
**Goal**: Keep the cleanup PR reviewable by limiting the touched area to dead-path deletion, direct fallout cleanup, and minimal regression updates.
|
||||
|
||||
**Independent Test**: Inspect the final changed-file set and confirm that it is limited to `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, the focused compare guard and regression files, and only direct blocker-driven follow-up in `apps/platform/app/Services/Baselines/BaselineCompareService.php` or `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` if justified.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T016 [P] [US3] Audit the touched-file set and strip opportunistic edits outside `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
||||
- [X] T017 [P] [US3] Review `apps/platform/app/Services/Baselines/BaselineCompareService.php` and `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` to confirm they remain unchanged or carry only the smallest blocker-driven follow-up required by the cleanup
|
||||
- [X] T018 [US3] Verify the final diff stays limited to `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, the focused compare guard and regression files above including `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, and only direct blocker-driven follow-up in `apps/platform/app/Services/Baselines/BaselineCompareService.php` or `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php`
|
||||
|
||||
**Checkpoint**: The PR stays small, reviewable, and aligned with Spec 205 rather than drifting into a broader compare refactor.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Apply formatting and rerun the final focused Sail pack before handing the cleanup over for review.
|
||||
|
||||
- [X] T019 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched PHP files centered on `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T020 Run the final focused Sail pack from `specs/205-compare-job-cleanup/quickstart.md` covering `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user story work.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||
- **User Story 2 (Phase 4)**: Depends on User Story 1 because behavior preservation is verified after the delete lands.
|
||||
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because scope review is only meaningful once the delete, gap and reason coverage, and run-guard updates exist.
|
||||
- **Polish (Phase 6)**: Depends on all user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependency beyond Foundational.
|
||||
- **US2**: Depends on US1 because the focused regression slice validates the actual cleanup result.
|
||||
- **US3**: Depends on US1 and US2 because it is the final scope-control pass over the implemented cleanup.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Update or tighten the story's tests first and confirm they fail before implementation.
|
||||
- Keep compare start orchestration in `apps/platform/app/Services/Baselines/BaselineCompareService.php` and live strategy behavior in `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` out of scope unless a blocker demands the smallest possible fix.
|
||||
- Finish each story's focused verification before moving to the next story.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T001` and `T002` can run in parallel.
|
||||
- `T003` and `T004` can run in parallel.
|
||||
- Within US1, `T005` and `T006` can run in parallel.
|
||||
- Within US2, `T010`, `T011`, `T012`, and `T013` can run in parallel.
|
||||
- Within US3, `T016` and `T017` can run in parallel.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Parallel guard updates for US1
|
||||
T005 Extend legacy helper absence assertions in Spec118NoLegacyBaselineDriftGuardTest.php
|
||||
T006 Reconfirm one-engine orchestration guard coverage in Spec116OneEngineGuardTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Parallel regression tightening for US2
|
||||
T010 Tighten strategy-driven execution assertions in FindingFidelityTest.php
|
||||
T011 Tighten finding lifecycle and summary outcome assertions in BaselineCompareFindingsTest.php
|
||||
T012 Tighten gap classification and reason-code assertions in BaselineCompareGapClassificationTest.php and BaselineCompareWhyNoFindingsReasonCodeTest.php
|
||||
T013 Reconfirm OperationRun lifecycle and summary-count guard coverage in OperationLifecycleOpsUxGuardTest.php, BaselineOperationRunGuardTest.php, OperationSummaryKeysSpecTest.php, and SummaryCountsWhitelistTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Parallel scope review for US3
|
||||
T016 Audit the touched-file set and strip opportunistic edits outside the focused cleanup files
|
||||
T017 Review BaselineCompareService.php and IntuneCompareStrategy.php for blocker-only follow-up changes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Complete Setup and Foundational work.
|
||||
2. Deliver US1 to remove the dead path and restore a single truthful compare engine in the orchestration file.
|
||||
3. Immediately follow with US2 so the cleanup is merge-safe, not just structurally cleaner.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Finish US1 and confirm the guard suite blocks the deleted helper cluster.
|
||||
2. Finish US2 and prove the live strategy-driven compare behavior remains unchanged.
|
||||
3. Finish US3 to keep the cleanup PR mechanically narrow.
|
||||
4. Finish with formatting and the final focused Sail pack from Phase 6.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor handles Setup and Foundational call-graph confirmation.
|
||||
2. After Foundation is green:
|
||||
T005 and T006 can be prepared in parallel for US1.
|
||||
T010, T011, T012, and T013 can be prepared in parallel for US2.
|
||||
T016 and T017 can be prepared in parallel for US3 once the cleanup diff exists.
|
||||
3. Merge back for the final diff review, formatting, and focused Sail verification.
|
||||
Loading…
Reference in New Issue
Block a user