TenantAtlas/apps/platform/app/Support/OperationCatalog.php
ahmido c86b399b43
Some checks failed
Main Confidence / confidence (push) Failing after 53s
feat(219): Finding ownership semantics + LEAN-001 constitution + backup_set unification (#256)
## Summary

This PR delivers three related improvements:

### 1. Finding Ownership Semantics (Spec 219)
- Add responsibility/accountability labels to findings and finding exceptions
- `owner_user_id` = accountable party (governance owner)
- `assignee_user_id` = responsible party (technical implementer)
- Expose Assign/Reassign actions in FindingResource with audit logging
- Add ownership columns and filters to finding list
- Propagate owner from finding to exception on creation
- Tests: ownership semantics, assignment audit, workflow actions

### 2. Constitution v2.7.0 — LEAN-001 Pre-Production Lean Doctrine
- New principle forbidding legacy aliases, migration shims, dual-write logic, and compatibility fixtures in a pre-production codebase
- AI-agent 4-question verification gate before adding any compatibility path
- Review rule: compatibility shims without answering the gate questions = merge blocker
- Exit condition: LEAN-001 expires at first production deployment
- Spec template: added default "Compatibility posture" block
- Agent instructions: added "Pre-production compatibility check" section

### 3. Backup Set Operation Type Unification
- Unified `backup_set.add_policies` and `backup_set.remove_policies` into single canonical `backup_set.update`
- Removed all legacy aliases, constants, and test fixtures
- Added lifecycle coverage for `backup_set.update` in config
- Updated all 14+ test files referencing legacy types

### Spec Artifacts
- `specs/219-finding-ownership-semantics/` — full spec, plan, tasks, research, data model, contracts, checklist

### Tests
- All affected tests pass (OperationCatalog, backup set, finding workflow, ownership semantics)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #256
2026-04-20 17:54:33 +00:00

309 lines
19 KiB
PHP

<?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
{
public const string TYPE_PERMISSION_POSTURE_CHECK = 'permission_posture_check';
/**
* @return array<string, string>
*/
public static function labels(): array
{
$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
{
$operationType = trim($operationType);
if ($operationType === '') {
return 'Operation';
}
return self::resolve($operationType)->canonical->displayLabel;
}
public static function expectedDurationSeconds(string $operationType): ?int
{
return self::resolve($operationType)->canonical->expectedDurationSeconds;
}
/**
* @return array<int, string>
*/
public static function allowedSummaryKeys(): array
{
return OperationSummaryKeys::all();
}
public static function governanceArtifactFamily(string $operationType): ?string
{
return self::resolve($operationType)->canonical->artifactFamily;
}
public static function isGovernanceArtifactOperation(string $operationType): bool
{
return self::governanceArtifactFamily($operationType) !== null;
}
public static function supportsOperatorExplanation(string $operationType): bool
{
return self::resolve($operationType)->canonical->supportsOperatorExplanation;
}
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.update', 'backup_set.update', 'canonical', true),
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),
];
}
}