feat: canonical control catalog foundation (#272)
Some checks failed
Main Confidence / confidence (push) Failing after 50s

## Summary
- add a config-seeded canonical control catalog plus shared resolution primitives and Microsoft subject bindings
- propagate canonical control references into findings-derived evidence snapshots and tenant review composition
- add the feature spec artifacts and focused Pest coverage, plus the supporting workspace and Sail helper adjustments included in this branch

## Testing
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php tests/Feature/PlatformRelocation/CommandModelSmokeTest.php
- cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #272
This commit is contained in:
ahmido 2026-04-24 12:26:02 +00:00
parent 2752515da5
commit 6a5b8a3a11
35 changed files with 2860 additions and 8 deletions

View File

@ -1,4 +1,4 @@
[mcp_servers.laravel-boost]
command = "vendor/bin/sail"
command = "./scripts/platform-sail"
args = ["artisan", "boost:mcp"]
cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"
cwd = "/Users/ahmeddarrazi/Documents/projects/wt-plattform"

View File

@ -248,6 +248,8 @@ ## Active Technologies
- Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces (235-baseline-capture-truth)
- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure (236-canonical-control-catalog-foundation)
- PostgreSQL for existing downstream governance artifacts plus a product-seeded in-repo canonical control registry; no new DB-backed control authoring table in the first slice (236-canonical-control-catalog-foundation)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -282,9 +284,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 236-canonical-control-catalog-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure
- 235-baseline-capture-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces
- 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests
- 233-stale-run-visibility: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks`
<!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check

View File

@ -40,9 +40,13 @@ mkdir -p "$FEATURE_DIR"
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
if [[ -f "$TEMPLATE" ]]; then
cp "$TEMPLATE" "$IMPL_PLAN"
echo "Copied plan template to $IMPL_PLAN"
if ! $JSON_MODE; then
echo "Copied plan template to $IMPL_PLAN"
fi
else
echo "Warning: Plan template not found at $TEMPLATE"
if ! $JSON_MODE; then
echo "Warning: Plan template not found at $TEMPLATE"
fi
# Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN"
fi

View File

@ -45,4 +45,17 @@ public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return list<array<string, mixed>>
*/
public function canonicalControlReferences(): array
{
$payload = is_array($this->summary_payload) ? $this->summary_payload : [];
$references = $payload['canonical_controls'] ?? [];
return is_array($references)
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
: [];
}
}

View File

@ -192,4 +192,17 @@ public function publishBlockers(): array
return is_array($blockers) ? array_values(array_map('strval', $blockers)) : [];
}
/**
* @return list<array<string, mixed>>
*/
public function canonicalControlReferences(): array
{
$summary = is_array($this->summary) ? $this->summary : [];
$references = $summary['canonical_controls'] ?? [];
return is_array($references)
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
: [];
}
}

View File

@ -219,6 +219,9 @@ public function buildSnapshotPayload(Tenant $tenant): array
'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null)
? $findingsSummary['report_bucket_counts']
: [],
'canonical_controls' => is_array($findingsSummary['canonical_controls'] ?? null)
? $findingsSummary['canonical_controls']
: [],
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
? $findingsSummary['risk_acceptance']
: [

View File

@ -10,12 +10,15 @@
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Governance\Controls\CanonicalControlResolutionRequest;
use App\Support\Governance\Controls\CanonicalControlResolver;
final class FindingsSummarySource implements EvidenceSourceProvider
{
public function __construct(
private readonly FindingRiskGovernanceResolver $governanceResolver,
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
private readonly CanonicalControlResolver $canonicalControlResolver,
) {}
public function key(): string
@ -36,6 +39,7 @@ public function collect(Tenant $tenant): array
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
$outcome = $this->findingOutcomeSemantics->describe($finding);
$canonicalControlResolution = $this->canonicalControlResolutionFor($finding);
return [
'id' => (int) $finding->getKey(),
@ -57,6 +61,7 @@ public function collect(Tenant $tenant): array
'report_bucket' => $outcome['report_bucket'],
'governance_state' => $governanceState,
] : null,
'canonical_control_resolution' => $canonicalControlResolution,
'governance_state' => $governanceState,
'governance_warning' => $governanceWarning,
];
@ -81,6 +86,12 @@ public function collect(Tenant $tenant): array
$reportBucketCounts[$reportBucket]++;
}
}
$canonicalControls = $entries
->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control'))
->filter(static fn (mixed $control): bool => is_array($control) && filled($control['control_key'] ?? null))
->unique(static fn (array $control): string => (string) $control['control_key'])
->values()
->all();
$riskAcceptedEntries = $entries->filter(
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
@ -115,6 +126,7 @@ public function collect(Tenant $tenant): array
],
'outcome_counts' => $outcomeCounts,
'report_bucket_counts' => $reportBucketCounts,
'canonical_controls' => $canonicalControls,
'entries' => $entries->all(),
];
@ -133,4 +145,68 @@ public function collect(Tenant $tenant): array
'sort_order' => 10,
];
}
/**
* @return array<string, mixed>
*/
private function canonicalControlResolutionFor(Finding $finding): array
{
return $this->canonicalControlResolver
->resolve($this->resolutionRequestFor($finding))
->toArray();
}
private function resolutionRequestFor(Finding $finding): CanonicalControlResolutionRequest
{
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
$findingType = (string) $finding->finding_type;
if ($findingType === Finding::FINDING_TYPE_PERMISSION_POSTURE) {
return new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: 'permission_posture',
workload: 'entra',
signalKey: 'permission_posture.required_graph_permission',
);
}
if ($findingType === Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) {
$roleTemplateId = (string) ($evidence['role_template_id'] ?? '');
return new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: 'entra_admin_roles',
workload: 'entra',
signalKey: $roleTemplateId === '62e90394-69f5-4237-9190-012177145e10'
? 'entra_admin_roles.global_admin_assignment'
: 'entra_admin_roles.privileged_role_assignment',
);
}
if ($findingType === Finding::FINDING_TYPE_DRIFT) {
$policyType = is_string($evidence['policy_type'] ?? null) && trim((string) $evidence['policy_type']) !== ''
? trim((string) $evidence['policy_type'])
: 'drift';
return new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: $policyType,
workload: 'intune',
signalKey: match ($policyType) {
'deviceCompliancePolicy' => 'intune.device_compliance_policy',
'drift' => 'finding.drift',
default => 'intune.device_configuration_drift',
},
);
}
return new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: $findingType,
);
}
}

View File

@ -65,6 +65,9 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
? data_get($sections, '0.summary_payload.finding_report_buckets')
: [],
'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls'))
? data_get($sections, '0.summary_payload.canonical_controls')
: [],
'report_count' => 2,
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
'highlights' => data_get($sections, '0.render_payload.highlights', []),

View File

@ -55,6 +55,7 @@ private function executiveSummarySection(
$findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : [];
$findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [];
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
$canonicalControls = is_array($findingsSummary['canonical_controls'] ?? null) ? $findingsSummary['canonical_controls'] : [];
$openCount = (int) ($findingsSummary['open_count'] ?? 0);
$findingCount = (int) ($findingsSummary['count'] ?? 0);
@ -70,6 +71,7 @@ private function executiveSummarySection(
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
sprintf('%d baseline drift findings remain open.', $driftCount),
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
$canonicalControls !== [] ? sprintf('%d canonical controls are referenced by the findings evidence.', count($canonicalControls)) : null,
sprintf('%d risk-accepted findings are currently governed.', (int) ($riskAcceptance['valid_governed_count'] ?? 0)),
sprintf('%d privileged Entra roles are captured in the evidence basis.', (int) ($rolesSummary['role_count'] ?? 0)),
]));
@ -96,6 +98,8 @@ private function executiveSummarySection(
'baseline_drift_count' => $driftCount,
'failed_operation_count' => $operationFailures,
'partial_operation_count' => $partialOperations,
'canonical_control_count' => count($canonicalControls),
'canonical_controls' => $canonicalControls,
'risk_acceptance' => $riskAcceptance,
],
'render_payload' => [
@ -145,6 +149,7 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
'summary_payload' => [
'open_count' => (int) ($summary['open_count'] ?? 0),
'severity_counts' => is_array($summary['severity_counts'] ?? null) ? $summary['severity_counts'] : [],
'canonical_controls' => $this->canonicalControlsFromEntries($entries),
],
'render_payload' => [
'entries' => $entries,
@ -178,6 +183,7 @@ private function acceptedRisksSection(?EvidenceSnapshotItem $findingsItem): arra
'expired_count' => (int) ($riskAcceptance['expired_count'] ?? 0),
'revoked_count' => (int) ($riskAcceptance['revoked_count'] ?? 0),
'missing_exception_count' => (int) ($riskAcceptance['missing_exception_count'] ?? 0),
'canonical_controls' => $this->canonicalControlsFromEntries($entries),
],
'render_payload' => [
'entries' => $entries,
@ -293,6 +299,20 @@ private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string
return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null;
}
/**
* @param list<array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function canonicalControlsFromEntries(array $entries): array
{
return collect($entries)
->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control'))
->filter(static fn (mixed $control): bool => is_array($control) && filled($control['control_key'] ?? null))
->unique(static fn (array $control): string => (string) $control['control_key'])
->values()
->all();
}
/**
* @param array<int, TenantReviewCompletenessState> $states
*/

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use InvalidArgumentException;
final readonly class ArtifactSuitability
{
public function __construct(
public bool $baseline,
public bool $drift,
public bool $finding,
public bool $exception,
public bool $evidence,
public bool $review,
public bool $report,
) {}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
foreach (self::requiredKeys() as $key) {
if (! array_key_exists($key, $data)) {
throw new InvalidArgumentException(sprintf('Canonical control artifact suitability is missing [%s].', $key));
}
}
return new self(
baseline: (bool) $data['baseline'],
drift: (bool) $data['drift'],
finding: (bool) $data['finding'],
exception: (bool) $data['exception'],
evidence: (bool) $data['evidence'],
review: (bool) $data['review'],
report: (bool) $data['report'],
);
}
/**
* @return array{baseline: bool, drift: bool, finding: bool, exception: bool, evidence: bool, review: bool, report: bool}
*/
public function toArray(): array
{
return [
'baseline' => $this->baseline,
'drift' => $this->drift,
'finding' => $this->finding,
'exception' => $this->exception,
'evidence' => $this->evidence,
'review' => $this->review,
'report' => $this->report,
];
}
/**
* @return list<string>
*/
public static function requiredKeys(): array
{
return ['baseline', 'drift', 'finding', 'exception', 'evidence', 'review', 'report'];
}
}

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use InvalidArgumentException;
final class CanonicalControlCatalog
{
/**
* @var list<CanonicalControlDefinition>
*/
private array $definitions;
/**
* @var list<MicrosoftSubjectBinding>
*/
private array $microsoftBindings;
/**
* @param list<array<string, mixed>>|null $controls
*/
public function __construct(?array $controls = null)
{
$controls ??= config('canonical_controls.controls', []);
if (! is_array($controls)) {
throw new InvalidArgumentException('Canonical controls config must define a controls array.');
}
$this->definitions = [];
$this->microsoftBindings = [];
foreach ($controls as $control) {
if (! is_array($control)) {
throw new InvalidArgumentException('Canonical control entries must be arrays.');
}
$definition = CanonicalControlDefinition::fromArray($control);
if ($this->find($definition->controlKey) instanceof CanonicalControlDefinition) {
throw new InvalidArgumentException(sprintf('Duplicate canonical control key [%s].', $definition->controlKey));
}
$this->definitions[] = $definition;
$bindings = is_array($control['microsoft_bindings'] ?? null) ? $control['microsoft_bindings'] : [];
foreach ($bindings as $binding) {
if (! is_array($binding)) {
throw new InvalidArgumentException(sprintf('Microsoft bindings for [%s] must be arrays.', $definition->controlKey));
}
$this->microsoftBindings[] = MicrosoftSubjectBinding::fromArray($definition->controlKey, $binding);
}
}
usort(
$this->definitions,
static fn (CanonicalControlDefinition $left, CanonicalControlDefinition $right): int => $left->controlKey <=> $right->controlKey,
);
}
/**
* @return list<CanonicalControlDefinition>
*/
public function all(): array
{
return $this->definitions;
}
/**
* @return list<CanonicalControlDefinition>
*/
public function active(): array
{
return array_values(array_filter(
$this->definitions,
static fn (CanonicalControlDefinition $definition): bool => ! $definition->isRetired(),
));
}
public function find(string $controlKey): ?CanonicalControlDefinition
{
$controlKey = trim($controlKey);
foreach ($this->definitions as $definition) {
if ($definition->controlKey === $controlKey) {
return $definition;
}
}
return null;
}
/**
* @return list<MicrosoftSubjectBinding>
*/
public function microsoftBindings(): array
{
return $this->microsoftBindings;
}
/**
* @return list<MicrosoftSubjectBinding>
*/
public function microsoftBindingsForControl(string $controlKey): array
{
return array_values(array_filter(
$this->microsoftBindings,
static fn (MicrosoftSubjectBinding $binding): bool => $binding->controlKey === trim($controlKey),
));
}
/**
* @return list<array<string, mixed>>
*/
public function listPayload(): array
{
return array_map(
static fn (CanonicalControlDefinition $definition): array => $definition->toArray(),
$this->all(),
);
}
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use InvalidArgumentException;
final readonly class CanonicalControlDefinition
{
/**
* @param list<EvidenceArchetype> $evidenceArchetypes
*/
public function __construct(
public string $controlKey,
public string $name,
public string $domainKey,
public string $subdomainKey,
public string $controlClass,
public string $summary,
public string $operatorDescription,
public DetectabilityClass $detectabilityClass,
public EvaluationStrategy $evaluationStrategy,
public array $evidenceArchetypes,
public ArtifactSuitability $artifactSuitability,
public string $historicalStatus = 'active',
) {
foreach ([
'control key' => $this->controlKey,
'name' => $this->name,
'domain key' => $this->domainKey,
'subdomain key' => $this->subdomainKey,
'control class' => $this->controlClass,
'summary' => $this->summary,
'operator description' => $this->operatorDescription,
'historical status' => $this->historicalStatus,
] as $label => $value) {
if (trim($value) === '') {
throw new InvalidArgumentException(sprintf('Canonical control definitions require a non-empty %s.', $label));
}
}
if ($this->controlKey !== mb_strtolower($this->controlKey) || preg_match('/^[a-z][a-z0-9_]*$/', $this->controlKey) !== 1) {
throw new InvalidArgumentException(sprintf('Canonical control key [%s] must be a lowercase provider-neutral slug.', $this->controlKey));
}
if (! in_array($this->historicalStatus, ['active', 'retired'], true)) {
throw new InvalidArgumentException(sprintf('Canonical control [%s] has an unsupported historical status.', $this->controlKey));
}
if ($this->evidenceArchetypes === []) {
throw new InvalidArgumentException(sprintf('Canonical control [%s] must declare at least one evidence archetype.', $this->controlKey));
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
controlKey: (string) ($data['control_key'] ?? ''),
name: (string) ($data['name'] ?? ''),
domainKey: (string) ($data['domain_key'] ?? ''),
subdomainKey: (string) ($data['subdomain_key'] ?? ''),
controlClass: (string) ($data['control_class'] ?? ''),
summary: (string) ($data['summary'] ?? ''),
operatorDescription: (string) ($data['operator_description'] ?? ''),
detectabilityClass: DetectabilityClass::from((string) ($data['detectability_class'] ?? '')),
evaluationStrategy: EvaluationStrategy::from((string) ($data['evaluation_strategy'] ?? '')),
evidenceArchetypes: self::evidenceArchetypes($data['evidence_archetypes'] ?? []),
artifactSuitability: ArtifactSuitability::fromArray(is_array($data['artifact_suitability'] ?? null) ? $data['artifact_suitability'] : []),
historicalStatus: (string) ($data['historical_status'] ?? 'active'),
);
}
/**
* @return array{
* control_key: string,
* name: string,
* domain_key: string,
* subdomain_key: string,
* control_class: string,
* summary: string,
* operator_description: string,
* detectability_class: string,
* evaluation_strategy: string,
* evidence_archetypes: list<string>,
* artifact_suitability: array{baseline: bool, drift: bool, finding: bool, exception: bool, evidence: bool, review: bool, report: bool},
* historical_status: string
* }
*/
public function toArray(): array
{
return [
'control_key' => $this->controlKey,
'name' => $this->name,
'domain_key' => $this->domainKey,
'subdomain_key' => $this->subdomainKey,
'control_class' => $this->controlClass,
'summary' => $this->summary,
'operator_description' => $this->operatorDescription,
'detectability_class' => $this->detectabilityClass->value,
'evaluation_strategy' => $this->evaluationStrategy->value,
'evidence_archetypes' => array_map(
static fn (EvidenceArchetype $archetype): string => $archetype->value,
$this->evidenceArchetypes,
),
'artifact_suitability' => $this->artifactSuitability->toArray(),
'historical_status' => $this->historicalStatus,
];
}
public function isRetired(): bool
{
return $this->historicalStatus === 'retired';
}
/**
* @param iterable<mixed> $values
* @return list<EvidenceArchetype>
*/
private static function evidenceArchetypes(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): EvidenceArchetype => EvidenceArchetype::from(trim($value)))
->values()
->all();
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
final readonly class CanonicalControlResolutionRequest
{
public function __construct(
public string $provider,
public string $consumerContext,
public ?string $subjectFamilyKey = null,
public ?string $workload = null,
public ?string $signalKey = null,
) {}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
provider: self::normalize((string) ($data['provider'] ?? '')),
consumerContext: self::normalize((string) ($data['consumer_context'] ?? '')),
subjectFamilyKey: self::optionalString($data['subject_family_key'] ?? null),
workload: self::optionalString($data['workload'] ?? null),
signalKey: self::optionalString($data['signal_key'] ?? null),
);
}
public function hasDiscriminator(): bool
{
return $this->subjectFamilyKey !== null || $this->workload !== null || $this->signalKey !== null;
}
/**
* @return array{provider: string, subject_family_key: ?string, workload: ?string, signal_key: ?string, consumer_context: string}
*/
public function bindingContext(): array
{
return [
'provider' => $this->provider,
'subject_family_key' => $this->subjectFamilyKey,
'workload' => $this->workload,
'signal_key' => $this->signalKey,
'consumer_context' => $this->consumerContext,
];
}
private static function optionalString(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$normalized = self::normalize($value);
return $normalized === '' ? null : $normalized;
}
private static function normalize(string $value): string
{
return trim($value);
}
}

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
final readonly class CanonicalControlResolutionResult
{
/**
* @param list<string> $candidateControlKeys
*/
private function __construct(
public string $status,
public ?CanonicalControlDefinition $control,
public ?string $reasonCode,
public array $bindingContext,
public array $candidateControlKeys = [],
) {}
public static function resolved(CanonicalControlDefinition $definition): self
{
return new self(
status: 'resolved',
control: $definition,
reasonCode: null,
bindingContext: [],
);
}
public static function unresolved(string $reasonCode, CanonicalControlResolutionRequest $request): self
{
return new self(
status: 'unresolved',
control: null,
reasonCode: $reasonCode,
bindingContext: $request->bindingContext(),
);
}
/**
* @param list<string> $candidateControlKeys
*/
public static function ambiguous(array $candidateControlKeys, CanonicalControlResolutionRequest $request): self
{
sort($candidateControlKeys, SORT_STRING);
return new self(
status: 'ambiguous',
control: null,
reasonCode: 'ambiguous_binding',
bindingContext: $request->bindingContext(),
candidateControlKeys: array_values(array_unique($candidateControlKeys)),
);
}
public function isResolved(): bool
{
return $this->status === 'resolved' && $this->control instanceof CanonicalControlDefinition;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
if ($this->isResolved()) {
return [
'status' => 'resolved',
'control' => $this->control?->toArray(),
];
}
if ($this->status === 'ambiguous') {
return [
'status' => 'ambiguous',
'reason_code' => $this->reasonCode,
'candidate_control_keys' => $this->candidateControlKeys,
'binding_context' => $this->bindingContext,
];
}
return [
'status' => 'unresolved',
'reason_code' => $this->reasonCode,
'binding_context' => $this->bindingContext,
];
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
final readonly class CanonicalControlResolver
{
/**
* @var list<string>
*/
private const SUPPORTED_CONTEXTS = ['baseline', 'drift', 'finding', 'evidence', 'exception', 'review', 'report'];
public function __construct(
private CanonicalControlCatalog $catalog,
) {}
public function resolve(CanonicalControlResolutionRequest $request): CanonicalControlResolutionResult
{
if ($request->provider !== 'microsoft') {
return CanonicalControlResolutionResult::unresolved('unsupported_provider', $request);
}
if (! in_array($request->consumerContext, self::SUPPORTED_CONTEXTS, true)) {
return CanonicalControlResolutionResult::unresolved('unsupported_consumer_context', $request);
}
if (! $request->hasDiscriminator()) {
return CanonicalControlResolutionResult::unresolved('insufficient_context', $request);
}
$bindings = array_values(array_filter(
$this->catalog->microsoftBindings(),
static fn (MicrosoftSubjectBinding $binding): bool => $binding->matches($request),
));
if ($bindings === []) {
return CanonicalControlResolutionResult::unresolved('missing_binding', $request);
}
$primaryBindings = array_values(array_filter(
$bindings,
static fn (MicrosoftSubjectBinding $binding): bool => $binding->primary,
));
if ($primaryBindings !== []) {
$bindings = $primaryBindings;
}
$candidateControlKeys = array_values(array_unique(array_map(
static fn (MicrosoftSubjectBinding $binding): string => $binding->controlKey,
$bindings,
)));
sort($candidateControlKeys, SORT_STRING);
if (count($candidateControlKeys) !== 1) {
return CanonicalControlResolutionResult::ambiguous($candidateControlKeys, $request);
}
$definition = $this->catalog->find($candidateControlKeys[0]);
if (! $definition instanceof CanonicalControlDefinition) {
return CanonicalControlResolutionResult::unresolved('missing_binding', $request);
}
return CanonicalControlResolutionResult::resolved($definition);
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
enum DetectabilityClass: string
{
case DirectTechnical = 'direct_technical';
case IndirectTechnical = 'indirect_technical';
case WorkflowAttested = 'workflow_attested';
case ExternalEvidenceOnly = 'external_evidence_only';
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
enum EvaluationStrategy: string
{
case StateEvaluated = 'state_evaluated';
case SignalInferred = 'signal_inferred';
case WorkflowConfirmed = 'workflow_confirmed';
case ExternallyAttested = 'externally_attested';
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
enum EvidenceArchetype: string
{
case ConfigurationSnapshot = 'configuration_snapshot';
case ExecutionResult = 'execution_result';
case PolicyOrAssignmentSummary = 'policy_or_assignment_summary';
case OperatorAttestation = 'operator_attestation';
case ExternalArtifactReference = 'external_artifact_reference';
}

View File

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance\Controls;
use InvalidArgumentException;
final readonly class MicrosoftSubjectBinding
{
/**
* @param list<string> $signalKeys
* @param list<string> $supportedContexts
*/
public function __construct(
public string $controlKey,
public ?string $subjectFamilyKey,
public ?string $workload,
public array $signalKeys,
public array $supportedContexts,
public bool $primary = false,
public ?string $notes = null,
) {
if (trim($this->controlKey) === '') {
throw new InvalidArgumentException('Microsoft subject bindings require a canonical control key.');
}
if ($this->subjectFamilyKey === null && $this->workload === null && $this->signalKeys === []) {
throw new InvalidArgumentException(sprintf('Microsoft subject binding for [%s] requires at least one discriminator.', $this->controlKey));
}
if ($this->supportedContexts === []) {
throw new InvalidArgumentException(sprintf('Microsoft subject binding for [%s] requires at least one supported context.', $this->controlKey));
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(string $controlKey, array $data): self
{
return new self(
controlKey: $controlKey,
subjectFamilyKey: self::optionalString($data['subject_family_key'] ?? null),
workload: self::optionalString($data['workload'] ?? null),
signalKeys: self::stringList($data['signal_keys'] ?? []),
supportedContexts: self::stringList($data['supported_contexts'] ?? []),
primary: (bool) ($data['primary'] ?? false),
notes: self::optionalString($data['notes'] ?? null),
);
}
public function supportsContext(string $consumerContext): bool
{
return in_array(trim($consumerContext), $this->supportedContexts, true);
}
public function matches(CanonicalControlResolutionRequest $request): bool
{
if ($request->provider !== 'microsoft') {
return false;
}
if (! $this->supportsContext($request->consumerContext)) {
return false;
}
if ($request->subjectFamilyKey !== null && $this->subjectFamilyKey !== $request->subjectFamilyKey) {
return false;
}
if ($request->workload !== null && $this->workload !== $request->workload) {
return false;
}
if ($request->signalKey !== null && ! in_array($request->signalKey, $this->signalKeys, true)) {
return false;
}
return true;
}
/**
* @return array{
* control_key: string,
* provider: string,
* subject_family_key: ?string,
* workload: ?string,
* signal_keys: list<string>,
* supported_contexts: list<string>,
* primary: bool,
* notes: ?string
* }
*/
public function toArray(): array
{
return [
'control_key' => $this->controlKey,
'provider' => 'microsoft',
'subject_family_key' => $this->subjectFamilyKey,
'workload' => $this->workload,
'signal_keys' => $this->signalKeys,
'supported_contexts' => $this->supportedContexts,
'primary' => $this->primary,
'notes' => $this->notes,
];
}
private static function optionalString(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
/**
* @param iterable<mixed> $values
* @return list<string>
*/
private static function stringList(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): string => trim($value))
->values()
->all();
}
}

View File

@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
return [
'controls' => [
[
'control_key' => 'strong_authentication',
'name' => 'Strong authentication',
'domain_key' => 'identity_access',
'subdomain_key' => 'authentication_assurance',
'control_class' => 'preventive',
'summary' => 'Accounts and privileged actions require strong authentication before access is granted.',
'operator_description' => 'Use this control when the governance objective is proving that access depends on multi-factor or similarly strong authentication.',
'detectability_class' => 'indirect_technical',
'evaluation_strategy' => 'signal_inferred',
'evidence_archetypes' => [
'configuration_snapshot',
'policy_or_assignment_summary',
'execution_result',
],
'artifact_suitability' => [
'baseline' => true,
'drift' => true,
'finding' => true,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'conditional_access_policy',
'workload' => 'entra',
'signal_keys' => [
'conditional_access.require_mfa',
'conditional_access.authentication_strength',
],
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Microsoft conditional access is provider-owned evidence for strong authentication, not the canonical control identity.',
],
[
'subject_family_key' => 'permission_posture',
'workload' => 'entra',
'signal_keys' => [
'permission_posture.required_graph_permission',
],
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
'primary' => false,
'notes' => 'Permission posture can support authentication governance when missing permissions block assessment evidence.',
],
],
],
[
'control_key' => 'conditional_access_enforcement',
'name' => 'Conditional access enforcement',
'domain_key' => 'identity_access',
'subdomain_key' => 'access_policy',
'control_class' => 'preventive',
'summary' => 'Access decisions are governed by explicit policy conditions and assignment boundaries.',
'operator_description' => 'Use this control when evaluating whether access is constrained by conditional policies rather than unmanaged default access.',
'detectability_class' => 'direct_technical',
'evaluation_strategy' => 'state_evaluated',
'evidence_archetypes' => [
'configuration_snapshot',
'policy_or_assignment_summary',
],
'artifact_suitability' => [
'baseline' => true,
'drift' => true,
'finding' => true,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'conditional_access_policy',
'workload' => 'entra',
'signal_keys' => [
'conditional_access.policy_state',
'conditional_access.assignment_scope',
],
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Policy state and assignments are Microsoft-owned signals for the provider-neutral access enforcement objective.',
],
],
],
[
'control_key' => 'privileged_access_governance',
'name' => 'Privileged access governance',
'domain_key' => 'identity_access',
'subdomain_key' => 'privileged_access',
'control_class' => 'preventive',
'summary' => 'Privileged roles are assigned intentionally, reviewed, and limited to accountable identities.',
'operator_description' => 'Use this control when privileged role exposure, ownership, and reviewability are the core governance objective.',
'detectability_class' => 'indirect_technical',
'evaluation_strategy' => 'signal_inferred',
'evidence_archetypes' => [
'policy_or_assignment_summary',
'execution_result',
'operator_attestation',
],
'artifact_suitability' => [
'baseline' => false,
'drift' => false,
'finding' => true,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'entra_admin_roles',
'workload' => 'entra',
'signal_keys' => [
'entra_admin_roles.global_admin_assignment',
'entra_admin_roles.privileged_role_assignment',
],
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Directory role assignment data supports privileged access governance without becoming the control taxonomy.',
],
],
],
[
'control_key' => 'external_sharing_boundaries',
'name' => 'External sharing boundaries',
'domain_key' => 'collaboration_boundary',
'subdomain_key' => 'external_access',
'control_class' => 'preventive',
'summary' => 'External access and sharing are constrained by explicit tenant or workload boundaries.',
'operator_description' => 'Use this control when the product needs to explain whether cross-boundary collaboration is intentionally limited.',
'detectability_class' => 'workflow_attested',
'evaluation_strategy' => 'workflow_confirmed',
'evidence_archetypes' => [
'configuration_snapshot',
'operator_attestation',
'external_artifact_reference',
],
'artifact_suitability' => [
'baseline' => false,
'drift' => false,
'finding' => false,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'sharing_boundary',
'workload' => 'microsoft_365',
'signal_keys' => [
'sharing.external_boundary_attested',
],
'supported_contexts' => ['evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Current release coverage depends on attested configuration evidence rather than direct universal evaluation.',
],
],
],
[
'control_key' => 'endpoint_hardening_compliance',
'name' => 'Endpoint hardening and compliance',
'domain_key' => 'endpoint_security',
'subdomain_key' => 'device_posture',
'control_class' => 'detective',
'summary' => 'Endpoint configuration and compliance policies express the expected device hardening posture.',
'operator_description' => 'Use this control when a finding or review references device configuration, compliance, or hardening drift.',
'detectability_class' => 'direct_technical',
'evaluation_strategy' => 'state_evaluated',
'evidence_archetypes' => [
'configuration_snapshot',
'policy_or_assignment_summary',
'execution_result',
],
'artifact_suitability' => [
'baseline' => true,
'drift' => true,
'finding' => true,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'deviceConfiguration',
'workload' => 'intune',
'signal_keys' => [
'intune.device_configuration_drift',
],
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Intune device configuration drift is a provider signal for the endpoint hardening control.',
],
[
'subject_family_key' => 'deviceCompliancePolicy',
'workload' => 'intune',
'signal_keys' => [
'intune.device_compliance_policy',
],
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Device compliance policy data supports the same endpoint hardening objective.',
],
[
'subject_family_key' => 'drift',
'workload' => 'intune',
'signal_keys' => [
'finding.drift',
],
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Legacy drift findings without a policy-family discriminator resolve to the broad endpoint hardening objective.',
],
],
],
[
'control_key' => 'audit_log_retention',
'name' => 'Audit log retention',
'domain_key' => 'auditability',
'subdomain_key' => 'retention',
'control_class' => 'detective',
'summary' => 'Administrative and security-relevant activity remains available for investigation for the required retention period.',
'operator_description' => 'Use this control when evidence depends on retained logs or exported audit artifacts rather than live configuration alone.',
'detectability_class' => 'external_evidence_only',
'evaluation_strategy' => 'externally_attested',
'evidence_archetypes' => [
'external_artifact_reference',
'operator_attestation',
],
'artifact_suitability' => [
'baseline' => false,
'drift' => false,
'finding' => false,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'audit_log_retention',
'workload' => 'microsoft_365',
'signal_keys' => [
'audit.retention_attested',
],
'supported_contexts' => ['evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Current evidence is external or attested until a later slice adds direct provider evaluation.',
],
],
],
[
'control_key' => 'delegated_admin_boundaries',
'name' => 'Delegated admin boundaries',
'domain_key' => 'identity_access',
'subdomain_key' => 'delegated_administration',
'control_class' => 'preventive',
'summary' => 'Delegated administration is constrained by explicit role, tenant, and scope boundaries.',
'operator_description' => 'Use this control when evaluating whether delegated administrative access is bounded and reviewable.',
'detectability_class' => 'workflow_attested',
'evaluation_strategy' => 'workflow_confirmed',
'evidence_archetypes' => [
'policy_or_assignment_summary',
'operator_attestation',
],
'artifact_suitability' => [
'baseline' => false,
'drift' => false,
'finding' => true,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [
[
'subject_family_key' => 'delegated_admin_relationship',
'workload' => 'microsoft_365',
'signal_keys' => [
'delegated_admin.relationship_boundary',
],
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
'primary' => true,
'notes' => 'Delegated admin relationship metadata remains provider-owned and secondary to the platform control.',
],
],
],
],
];

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\Evidence\Sources\FindingsSummarySource;
it('adds shared canonical control references to findings-derived evidence summaries', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Finding::factory()->permissionPosture()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
Finding::factory()->entraAdminRoles()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'evidence_jsonb' => [
'policy_type' => 'deviceConfiguration',
],
]);
$item = app(FindingsSummarySource::class)->collect($tenant);
$summary = $item['summary_payload'];
expect($summary['canonical_controls'])->toHaveCount(3)
->and(collect($summary['canonical_controls'])->pluck('control_key')->all())->toEqualCanonicalizing([
'endpoint_hardening_compliance',
'privileged_access_governance',
'strong_authentication',
]);
foreach ($summary['entries'] as $entry) {
expect($entry['canonical_control_resolution']['status'])->toBe('resolved')
->and($entry['canonical_control_resolution']['control'])->toHaveKey('control_key')
->and($entry)->not->toHaveKey('control_label');
}
$payload = app(EvidenceSnapshotService::class)->buildSnapshotPayload($tenant);
expect($payload['summary']['canonical_controls'])->toHaveCount(3);
});
it('keeps missing bindings explicit instead of inventing evidence fallback labels', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => 'unknown_provider_signal',
]);
$summary = app(FindingsSummarySource::class)->collect($tenant)['summary_payload'];
$entry = $summary['entries'][0];
expect($entry['canonical_control_resolution'])->toMatchArray([
'status' => 'unresolved',
'reason_code' => 'missing_binding',
])->and($entry['canonical_control_resolution'])->not->toHaveKey('control')
->and($entry)->not->toHaveKey('control_label');
});

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Support\Governance\Controls\CanonicalControlCatalog;
use App\Support\Governance\Controls\CanonicalControlResolutionRequest;
use App\Support\Governance\Controls\CanonicalControlResolver;
it('lists seeded canonical controls in the logical contract shape', function (): void {
$payload = [
'controls' => app(CanonicalControlCatalog::class)->listPayload(),
];
expect($payload['controls'])->not->toBeEmpty();
foreach ($payload['controls'] as $control) {
expect($control)->toHaveKeys([
'control_key',
'name',
'domain_key',
'subdomain_key',
'control_class',
'summary',
'operator_description',
'detectability_class',
'evaluation_strategy',
'evidence_archetypes',
'artifact_suitability',
'historical_status',
])->and($control['artifact_suitability'])->toHaveKeys([
'baseline',
'drift',
'finding',
'exception',
'evidence',
'review',
'report',
]);
}
});
it('returns resolved, unresolved, and ambiguous resolution shapes without guessing', function (): void {
$resolver = app(CanonicalControlResolver::class);
$resolved = $resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'review',
subjectFamilyKey: 'entra_admin_roles',
workload: 'entra',
signalKey: 'entra_admin_roles.global_admin_assignment',
))->toArray();
$unresolved = $resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'review',
subjectFamilyKey: 'not_bound',
))->toArray();
$ambiguous = $resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'review',
subjectFamilyKey: 'conditional_access_policy',
workload: 'entra',
))->toArray();
expect($resolved)->toHaveKeys(['status', 'control'])
->and($resolved['status'])->toBe('resolved')
->and($resolved['control']['control_key'])->toBe('privileged_access_governance')
->and($unresolved)->toHaveKeys(['status', 'reason_code', 'binding_context'])
->and($unresolved['reason_code'])->toBe('missing_binding')
->and($ambiguous)->toHaveKeys(['status', 'reason_code', 'candidate_control_keys', 'binding_context'])
->and($ambiguous['status'])->toBe('ambiguous')
->and($ambiguous['candidate_control_keys'])->toEqualCanonicalizing([
'conditional_access_enforcement',
'strong_authentication',
]);
});

View File

@ -15,7 +15,8 @@
->and(is_executable($scriptPath))->toBeTrue()
->and($scriptContents)->toContain('APP_DIR')
->toContain('apps/platform')
->toContain('exec ./vendor/bin/sail "$@"');
->toContain('exec ./vendor/bin/sail "$@"')
->not->toContain('COMPOSE_PROJECT_NAME');
});
it('keeps the repo root compose file pointed at the relocated app', function (): void {

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
it('passes shared canonical control references through tenant review composition', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 1);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$openRisks = $review->sections->firstWhere('section_key', 'open_risks');
$executiveSummary = $review->sections->firstWhere('section_key', 'executive_summary');
expect($review->canonicalControlReferences())->toHaveCount(1)
->and($review->canonicalControlReferences()[0]['control_key'])->toBe('endpoint_hardening_compliance')
->and($executiveSummary->summary_payload['canonical_control_count'])->toBe(1)
->and($executiveSummary->summary_payload['canonical_controls'][0]['control_key'])->toBe('endpoint_hardening_compliance')
->and($openRisks->summary_payload['canonical_controls'])->toBe([]);
});

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use App\Support\Governance\Controls\CanonicalControlCatalog;
use App\Support\Governance\Controls\DetectabilityClass;
use App\Support\Governance\Controls\EvaluationStrategy;
it('loads stable provider-neutral seed definitions with complete metadata', function (): void {
$catalog = app(CanonicalControlCatalog::class);
expect($catalog->all())->toHaveCount(7);
foreach ($catalog->all() as $definition) {
expect($definition->controlKey)->toMatch('/^[a-z][a-z0-9_]*$/')
->and($definition->name)->not->toBeEmpty()
->and($definition->domainKey)->not->toContain('microsoft')
->and($definition->domainKey)->not->toContain('intune')
->and($definition->subdomainKey)->not->toBeEmpty()
->and($definition->controlClass)->not->toBeEmpty()
->and($definition->summary)->not->toBeEmpty()
->and($definition->operatorDescription)->not->toBeEmpty()
->and($definition->detectabilityClass)->toBeInstanceOf(DetectabilityClass::class)
->and($definition->evaluationStrategy)->toBeInstanceOf(EvaluationStrategy::class)
->and($definition->evidenceArchetypes)->not->toBeEmpty()
->and(array_keys($definition->artifactSuitability->toArray()))->toBe([
'baseline',
'drift',
'finding',
'exception',
'evidence',
'review',
'report',
])
->and($definition->historicalStatus)->toBeIn(['active', 'retired']);
}
});
it('seeds the first-slice high-value control families', function (): void {
$keys = array_map(
static fn ($definition): string => $definition->controlKey,
app(CanonicalControlCatalog::class)->all(),
);
expect($keys)->toEqualCanonicalizing([
'audit_log_retention',
'conditional_access_enforcement',
'delegated_admin_boundaries',
'endpoint_hardening_compliance',
'external_sharing_boundaries',
'privileged_access_governance',
'strong_authentication',
]);
});
it('keeps Microsoft bindings secondary to the definition payload', function (): void {
$catalog = app(CanonicalControlCatalog::class);
$definition = $catalog->find('endpoint_hardening_compliance');
expect($definition?->toArray())->not->toHaveKey('microsoft_bindings')
->and($catalog->microsoftBindingsForControl('endpoint_hardening_compliance'))->not->toBeEmpty()
->and($catalog->microsoftBindingsForControl('endpoint_hardening_compliance')[0]->toArray()['provider'])->toBe('microsoft');
});
it('preserves honest detectability, evaluation, and suitability distinctions', function (): void {
$catalog = app(CanonicalControlCatalog::class);
expect($catalog->find('endpoint_hardening_compliance')?->detectabilityClass)->toBe(DetectabilityClass::DirectTechnical)
->and($catalog->find('endpoint_hardening_compliance')?->evaluationStrategy)->toBe(EvaluationStrategy::StateEvaluated)
->and($catalog->find('audit_log_retention')?->detectabilityClass)->toBe(DetectabilityClass::ExternalEvidenceOnly)
->and($catalog->find('audit_log_retention')?->evaluationStrategy)->toBe(EvaluationStrategy::ExternallyAttested)
->and($catalog->find('audit_log_retention')?->artifactSuitability->baseline)->toBeFalse()
->and($catalog->find('audit_log_retention')?->artifactSuitability->review)->toBeTrue();
});

View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use App\Support\Governance\Controls\CanonicalControlCatalog;
use App\Support\Governance\Controls\CanonicalControlResolutionRequest;
use App\Support\Governance\Controls\CanonicalControlResolver;
it('resolves multiple Microsoft subject families to one stable canonical control identity', function (): void {
$resolver = app(CanonicalControlResolver::class);
$configurationResult = $resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: 'deviceConfiguration',
workload: 'intune',
signalKey: 'intune.device_configuration_drift',
));
$complianceResult = $resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'review',
subjectFamilyKey: 'deviceCompliancePolicy',
workload: 'intune',
signalKey: 'intune.device_compliance_policy',
));
expect($configurationResult->toArray()['control']['control_key'])->toBe('endpoint_hardening_compliance')
->and($complianceResult->toArray()['control']['control_key'])->toBe('endpoint_hardening_compliance')
->and($configurationResult->toArray()['control']['name'])->toBe($complianceResult->toArray()['control']['name']);
});
it('uses supplied signal context instead of letting workload labels become primary identity', function (): void {
$resolver = app(CanonicalControlResolver::class);
$strongAuthentication = $resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: 'conditional_access_policy',
workload: 'entra',
signalKey: 'conditional_access.require_mfa',
));
$accessEnforcement = $resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: 'conditional_access_policy',
workload: 'entra',
signalKey: 'conditional_access.policy_state',
));
expect($strongAuthentication->toArray()['control']['control_key'])->toBe('strong_authentication')
->and($accessEnforcement->toArray()['control']['control_key'])->toBe('conditional_access_enforcement');
});
it('returns explicit unresolved reason codes instead of fallback labels', function (): void {
$resolver = app(CanonicalControlResolver::class);
expect($resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'unknown',
consumerContext: 'evidence',
subjectFamilyKey: 'deviceConfiguration',
))->toArray())->toMatchArray([
'status' => 'unresolved',
'reason_code' => 'unsupported_provider',
]);
expect($resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
))->toArray())->toMatchArray([
'status' => 'unresolved',
'reason_code' => 'insufficient_context',
]);
expect($resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: 'not_bound',
))->toArray())->toMatchArray([
'status' => 'unresolved',
'reason_code' => 'missing_binding',
]);
});
it('fails deterministically when a binding context is ambiguous', function (): void {
$resolver = new CanonicalControlResolver(new CanonicalControlCatalog([
spec236ControlDefinition('first_control', [
'microsoft_bindings' => [
spec236Binding('shared_subject', primary: false),
],
]),
spec236ControlDefinition('second_control', [
'microsoft_bindings' => [
spec236Binding('shared_subject', primary: false),
],
]),
]));
$result = $resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: 'shared_subject',
workload: 'entra',
signalKey: 'shared.signal',
))->toArray();
expect($result['status'])->toBe('ambiguous')
->and($result['reason_code'])->toBe('ambiguous_binding')
->and($result['candidate_control_keys'])->toBe(['first_control', 'second_control']);
});
it('keeps retired controls resolvable for historical references', function (): void {
$resolver = new CanonicalControlResolver(new CanonicalControlCatalog([
spec236ControlDefinition('retired_control', [
'historical_status' => 'retired',
'microsoft_bindings' => [
spec236Binding('retired_subject'),
],
]),
]));
$result = $resolver->resolve(new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'review',
subjectFamilyKey: 'retired_subject',
workload: 'entra',
signalKey: 'shared.signal',
))->toArray();
expect($result['status'])->toBe('resolved')
->and($result['control']['control_key'])->toBe('retired_control')
->and($result['control']['historical_status'])->toBe('retired');
});
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function spec236ControlDefinition(string $controlKey, array $overrides = []): array
{
return array_replace_recursive([
'control_key' => $controlKey,
'name' => str_replace('_', ' ', ucfirst($controlKey)),
'domain_key' => 'identity_access',
'subdomain_key' => 'test_subjects',
'control_class' => 'preventive',
'summary' => 'Test summary.',
'operator_description' => 'Test operator description.',
'detectability_class' => 'direct_technical',
'evaluation_strategy' => 'state_evaluated',
'evidence_archetypes' => ['configuration_snapshot'],
'artifact_suitability' => [
'baseline' => true,
'drift' => true,
'finding' => true,
'exception' => true,
'evidence' => true,
'review' => true,
'report' => true,
],
'historical_status' => 'active',
'microsoft_bindings' => [],
], $overrides);
}
/**
* @return array<string, mixed>
*/
function spec236Binding(string $subjectFamilyKey, bool $primary = true): array
{
return [
'subject_family_key' => $subjectFamilyKey,
'workload' => 'entra',
'signal_keys' => ['shared.signal'],
'supported_contexts' => ['evidence', 'review'],
'primary' => $primary,
];
}

View File

@ -11,6 +11,4 @@ APP_DIR="${SCRIPT_DIR}/../apps/platform"
cd "${APP_DIR}"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-tenantatlas}"
exec ./vendor/bin/sail "$@"

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Canonical Control Catalog Foundation
**Purpose**: Capture specification completeness and quality at planning handoff while keeping post-plan status aligned with the current artifact set
**Created**: 2026-04-24
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation algorithms, code diffs, or migration steps
- [x] Focused on user value and business needs
- [x] Repo-specific constitutional and provider-boundary references remain intentional and bounded
- [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
- [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 algorithms or file-by-file execution steps leak into specification
## Notes
- This checklist records readiness at planning handoff; `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/`, and `tasks.md` are the implementation-facing artifacts for this feature.
- The first slice remains product-seeded, persistence-neutral, and bounded to shared control resolution plus downstream evidence and tenant review adoption.
- No clarification markers remain, and the current scope is aligned across spec, plan, tasks, and supporting artifacts for implementation.

View File

@ -0,0 +1,252 @@
openapi: 3.1.0
info:
title: Canonical Control Catalog Logical Contract
version: 0.1.0
description: |
Logical contract for the first canonical control catalog slice.
This describes shared internal request and response shapes for catalog lookup
and control resolution. It is not a commitment to expose public HTTP routes.
paths:
/logical/canonical-controls/catalog:
get:
summary: List the seeded canonical control definitions
operationId: listCanonicalControls
responses:
'200':
description: Seeded canonical controls
content:
application/json:
schema:
type: object
properties:
controls:
type: array
items:
$ref: '#/components/schemas/CanonicalControlDefinition'
required:
- controls
/logical/canonical-controls/resolve:
post:
summary: Resolve canonical control metadata for a governed subject or signal context
operationId: resolveCanonicalControl
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ControlResolutionRequest'
responses:
'200':
description: Control resolution outcome
content:
application/json:
schema:
$ref: '#/components/schemas/ControlResolutionResponse'
components:
schemas:
CanonicalControlDefinition:
type: object
properties:
control_key:
type: string
name:
type: string
domain_key:
type: string
subdomain_key:
type: string
control_class:
type: string
summary:
type: string
operator_description:
type: string
detectability_class:
type: string
enum:
- direct_technical
- indirect_technical
- workflow_attested
- external_evidence_only
evaluation_strategy:
type: string
enum:
- state_evaluated
- signal_inferred
- workflow_confirmed
- externally_attested
evidence_archetypes:
type: array
items:
$ref: '#/components/schemas/EvidenceArchetype'
artifact_suitability:
type: object
properties:
baseline:
type: boolean
drift:
type: boolean
finding:
type: boolean
exception:
type: boolean
evidence:
type: boolean
review:
type: boolean
report:
type: boolean
required:
- baseline
- drift
- finding
- exception
- evidence
- review
- report
historical_status:
type: string
enum:
- active
- retired
required:
- control_key
- name
- domain_key
- subdomain_key
- control_class
- summary
- operator_description
- detectability_class
- evaluation_strategy
- evidence_archetypes
- artifact_suitability
- historical_status
ControlResolutionRequest:
type: object
description: All supplied discriminator fields combine conjunctively. The resolver narrows bindings by provider, consumer context, and every provided subject-family, workload, or signal value.
properties:
provider:
type: string
enum:
- microsoft
subject_family_key:
type: string
workload:
type: string
signal_key:
type: string
consumer_context:
type: string
enum:
- baseline
- drift
- finding
- evidence
- exception
- review
- report
anyOf:
- required:
- subject_family_key
- required:
- workload
- required:
- signal_key
required:
- provider
- consumer_context
BindingContext:
type: object
properties:
provider:
type: string
enum:
- microsoft
subject_family_key:
type: string
workload:
type: string
signal_key:
type: string
consumer_context:
type: string
enum:
- baseline
- drift
- finding
- evidence
- exception
- review
- report
EvidenceArchetype:
type: string
enum:
- configuration_snapshot
- execution_result
- policy_or_assignment_summary
- operator_attestation
- external_artifact_reference
UnresolvedControlResolutionReasonCode:
type: string
enum:
- missing_binding
- unsupported_provider
- unsupported_consumer_context
- insufficient_context
AmbiguousControlResolutionReasonCode:
type: string
enum:
- ambiguous_binding
ResolvedControlResolutionResponse:
type: object
properties:
status:
type: string
enum:
- resolved
control:
$ref: '#/components/schemas/CanonicalControlDefinition'
required:
- status
- control
UnresolvedControlResolutionResponse:
type: object
properties:
status:
type: string
enum:
- unresolved
reason_code:
$ref: '#/components/schemas/UnresolvedControlResolutionReasonCode'
binding_context:
$ref: '#/components/schemas/BindingContext'
required:
- status
- reason_code
- binding_context
AmbiguousControlResolutionResponse:
type: object
properties:
status:
type: string
enum:
- ambiguous
reason_code:
$ref: '#/components/schemas/AmbiguousControlResolutionReasonCode'
candidate_control_keys:
type: array
items:
type: string
binding_context:
$ref: '#/components/schemas/BindingContext'
required:
- status
- reason_code
- candidate_control_keys
- binding_context
ControlResolutionResponse:
oneOf:
- $ref: '#/components/schemas/ResolvedControlResolutionResponse'
- $ref: '#/components/schemas/UnresolvedControlResolutionResponse'
- $ref: '#/components/schemas/AmbiguousControlResolutionResponse'

View File

@ -0,0 +1,133 @@
# Data Model: Canonical Control Catalog Foundation
## Overview
The first slice introduces a product-seeded control catalog and a shared resolution contract. The catalog itself is not operator-managed persistence in v1; it is a bounded canonical registry consumed by existing governance domains.
## Entity: CanonicalControlDefinition
- **Purpose**: Represents one stable governance control objective independent of framework clauses, provider identifiers, or individual workload payloads.
- **Identity**:
- `control_key` — stable canonical slug, unique across the catalog
- **Core fields**:
- `name`
- `domain_key`
- `subdomain_key`
- `control_class`
- `summary`
- `operator_description`
- **Semantics fields**:
- `detectability_class`
- `evaluation_strategy`
- `evidence_archetypes[]`
- `artifact_suitability`
- `historical_status``active` or `retired`
- **Validation rules**:
- `control_key` must be stable, lowercase, and provider-neutral.
- `domain_key` and `subdomain_key` must point to canonical catalog taxonomy, not framework or provider namespaces.
- Each control must declare at least one evidence archetype.
- Each control must declare explicit suitability flags for baseline, drift, finding, exception, evidence, review, and report usage.
## Entity: MicrosoftSubjectBinding
- **Purpose**: Connects provider-owned Microsoft subjects, workloads, or signals to one canonical control without redefining the control.
- **Fields**:
- `control_key`
- `provider` — always `microsoft` in the first slice
- `subject_family_key`
- `workload`
- `signal_keys[]`
- `supported_contexts[]` — for example baseline, finding, evidence, exception, review, report
- `primary` — whether this binding is the default control for the declared context
- `notes`
- **Validation rules**:
- Every binding must reference an existing `control_key`.
- Provider-specific descriptors must not overwrite control-core terminology.
- More than one binding may point to the same control.
- Multiple controls may only claim the same binding context when the ambiguity is intentionally declared and handled.
## Entity: CanonicalControlResolutionResult
- **Purpose**: Shared response contract for downstream consumers.
- **Resolver matching rule**: provider, consumer context, and every supplied subject-family, workload, or signal discriminator combine conjunctively to narrow candidate bindings.
- **States**:
- `resolved`
- `unresolved`
- `ambiguous`
- **Fields when resolved**:
- `status` — always `resolved`
- `control` — full `CanonicalControlDefinition` payload containing:
- `control_key`
- `name`
- `domain_key`
- `subdomain_key`
- `control_class`
- `summary`
- `operator_description`
- `detectability_class`
- `evaluation_strategy`
- `evidence_archetypes[]`
- `artifact_suitability`
- `historical_status`
- **Fields when unresolved**:
- `reason_code` — stable failure vocabulary such as `missing_binding`, `unsupported_provider`, `unsupported_consumer_context`, or `insufficient_context`
- `binding_context`
- **Fields when ambiguous**:
- `reason_code` — stable failure vocabulary, including `ambiguous_binding`
- `candidate_control_keys[]`
- `binding_context`
- **Validation rules**:
- `resolved` returns exactly one canonical control.
- All supplied discriminator inputs must narrow resolution together; the resolver must not ignore a provided field to force a match.
- `ambiguous` returns no guessed winner.
- `unresolved` returns no local fallback label.
## Supporting Classifications
### DetectabilityClass
- `direct_technical`
- `indirect_technical`
- `workflow_attested`
- `external_evidence_only`
### EvaluationStrategy
- `state_evaluated`
- `signal_inferred`
- `workflow_confirmed`
- `externally_attested`
### EvidenceArchetype
- `configuration_snapshot`
- `execution_result`
- `policy_or_assignment_summary`
- `operator_attestation`
- `external_artifact_reference`
## Relationships
- One `CanonicalControlDefinition` has many `MicrosoftSubjectBinding` records.
- One `MicrosoftSubjectBinding` references exactly one canonical control.
- One governed subject or signal context may resolve to one control or to an explicit ambiguous set.
- Existing governance consumers remain the owners of their own records and read models; they do not become child entities of the canonical catalog.
## Lifecycle
### CanonicalControlDefinition lifecycle
- `active`: valid for new bindings and downstream use
- `retired`: historical references remain resolvable, but new adoption should stop unless explicitly allowed
### Resolution lifecycle
- `resolved`: downstream consumer may use canonical metadata directly
- `unresolved`: downstream consumer must surface or log explicit absence rather than invent local meaning
- `ambiguous`: downstream consumer must stop and preserve explicit ambiguity until the binding model is clarified
## Rollout Model
- The first slice keeps the catalog seeded in code and consumed through the resolver.
- Broad persistence of `canonical_control_key` on downstream entities is deferred.
- First-slice adoption is read-through and bounded to findings-derived evidence composition and tenant review composition.

View File

@ -0,0 +1,250 @@
# Implementation Plan: Canonical Control Catalog Foundation
**Branch**: `236-canonical-control-catalog-foundation` | **Date**: 2026-04-24 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/236-canonical-control-catalog-foundation/spec.md`
**Note**: This plan keeps the slice intentionally narrow. It introduces one product-seeded canonical control catalog plus one shared resolver contract, then adopts that contract only in findings-derived evidence composition and tenant review composition without adding operator CRUD, Graph sync, or a new operator-facing surface.
## Summary
Add a config-seeded canonical control catalog in `apps/platform/config/canonical_controls.php` plus a small `App\Support\Governance\Controls` value-object and resolver layer so the same governance objective resolves to one stable control identity, one honest detectability story, and one provider-neutral vocabulary. The implementation will keep Microsoft workload, subject-family, and signal mappings as secondary provider-owned bindings, expose explicit `resolved`, `unresolved`, and `ambiguous` outcomes, and adopt the shared contract only in the existing findings-derived evidence composition and tenant review composition paths instead of widening into baseline, exception, report, or review-pack consumers in this slice.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
**Primary Dependencies**: existing governance support types under `App\Support\Governance`, `EvidenceSnapshotResolver`, `EvidenceSnapshotService`, `FindingsSummarySource`, `TenantReviewComposer`, `TenantReviewSectionFactory`, `TenantReviewService`, Pest v4
**Storage**: Existing PostgreSQL tables for downstream evidence and tenant review records; product-seeded in-repo config for canonical control definitions and Microsoft bindings
**Testing**: Pest v4 unit and feature tests through Laravel Sail
**Validation Lanes**: `fast-feedback`, `confidence`
**Target Platform**: Laravel admin web application running in Sail with existing `/admin` and `/admin/t/{tenant}` surfaces
**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root
**Performance Goals**: Keep catalog lookup deterministic and in-process, add no outbound provider calls, and avoid new high-cardinality or repeated per-item resolver work in evidence or tenant review composition
**Constraints**: No new Graph calls, no sync job, no DB-backed control authoring UI, no new operator-facing page, no new persistence table, and no provider-specific vocabulary leaking into platform-core control identity
**Scale/Scope**: One config-backed catalog, one shared resolver, one bounded Microsoft binding family, two first-slice downstream adoption paths, and focused governance foundation unit plus feature tests
## Filament v5 Implementation Contract
- **Livewire v4.0+ compliance**: Preserved. This slice changes shared services and value objects only and introduces no legacy Livewire patterns.
- **Provider registration location**: Unchanged. Panel providers remain registered in `apps/platform/bootstrap/providers.php`.
- **Global search coverage**: No new Filament Resource or Page is added, and no existing global-search posture changes in this slice.
- **Destructive actions**: No destructive action is added or changed. This slice does not introduce new Filament actions.
- **Asset strategy**: No new assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only when future UI work introduces registered assets.
- **Testing plan**: Prove the slice with focused Pest unit coverage for catalog and resolver rules plus focused feature coverage for logical resolution, findings-derived evidence composition, and tenant review composition.
## UI / Surface Guardrail Plan
- **Guardrail scope**: no operator-facing surface change
- **Native vs custom classification summary**: `N/A`
- **Shared-family relevance**: evidence viewers, tenant review detail composition, governance summaries
- **State layers in scope**: detail
- **Handling modes by drift class or surface**: `report-only`
- **Repository-signal treatment**: `report-only`
- **Special surface test profiles**: `standard-native-filament`
- **Required tests or manual smoke**: `functional-core`
- **Exception path and spread control**: none planned; any later UI adoption stays in a follow-through slice
- **Active feature PR close-out entry**: `Guardrail`
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: findings-derived evidence composition, evidence snapshot lookup, tenant review composition, tenant review section rendering inputs
- **Shared abstractions reused**: existing evidence composition paths, existing tenant review composition paths, existing governance support types, new shared `CanonicalControlCatalog` and `CanonicalControlResolver`
- **New abstraction introduced? why?**: yes. A bounded catalog plus resolver layer is required because existing builders only know provider subjects or local evidence context and cannot safely share one control identity.
- **Why the existing abstraction was sufficient or insufficient**: existing builders are sufficient for surface-specific formatting, but insufficient for cross-domain control identity, detectability semantics, and provider-neutral vocabulary.
- **Bounded deviation / spread control**: none. Downstream consumers must call the shared resolver rather than add local registries or fallback labels.
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes
- **Provider-owned seams**: Microsoft workload labels, subject-family identifiers, signal keys, supported-context bindings
- **Platform-core seams**: canonical control key, control domain, control subdomain, control class, detectability class, evaluation strategy, evidence archetypes, artifact suitability, historical status
- **Neutral platform terms / contracts preserved**: canonical control, provider binding, governed subject, detectability class, evaluation strategy, evidence archetype, artifact suitability
- **Retained provider-specific semantics and why**: Microsoft binding metadata remains provider-specific because the current product truth is Microsoft-first, but it remains secondary to the canonical control identity.
- **Bounded extraction or follow-up path**: none in this slice; future provider expansion can layer on the same binding model if and when a second concrete provider exists
## Constitution Check
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passed with no new persistence, no new operator surface, no Graph path, and no auth-plane drift.*
| Gate | Status | Plan Notes |
|------|--------|------------|
| Inventory-first / read-write separation | PASS | This slice is read-focused and in-process only. No new write, preview, or operator mutation flow is introduced. |
| RBAC, workspace isolation, tenant isolation | PASS | No new route or capability is added. Evidence and tenant review authorization remain the guarding surfaces for first-slice consumer metadata. |
| Run observability / Ops-UX lifecycle | PASS | No new `OperationRun` type is introduced. Existing evidence and tenant review operation semantics remain unchanged. |
| Shared pattern first | PASS | Evidence and tenant review builders remain the surface-specific composition paths; the shared catalog and resolver provide the missing control identity only. |
| Proportionality / no premature abstraction | PASS | One catalog and one resolver are the narrowest correct shared layer. No DB CRUD, no plugin framework, and no new persistence are introduced. |
| Persisted truth / behavioral state | PASS | No new table, entity, or lifecycle state is introduced. First-slice adoption is read-through only. |
| Provider boundary | PASS | Microsoft semantics remain secondary binding metadata and do not replace the platform-core control vocabulary. |
| Filament v5 / Livewire v4 contract | PASS | No new Filament surfaces or actions are added, and provider registration remains in `bootstrap/providers.php`. |
| Test governance | PASS | Coverage stays in focused unit and feature lanes with no browser or heavy-governance expansion. |
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for catalog and resolver semantics; `Feature` for findings-derived evidence composition, logical resolution, and tenant review composition
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: The core risk is semantic drift, not browser behavior. Unit tests prove deterministic catalog and binding rules; feature tests prove first-slice consumers use the shared contract instead of local labels.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- **Fixture / helper / factory / seed / context cost risks**: Minimal. Use config-seeded catalog fixtures and existing evidence or tenant review factories only where the downstream consumer proof needs persisted context.
- **Expensive defaults or shared helper growth introduced?**: No. The catalog remains config-backed and in-process by default.
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: `standard-native-filament` relief; the slice does not add or materially refactor a Filament screen
- **Closing validation and reviewer handoff**: Reviewers should verify that no Graph client or sync job changed, that first-slice adoption is limited to findings-derived evidence and tenant review composition, that unresolved and ambiguous outcomes never guess, and that provider-specific labels never replace canonical control vocabulary.
- **Budget / baseline / trend follow-up**: none expected
- **Review-stop questions**: Did any change widen consumer adoption beyond the intended first slice? Did any change introduce Graph or sync behavior? Did any feature-local control registry or fallback label appear? Did any contract field drift from the data model or seeded metadata?
- **Escalation path**: `document-in-feature`
- **Active feature PR close-out entry**: `Guardrail`
- **Why no dedicated follow-up spec is needed**: The slice is a bounded semantic foundation. Only future consumer expansion or a second provider would justify a wider follow-up spec.
## Project Structure
### Documentation (this feature)
```text
specs/236-canonical-control-catalog-foundation/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── canonical-control-catalog.logical.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Models/
│ │ ├── EvidenceSnapshot.php
│ │ ├── EvidenceSnapshotItem.php
│ │ └── TenantReview.php
│ ├── Services/
│ │ ├── Evidence/
│ │ │ ├── EvidenceSnapshotResolver.php
│ │ │ ├── EvidenceSnapshotService.php
│ │ │ └── Sources/
│ │ │ └── FindingsSummarySource.php
│ │ └── TenantReviews/
│ │ ├── TenantReviewComposer.php
│ │ ├── TenantReviewSectionFactory.php
│ │ └── TenantReviewService.php
│ └── Support/
│ └── Governance/
│ ├── GovernanceDomainKey.php
│ └── Controls/
├── config/
│ └── canonical_controls.php
└── tests/
├── Feature/
│ ├── Evidence/
│ │ └── EvidenceSnapshotCanonicalControlReferenceTest.php
│ ├── Governance/
│ │ └── CanonicalControlResolutionIntegrationTest.php
│ └── TenantReview/
│ └── TenantReviewCanonicalControlReferenceTest.php
└── Unit/
└── Governance/
├── CanonicalControlCatalogTest.php
└── CanonicalControlResolverTest.php
```
**Structure Decision**: Keep the slice entirely inside the existing Laravel runtime in `apps/platform`. The new structure is limited to one config-backed seed file and one small `App\Support\Governance\Controls` namespace, while first-slice consumer adoption stays inside existing Evidence and TenantReview services plus focused Pest tests.
## Complexity Tracking
No constitutional violation is planned. No complexity exception is currently required.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |
## Proportionality Review
- **Current operator problem**: The same governance objective is still described differently across findings-derived evidence and tenant review composition, which prevents stable control identity and honest detectability semantics.
- **Existing structure is insufficient because**: current governed-subject and workload metadata explain provider context, not the higher-order control objective or what kind of proof the product can honestly claim.
- **Narrowest correct implementation**: add one config-backed canonical control catalog plus one shared resolver and adopt it only in findings-derived evidence composition and tenant review composition.
- **Ownership cost created**: maintain the seed catalog, keep binding rules deterministic, and preserve regression tests that block local control-family drift.
- **Alternative intentionally rejected**: feature-local mappings and a DB-backed control authoring system. The first preserves fragmentation; the second imports unnecessary lifecycle, UI, and persistence complexity before the foundation is proven.
- **Release truth**: current-release truth with deliberate preparation for later consumer expansion
## Phase 0 Research Summary
- The first catalog should be product-seeded and config-backed, not DB-managed.
- Platform-core canonical controls must remain separate from provider-owned Microsoft bindings.
- Ambiguity must resolve as explicit `ambiguous`, never as a guessed winner.
- Detectability, evaluation strategy, and evidence archetypes belong directly on the control definition.
- First-slice adoption should be read-through rather than persistence-first.
- The seed catalog should stay bounded to a small set of high-value governance control families.
## Phase 1 Design Summary
- `research.md` records the architectural decisions that keep the slice narrow and provider-neutral at the control core.
- `data-model.md` defines the three core shapes: `CanonicalControlDefinition`, `MicrosoftSubjectBinding`, and `CanonicalControlResolutionResult`.
- `contracts/canonical-control-catalog.logical.openapi.yaml` defines the shared internal contract for catalog listing and control resolution.
- `quickstart.md` defines the narrow validation order and the intended code areas for the first slice.
- `tasks.md` sequences the work from seed catalog and resolver foundation through findings-derived evidence and tenant review adoption.
## Phase 1 — Agent Context Update
Run after artifact generation:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Implementation Strategy
### Phase A — Seed the canonical control catalog
**Goal**: Create one authoritative, product-seeded catalog with stable control keys and complete control metadata.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/config/canonical_controls.php` | Add the bounded canonical control seed catalog and Microsoft binding metadata. |
| A.2 | `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php` and related enums/value objects | Model canonical control metadata, detectability classes, evaluation strategies, evidence archetypes, artifact suitability, and historical status. |
| A.3 | `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php` | Load, validate, and expose stable control definitions and binding metadata deterministically. |
### Phase B — Implement provider-owned binding and shared resolution semantics
**Goal**: Resolve canonical controls through one shared contract without letting Microsoft metadata become the control model.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Support/Governance/Controls/MicrosoftSubjectBinding.php` | Model Microsoft workload, subject-family, signal, and supported-context binding metadata. |
| B.2 | `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php` and `CanonicalControlResolutionResult.php` | Define the shared request and response primitives for `resolved`, `unresolved`, and `ambiguous` outcomes. |
| B.3 | `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php` | Implement deterministic context-aware resolution, unresolved handling, and explicit ambiguity. |
### Phase C — Adopt the shared contract in the first-slice consumers
**Goal**: Move current-release consumer adoption onto the shared control contract without widening the slice.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php` and `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` | Resolve canonical control references inside findings-derived evidence composition. |
| C.2 | `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php` and `apps/platform/app/Models/EvidenceSnapshotItem.php` | Preserve transient resolved control metadata during evidence lookup and item payload consumption without introducing new canonical-control persistence ownership. |
| C.3 | `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, `TenantReviewSectionFactory.php`, and `TenantReviewService.php` | Reuse the shared resolver during tenant review composition and keep persistence derived. |
### Phase D — Validate contract shape, scope discipline, and negative constraints
**Goal**: Prove semantic correctness while keeping the slice narrow.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php` and `CanonicalControlResolverTest.php` | Prove stable keys, metadata completeness, multi-binding behavior, unresolved outcomes, ambiguity, and retired-control handling. |
| D.2 | `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php` | Prove the logical contract shape stays aligned to the seed catalog and resolver rules. |
| D.3 | `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php` and `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php` | Prove first-slice consumers adopt the shared contract without local registries or fallback labels. |
| D.4 | `specs/236-canonical-control-catalog-foundation/quickstart.md` and `tasks.md` | Keep validation commands, paths, and no-Graph/no-sync guardrails explicit. |
## Risks and Mitigations
- **Provider-shaped drift**: Microsoft labels may accidentally become the canonical vocabulary. Mitigation: keep canonical control definitions and bindings structurally separate and test for provider-neutral keys and labels.
- **Consumer-scope drift**: It is easy to widen adoption into baseline, exception, or report surfaces prematurely. Mitigation: keep first-slice scope explicitly limited to findings-derived evidence and tenant review composition in the plan, spec, tasks, and validation notes.
- **Contract-shape drift**: The contract, data model, and seed metadata can diverge. Mitigation: keep the logical contract small, test it directly, and align fields such as `operator_description`, `binding_context`, and supported contexts explicitly.
- **Graph creep**: Future-looking catalog work can attract provider sync ideas too early. Mitigation: keep a documented no-Graph/no-sync guardrail in tasks and review focus.
## Post-Design Re-check
The feature remains constitution-compliant, Filament v5 and Livewire v4 compliant, and narrow. It introduces no new persistence, no new operator-facing page, no new Graph path, and no new operation type. The plan, research, data model, quickstart, contract, and tasks now align on one config-seeded catalog, one shared resolver, one provider-boundary rule, and one bounded first-slice consumer scope.

View File

@ -0,0 +1,68 @@
# Quickstart: Canonical Control Catalog Foundation
## Goal
Implement the first canonical control core without introducing framework overlays, operator CRUD, or new provider runtime machinery.
## Implementation Sequence
1. Add the product-seeded canonical control registry and the supporting value objects.
2. Add provider-owned Microsoft subject and signal bindings.
3. Implement the shared resolution contract with explicit `resolved`, `unresolved`, and `ambiguous` outcomes.
4. Wire a bounded first-slice set of governance consumers to the shared contract.
5. Add focused unit and feature coverage proving convergence and ambiguity handling.
## Suggested Code Areas
```text
apps/platform/app/Support/Governance/Controls/
apps/platform/config/
apps/platform/app/Services/Evidence/
apps/platform/app/Services/TenantReviews/
apps/platform/tests/Unit/Governance/
apps/platform/tests/Feature/Governance/
apps/platform/tests/Feature/Evidence/
apps/platform/tests/Feature/TenantReview/
```
## Verification Commands
Run the narrowest proving lane first:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php
```
Then run the bounded integration proof:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php
```
If PHP files were added or changed, finish with formatting:
```bash
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Review Focus
- Confirm the control catalog remains provider-neutral at its core.
- Confirm Microsoft bindings are secondary metadata only.
- Confirm first-slice evidence and tenant review consumers do not invent feature-local control-family wording.
- Confirm ambiguity is explicit and never guessed.
- Confirm no Graph path or provider sync job slipped into the slice.
- Confirm no broad persistence or authoring UI slipped into the first slice.
## Guardrail Close-Out
- Validation completed:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- Guardrails checked:
- No Graph client change.
- No `config/graph_contracts.php` change.
- No provider sync job.
- No feature-local control-family fallback or workload-first primary control vocabulary in the touched evidence and tenant review adoption paths.
- Bounded follow-up: none for this slice.

View File

@ -0,0 +1,49 @@
# Research: Canonical Control Catalog Foundation
## Decision 1: Keep the first catalog product-seeded and config-backed
- **Decision**: Model the first canonical control catalog as a product-seeded registry in repository configuration plus narrow value objects and resolvers, not as an operator-managed DB CRUD domain.
- **Rationale**: The current release needs one stable control identity more than it needs authoring workflow, lifecycle UI, or workspace-specific customization. A config-backed seed catalog keeps the control core small, reviewable, versioned with the code, and easy to exercise across multiple governance consumers.
- **Alternatives considered**:
- DB-backed control management UI: rejected because the current release has no operator workflow that requires live authoring, approval, archiving, or per-workspace overrides.
- Feature-local arrays inside each consumer: rejected because that would preserve the current semantic fragmentation.
## Decision 2: Separate platform-core control definitions from provider-owned Microsoft bindings
- **Decision**: Canonical control identity, taxonomy, detectability, evaluation semantics, and evidence suitability stay platform-core, while Microsoft workload, subject-family, and signal relationships remain provider-owned binding metadata.
- **Rationale**: The product is Microsoft-first today, but this spec exists partly to stop Microsoft semantics from becoming silent platform truth. The separation keeps the catalog framework-neutral and provider-neutral without inventing a speculative multi-provider runtime.
- **Alternatives considered**:
- Use Microsoft subject identifiers as the primary control key: rejected because it would make the provider the platform core.
- Create a generic provider-plugin framework now: rejected because there is only one real provider case today.
## Decision 3: Make ambiguity explicit in the shared resolution contract
- **Decision**: The shared resolver returns explicit `resolved`, `unresolved`, or `ambiguous` outcomes instead of guessing when one subject or signal could imply multiple controls.
- **Rationale**: A guessed mapping would silently misclassify governance meaning and poison later findings, review outputs, or evidence narratives. Explicit ambiguity is safer and easier to test.
- **Alternatives considered**:
- Always return the first matching control: rejected because ordering would become hidden truth.
- Allow consumers to choose different local fallback rules: rejected because it would recreate semantic drift.
## Decision 4: Encode detectability, evaluation strategy, and evidence archetypes directly on each control
- **Decision**: Each canonical control definition carries detectability class, evaluation strategy, evidence archetypes, and artifact suitability instead of leaving those semantics to later presentation layers.
- **Rationale**: The control core must explain what the product can prove, partially infer, attest, or only reference externally. Deferring that meaning to later overlays would force downstream consumers to invent their own truth.
- **Alternatives considered**:
- One generic verification flag: rejected because it collapses materially different control types into one misleading boolean.
- Consumer-specific interpretation rules: rejected because those rules would diverge immediately.
## Decision 5: Keep first-slice consumer adoption derived rather than persistence-first
- **Decision**: First-slice consumers resolve canonical control metadata on read through the shared contract instead of requiring immediate schema expansion across baseline, finding, evidence, exception, and review records.
- **Rationale**: The current need is control convergence, not a broad storage migration. Derived adoption proves the catalog against real workflows while keeping rollout narrow.
- **Alternatives considered**:
- Add `canonical_control_key` columns everywhere up front: rejected because it forces a broad migration before the model is proven.
- Leave all consumers untouched until a later reporting slice: rejected because then the catalog would exist without proving cross-domain value.
## Decision 6: Start with a bounded seed catalog of high-value governance families
- **Decision**: Seed only the control families already implied by the current product and roadmap, such as strong authentication, conditional access, privileged access, endpoint hardening or compliance, sharing boundaries, audit retention, and delegated admin boundaries.
- **Rationale**: The goal is a reviewable bridge layer, not exhaustive coverage. A bounded seed catalog is easier to validate and keeps the spec proportional.
- **Alternatives considered**:
- Exhaustive control library in the first release: rejected because it imports compliance-program scale before the control core is proven.
- Framework-shaped seeds such as CIS or NIS2 first: rejected because frameworks are downstream overlays, not the primary control ontology.

View File

@ -0,0 +1,243 @@
# Feature Specification: Canonical Control Catalog Foundation
**Feature Branch**: `236-canonical-control-catalog-foundation`
**Created**: 2026-04-24
**Status**: Approved
**Input**: User description: "Canonical Control Catalog Foundation"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: TenantPilot already has real governance workflows across baselines, drift, findings, evidence, exceptions, and review packs, but it still lacks one shared canonical control object that those workflows can point at.
- **Today's failure**: The same technical control objective can be expressed differently in baseline logic, finding summaries, evidence interpretation, and later framework discussions, which blurs what the control actually is versus which Microsoft subject, workload, or evidence item currently supports it.
- **User-visible improvement**: Governance artifacts can converge on one stable control identity and one honest detectability story instead of each surface inventing local control meaning.
- **Smallest enterprise-capable version**: Introduce a product-seeded canonical control catalog with stable control keys, control metadata, detectability and evaluation semantics, evidence archetypes, Microsoft subject bindings, and one shared resolution contract consumed by existing governance builders.
- **Explicit non-goals**: No certification engine, no framework-first catalog, no full NIS2/BSI/ISO/COBIT library, no operator-managed CRUD UI for controls, no posture scoring, no second artifact store, and no broad Microsoft-domain expansion.
- **Permanent complexity imported**: One canonical control registry, one subject-binding model, one shared control-resolution contract, a small metadata family for detectability and evaluation semantics, and focused regression coverage for consumers.
- **Why now**: The roadmap and spec candidates place this as the next strategic bridge between the shipped governance engine and later readiness or customer-review work.
- **Why not local**: A local label or mapping inside one feature would keep control meaning fragmented and force every downstream surface to keep duplicating the same semantics.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New source-of-truth risk and taxonomy risk. Defense: the first slice stays product-seeded, narrow, framework-neutral, and avoids authoring UI or speculative multi-provider machinery.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- No new standalone route is required in the foundation slice.
- First-slice consumers remain on their current surfaces, specifically evidence snapshots and tenant review composition paths. Findings continue to feed the existing evidence pipeline on their current path, and any tenant review inspection remains downstream of already composed review data rather than a separate adoption target in this slice.
- **Data Ownership**:
- Canonical control definitions are product-seeded platform truth consumed safely within workspace-scoped governance workflows.
- Derived control references in the first slice remain owned by existing evidence snapshot and tenant review records that consume them. Findings remain feeder inputs rather than a direct canonical-control consumer surface in this slice.
- No new operator-managed tenant-owned entity is introduced in the first slice.
- **RBAC**:
- No new top-level capability is introduced for the first slice.
- Existing authorization on evidence and tenant review surfaces continues to gate any downstream control metadata shown through those surfaces in the first slice.
- The catalog foundation must not relax tenant or workspace isolation.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: evidence viewers, tenant review composition, governance summaries, read-model composition
- **Systems touched**: findings-derived evidence composition, evidence snapshot composition, tenant review composition, and downstream inspection of already composed review data
- **Existing pattern(s) to extend**: existing governance summary builders and existing evidence or review composition paths
- **Shared contract / presenter / builder / renderer to reuse**: existing domain builders stay in place; they consume one new shared control-resolution contract rather than inventing local control labels
- **Why the existing shared path is sufficient or insufficient**: existing builders are sufficient for surface-specific formatting, but they are insufficient for cross-domain control identity because each builder currently has only local subject or evidence context
- **Allowed deviation and why**: none
- **Consistency impact**: control key, control label, detectability language, and evidence suitability semantics must remain identical wherever the shared control contract is consumed
- **Review focus**: reviewers should block any new consumer that bypasses the shared control catalog by inventing local control-family wording or workload-first labels
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: yes
- **Boundary classification**: mixed
- **Seams affected**: control taxonomy, governed-subject binding, control-resolution semantics, downstream operator vocabulary derived from canonical controls
- **Neutral platform terms preserved or introduced**: canonical control, control domain, control subdomain, control class, detectability class, evaluation strategy, evidence archetype, governed subject, provider binding
- **Provider-specific semantics retained and why**: Microsoft workload, subject-family, and signal bindings remain provider-owned metadata because the current product truth is Microsoft-first
- **Why this does not deepen provider coupling accidentally**: canonical control keys and primary control definitions remain framework-neutral and provider-neutral; Microsoft-specific bindings are attached as secondary metadata, not used as the primary control identity
- **Follow-up path**: none
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
N/A - no new operator-facing surface is required in the foundation slice. Existing surfaces may consume canonical control references through later adoption or small follow-through changes, but this spec does not add a new page, queue, or custom UI framework.
## 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
- **Current operator problem**: Operators, reviewers, and future customer-facing outputs do not yet have one stable answer to which control an artifact is about; the same objective can still be rephrased differently per workflow.
- **Existing structure is insufficient because**: governed-subject taxonomy explains what Microsoft object or subject family is in scope, but it does not define the higher-order control objective, its detectability class, or how evidence should be interpreted across domains.
- **Narrowest correct implementation**: use a product-seeded canonical control registry plus one shared resolution contract and keep the first adoption derived rather than introducing CRUD management or broad new persistence.
- **Ownership cost**: the seed catalog must be curated, binding rules must stay deterministic, and downstream consumer tests must prevent drift in control identity or detectability semantics.
- **Alternative intentionally rejected**: feature-local control labels and an immediate DB-backed authoring system were rejected because the former preserves fragmentation and the latter imports unnecessary lifecycle and UI complexity before the catalog proves itself.
- **Release truth**: current-release truth with deliberate preparation for later readiness and reporting overlays
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: the first slice is primarily a deterministic catalog and resolution contract with a few bounded integration points; unit tests prove metadata and resolution rules, and focused feature tests prove downstream consumers do not fork control meaning locally
- **New or expanded test families**: targeted governance foundation tests only
- **Fixture / helper cost impact**: minimal; use seeded config or registry fixtures and existing baseline, finding, evidence, and review factories where integration coverage is needed
- **Heavy-family visibility / justification**: none; no browser or heavy-governance family is required for the first slice
- **Special surface test profile**: N/A
- **Standard-native relief or required special coverage**: ordinary feature coverage only
- **Reviewer handoff**: reviewers should confirm that lane choice stays narrow, no expensive shared helper defaults are introduced, and all downstream references come from the shared contract rather than local labels
- **Budget / baseline / trend impact**: none expected
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Resolve One Stable Control Identity (Priority: P1)
As an operator or reviewer, I want governance artifacts that describe the same control objective to resolve to one stable canonical control so the product stops explaining the same issue differently per feature.
**Why this priority**: This is the primary value of the foundation. Without stable control identity, later readiness, reporting, and customer review work will keep duplicating local semantics.
**Independent Test**: Resolve the same governance objective through at least two existing consumer contexts and confirm the shared contract returns the same canonical control key and metadata.
**Acceptance Scenarios**:
1. **Given** two Microsoft subject families that represent the same governance objective, **When** the system resolves their canonical control references, **Then** both resolve to the same canonical control key and label.
2. **Given** a findings-derived evidence composition path and a tenant review consumer that point at the same governance objective, **When** both request canonical control metadata, **Then** both receive the same control identity and detectability semantics.
---
### User Story 2 - Preserve Honest Detectability and Evidence Meaning (Priority: P1)
As an operator preparing a governance review, I want the platform to distinguish direct-technical controls from indirect, attested, or external-evidence-only controls so later outputs do not over-claim what TenantPilot can prove automatically.
**Why this priority**: Honest detectability is part of the product's trust contract. A canonical control layer that collapses all controls into one false verified or not-verified path would harm operator trust.
**Independent Test**: Inspect seed controls with different detectability classes and verify each one carries explicit evaluation and evidence semantics.
**Acceptance Scenarios**:
1. **Given** a seed control that is only workflow-attested or external-evidence-only, **When** the control is resolved, **Then** the metadata explicitly marks that detectability class instead of implying direct technical verification.
2. **Given** a seed control with multiple allowed evidence archetypes, **When** a downstream consumer requests suitability metadata, **Then** the response identifies which evidence forms are valid for that control.
---
### User Story 3 - Add Microsoft Bindings Without Making Microsoft the Control Model (Priority: P2)
As a maintainer extending governance coverage, I want Microsoft workload and signal bindings to attach to canonical controls without turning service-specific labels into the platform's primary control vocabulary.
**Why this priority**: The first provider is Microsoft, but the platform core must not become silently Microsoft-shaped. This story protects the boundary while still enabling real current-release bindings.
**Independent Test**: Add or modify a Microsoft subject binding for a seeded control and confirm the canonical control definition stays unchanged while the binding metadata changes.
**Acceptance Scenarios**:
1. **Given** a canonical control already exists for a governance objective, **When** a new Microsoft subject family is bound to it, **Then** the system reuses the existing canonical control key instead of creating a duplicate control definition.
2. **Given** provider-specific subject or signal metadata changes, **When** the binding is updated, **Then** the platform-core control definition remains stable and provider-neutral.
---
### User Story 4 - Prepare Later Readiness and Review Work Without Local Reinvention (Priority: P3)
As a product maintainer, I want the first-slice evidence and tenant review consumers to have one defined path to canonical control metadata so later work does not invent its own framework or workload-specific control objects.
**Why this priority**: This is the strategic bridge value of the spec. It keeps later slices smaller and prevents new semantic drift.
**Independent Test**: Prove that the first-slice evidence and tenant review consumers can request canonical control metadata through one shared contract without adding local control-family registries.
**Acceptance Scenarios**:
1. **Given** an evidence or tenant review consumer is in the first adoption slice, **When** it needs control metadata, **Then** it uses the shared canonical control contract rather than feature-local labels or registries.
2. **Given** a framework-specific readiness or reporting slice is planned later, **When** it references control meaning, **Then** the canonical catalog remains the primary control layer and any framework mapping remains secondary.
### Edge Cases
- One Microsoft subject family can plausibly map to more than one control objective.
- A canonical control is valid for review packs and evidence only, but not for direct baseline or drift evaluation.
- A control is retired for new use, but existing downstream references still point to its stable key.
- A downstream consumer asks for canonical control metadata without a valid subject binding.
- Two provider-owned bindings point to one canonical control while using different signal shapes.
- A future framework mapping attempts to redefine canonical control identity instead of layering on top of it.
## Requirements *(mandatory)*
**Constitution alignment (required):** This foundation does not add Microsoft Graph calls, destructive actions, or a new operator-facing run flow. The first slice remains read-focused and in-process. If later follow-through work introduces writes, runs, or new surfaces, those slices must define their own safety and observability contract explicitly.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature intentionally introduces one new canonical control taxonomy and one shared resolution contract because governed-subject vocabulary alone cannot safely carry control meaning. The first slice avoids DB-backed control authoring, avoids framework overlays, and keeps consumer adoption derived before persistence.
**Constitution alignment (XCUT-001):** This feature touches cross-cutting governance summaries plus first-slice evidence and tenant review consumers. Those consumers must continue to use their existing builders and presentation paths, but control identity and detectability semantics must come from the shared canonical control contract.
**Constitution alignment (PROV-001):** The canonical control catalog is platform-core. Microsoft workload and signal bindings are provider-owned metadata. Provider-specific semantics must remain secondary and must not replace canonical control keys or vocabulary.
**Constitution alignment (TEST-GOV-001):** Coverage stays in narrow unit and feature lanes. No new heavy browser or broad surface family is justified for the first slice.
**Constitution alignment (OPS-UX):** Not applicable in the foundation slice because no new `OperationRun` is required.
**Constitution alignment (RBAC-UX):** No authorization boundary changes are introduced. Existing capabilities continue to guard any consumer surfaces that later display canonical control metadata.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
**Constitution alignment (BADGE-001):** The first slice does not add new badge families. If later consumers render detectability or suitability as badges, they must do so through centralized badge semantics in a follow-through slice.
**Constitution alignment (UI-FIL-001):** The foundation slice does not require new Filament UI.
**Constitution alignment (UI-NAMING-001):** Canonical control vocabulary must remain stable across future consumer surfaces. Provider or workload names are secondary descriptors only.
**Constitution alignment (DECIDE-001):** No new decision surface is added in the foundation slice.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Not applicable in the foundation slice because no new operator-facing surface is added.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Not applicable.
**Constitution alignment (OPSURF-001):** Not applicable in the foundation slice because there is no new operator-facing page.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature adds one semantic layer because direct domain-to-UI mapping is insufficient across baseline, finding, evidence, and review workflows without a shared control identity. The catalog and resolver must remain the single source for this meaning, and downstream tests must focus on business truth rather than thin wrappers.
### Functional Requirements
- **FR-236-001 Authoritative canonical control catalog**: The system MUST maintain one authoritative canonical control catalog for the first slice with stable canonical control keys that are independent of provider identifiers, framework clause IDs, and individual workload payload shapes, and it MUST support internal listing of seeded control definitions for inspection and validation.
- **FR-236-002 Control definition metadata**: Each canonical control definition MUST include, at minimum, a stable key, canonical name, control domain, control subdomain, control class, descriptive summary, and operator-safe explanation of what the control is about.
- **FR-236-003 Detectability semantics**: Each canonical control definition MUST declare a detectability class that distinguishes at least direct-technical, indirect-technical, workflow-attested, and external-evidence-only controls.
- **FR-236-004 Evaluation semantics**: Each canonical control definition MUST declare an evaluation strategy that explains how the product should reason about the control without collapsing all controls into one universal compliant or non-compliant path.
- **FR-236-005 Evidence archetypes**: Each canonical control definition MUST declare at least one evidence archetype and MAY declare more than one valid evidence archetype.
- **FR-236-006 Artifact suitability**: Each canonical control definition MUST declare whether it is baseline-capable, drift-capable, finding-capable, exception-capable, evidence-capable, review-capable, and report-capable.
- **FR-236-007 Microsoft subject binding model**: The system MUST support provider-owned Microsoft bindings that connect one canonical control to one or more Microsoft subject families, workloads, or signal sources without redefining the control itself.
- **FR-236-008 Provider-neutral control identity**: Provider-specific subject metadata MUST NOT be the canonical control primary key or replace the provider-neutral control definition.
- **FR-236-009 Multi-binding support**: One canonical control MUST be able to bind to multiple Microsoft subject families or signals.
- **FR-236-010 Ambiguity handling**: If a governed subject or signal maps ambiguously to multiple canonical controls without an explicitly declared primary relationship for the current context, the resolver MUST fail deterministically rather than guessing.
- **FR-236-011 Shared resolution contract**: The platform MUST provide one shared resolution contract that lets downstream governance consumers request canonical control metadata using current governed-subject or signal context.
- **FR-236-012 Consumer convergence path**: Findings-derived evidence composition and tenant review consumers in scope for first adoption MUST be able to consume canonical control metadata through the shared contract instead of defining local control-family truth.
- **FR-236-013 Seed catalog breadth**: The first slice MUST ship with a bounded seed catalog covering a small set of high-value control families relevant to the current governance product, including strong authentication, conditional access, privileged access, sharing or boundary controls, endpoint hardening or compliance, audit retention, and delegated admin boundaries.
- **FR-236-014 No framework-first primary shape**: Framework overlays such as NIS2, BSI, ISO, COBIT, or CIS MUST NOT be the primary shape of the canonical catalog in the first slice.
- **FR-236-015 Honest non-direct coverage**: The system MUST represent controls that are not directly technically verifiable without implying that they are directly evaluated by the product.
- **FR-236-016 Stable historical reference**: A canonical control key once shipped MUST remain stable for downstream artifacts to reference it consistently, even if the control later becomes retired for new use.
- **FR-236-017 Missing binding failure safety**: If a downstream consumer requests canonical control metadata for a subject or signal with no valid binding, the system MUST return an explicit unresolved result rather than inventing a local fallback control label.
- **FR-236-018 Narrow rollout model**: The first slice MUST stay product-seeded and MUST NOT require operator-managed CRUD authoring for controls.
- **FR-236-019 No new Graph path**: The first slice MUST NOT introduce Microsoft Graph calls or a provider synchronization job for catalog resolution.
- **FR-236-020 Platform vocabulary**: Shared platform contracts introduced by this feature MUST use canonical control and governed-subject vocabulary rather than workload-specific or framework-specific names as their primary language.
### Key Entities *(include if feature involves data)*
- **Canonical Control Definition**: The product-owned description of one stable governance control objective, including its identity, taxonomy placement, detectability semantics, evaluation strategy, evidence archetypes, and suitability metadata.
- **Microsoft Subject Binding**: Provider-owned metadata that links Microsoft subject families, workloads, or signal sources to one canonical control without changing the control's primary identity.
- **Canonical Control Resolution Result**: The shared contract outcome that returns either a resolved canonical control reference or an explicit unresolved or ambiguous result for downstream consumers.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-236-001**: For every seed control in the first slice, 100% of catalog entries include control domain, subdomain, control class, detectability class, evaluation strategy, and at least one evidence archetype.
- **SC-236-002**: The same governance objective resolved through at least two targeted first-slice consumer contexts returns one identical canonical control key and label.
- **SC-236-003**: 100% of first-slice evidence and tenant review integrations use the shared canonical control contract and do not introduce feature-local control-family registries or fallback labels.
- **SC-236-004**: A new Microsoft subject binding for an already-modeled governance objective can be added without creating a duplicate canonical control definition.

View File

@ -0,0 +1,256 @@
# Tasks: Canonical Control Catalog Foundation
**Input**: Design documents from `/specs/236-canonical-control-catalog-foundation/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/canonical-control-catalog.logical.openapi.yaml`, `quickstart.md`
**Tests**: Required. This feature changes shared governance semantics and downstream read-model composition, so Pest coverage must be added or extended in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php`, `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php`, `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php`, and `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`.
**Operations**: No new `OperationRun` type is introduced. Tasks that touch `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` or `apps/platform/app/Services/TenantReviews/TenantReviewService.php` must preserve existing run ownership, notifications, and audit behavior instead of creating a new catalog-specific workflow.
**RBAC**: No new capability or route is introduced. Existing authorization on evidence and tenant review surfaces must remain tenant-safe, and any downstream control metadata must stay behind the current evidence and review authorization paths.
**UI Naming**: No new operator-facing action surface is added. If any evidence or review copy changes, canonical control vocabulary must be primary and provider or workload labels must remain secondary descriptors.
**Cross-Cutting Shared Pattern Reuse**: Extend the existing governance summary and composition paths in `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php`, `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` before introducing any feature-local control registry or formatter.
**Provider Boundary / Platform Core**: Platform-core control identity, taxonomy, detectability, and evidence suitability live in `apps/platform/app/Support/Governance/Controls/` and `apps/platform/config/canonical_controls.php`. Microsoft workload, subject-family, and signal metadata remain secondary binding data and must not become the primary key or vocabulary for canonical controls.
**UI / Surface Guardrails**: `N/A` for new surfaces. This slice is `report-only` for existing evidence and review composition surfaces and must not introduce a new page, wizard, or custom Filament contract.
**Filament UI Action Surfaces**: No new Filament Resource, RelationManager, or Page action is introduced. Existing global-search posture remains unchanged because no new Resource is added in this slice.
**Badges**: No new badge domain or badge-mapping family is introduced.
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1`, `US2`, `US3`, then `US4`, because consumer adoption depends on the shared catalog, metadata semantics, and provider-binding behavior being stable first.
## Test Governance Checklist
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [X] New or changed tests stay in the smallest honest family, and no heavy-governance or browser lane is introduced.
- [X] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; seeded config drives the catalog.
- [X] Planned validation commands cover the change without pulling in unrelated lane cost.
- [X] The declared surface test profile or `standard-native-filament` relief is explicit.
- [X] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Phase 1: Setup (Shared Anchors)
**Purpose**: Lock the implementation anchors, consumer scope, and narrow proving commands before adding the canonical control core.
- [X] T001 [P] Verify the feature anchor inventory across `apps/platform/app/Support/Governance/`, `apps/platform/app/Services/Evidence/`, `apps/platform/app/Services/TenantReviews/`, and `apps/platform/config/canonical_controls.php`
- [X] T002 [P] Create the Spec 236 proving entry points in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php`, `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php`, `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php`, and `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
- [X] T003 [P] Confirm the narrow validation commands and first-slice consumer scope in `specs/236-canonical-control-catalog-foundation/spec.md` and `specs/236-canonical-control-catalog-foundation/quickstart.md`
**Checkpoint**: Runtime anchors and proof entry points are fixed before implementation starts.
---
## Phase 2: Foundational (Blocking Control Core)
**Purpose**: Establish the product-seeded catalog and shared resolution primitives that every user story depends on.
**CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 [P] Create the bounded seed-catalog configuration anchor in `apps/platform/config/canonical_controls.php`
- [X] T005 [P] Create canonical control metadata types in `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php`, `apps/platform/app/Support/Governance/Controls/DetectabilityClass.php`, `apps/platform/app/Support/Governance/Controls/EvaluationStrategy.php`, `apps/platform/app/Support/Governance/Controls/EvidenceArchetype.php`, and `apps/platform/app/Support/Governance/Controls/ArtifactSuitability.php`
- [X] T006 [P] Create provider-binding and resolution-contract primitives in `apps/platform/app/Support/Governance/Controls/MicrosoftSubjectBinding.php`, `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php`, and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php`
- [X] T007 [P] Create shared catalog and resolver service shells in `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php` and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`
- [X] T008 [P] Inventory first-slice downstream adoption seams in `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` so consumer adoption stays derived and local registries are forbidden
**Checkpoint**: The repo has one shared catalog namespace, one config-backed seed source, and one explicit downstream adoption boundary.
---
## Phase 3: User Story 1 - Resolve One Stable Control Identity (Priority: P1) 🎯 MVP
**Goal**: Resolve the same governance objective to one stable canonical control key and label across supported contexts.
**Independent Test**: Resolve the same objective through multiple subject families and consumer contexts and confirm the shared contract returns one identical canonical control key and canonical label.
### Tests for User Story 1
- [X] T009 [P] [US1] Add stable-key, canonical-label, and same-objective convergence coverage in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php` and `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php`
- [X] T010 [P] [US1] Add shared logical-contract coverage for catalog listing and resolution shapes in `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`
### Implementation for User Story 1
- [X] T011 [US1] Populate stable canonical control keys, names, domains, subdomains, classes, summaries, operator descriptions, and `historical_status` in `apps/platform/config/canonical_controls.php`
- [X] T012 [US1] Implement deterministic catalog loading, lookup, and historical-key stability in `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`
- [X] T013 [US1] Implement shared canonical control resolution by provider, subject family, workload, signal, and consumer context in `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`, `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php`, and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php`
**Checkpoint**: User Story 1 is independently functional and stable canonical control identity no longer depends on feature-local naming.
---
## Phase 4: User Story 2 - Preserve Honest Detectability and Evidence Meaning (Priority: P1)
**Goal**: Ensure each canonical control carries explicit detectability, evaluation, and evidence semantics so downstream consumers do not over-claim proof.
**Independent Test**: Resolve controls with direct, indirect, workflow-attested, and external-evidence-only semantics and confirm the returned metadata preserves those distinctions and allowed evidence forms.
### Tests for User Story 2
- [X] T014 [P] [US2] Add detectability, evaluation-strategy, evidence-archetype, artifact-suitability, and required seed-family coverage in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php`
- [X] T015 [P] [US2] Add resolved-metadata contract coverage for honest detectability and suitability semantics in `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`
### Implementation for User Story 2
- [X] T016 [US2] Expand every seed definition with detectability, evaluation, evidence-archetype, and artifact-suitability metadata in `apps/platform/config/canonical_controls.php`
- [X] T017 [US2] Enforce metadata completeness and narrow validation failures in `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php` and `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`
- [X] T018 [US2] Expose downstream-safe detectability and suitability metadata through `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php` and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`
**Checkpoint**: User Story 2 is independently functional and the shared contract carries honest proof semantics instead of a false universal verification model.
---
## Phase 5: User Story 3 - Add Microsoft Bindings Without Making Microsoft the Control Model (Priority: P2)
**Goal**: Attach Microsoft workload, subject-family, and signal bindings to canonical controls while keeping provider-neutral control identity primary.
**Independent Test**: Add or modify Microsoft binding metadata for a seeded control and confirm the canonical control definition stays stable, duplicate controls are not created, and ambiguous cases fail deterministically.
### Tests for User Story 3
- [X] T019 [P] [US3] Add multi-binding, provider-neutral identity, unresolved, ambiguous, retired-control, and `historical_status` coverage in `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php`
- [X] T020 [P] [US3] Add workload, signal, and context-primary integration coverage for no-guess resolution in `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`
### Implementation for User Story 3
- [X] T021 [US3] Model provider-owned Microsoft bindings, supported contexts, primary flags, and notes in `apps/platform/config/canonical_controls.php` and `apps/platform/app/Support/Governance/Controls/MicrosoftSubjectBinding.php`
- [X] T022 [US3] Implement binding selection, context-primary resolution, and deterministic unresolved or ambiguous reason codes in `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`
- [X] T023 [US3] Keep Microsoft metadata secondary and provider-neutral vocabulary primary across `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php`, `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`, and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php`
**Checkpoint**: User Story 3 is independently functional and Microsoft bindings extend coverage without becoming the control model.
---
## Phase 6: User Story 4 - Prepare Later Readiness and Review Work Without Local Reinvention (Priority: P3)
**Goal**: Give first-slice downstream consumers one shared path to canonical control metadata so evidence and review composition stop inventing local control families.
**Independent Test**: Generate evidence and compose a tenant review, then confirm both paths consume canonical control metadata through the shared resolver without adding a local registry or fallback label.
### Tests for User Story 4
- [X] T024 [P] [US4] Add evidence-snapshot control-reference coverage proving shared canonical-control contract use and no local fallback labels in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php`
- [X] T025 [P] [US4] Add tenant-review composition control-reference coverage proving shared canonical-control contract use and no local fallback labels in `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
### Implementation for User Story 4
- [X] T026 [US4] Resolve canonical control references inside findings-derived evidence composition in `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php` and `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
- [X] T027 [US4] Preserve transient shared control metadata on evidence lookup and snapshot item payload consumption without introducing new canonical-control persistence ownership in `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php` and `apps/platform/app/Models/EvidenceSnapshotItem.php`
- [X] T028 [US4] Reuse shared control resolution during review composition instead of local control wording in `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php` and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`
- [X] T029 [US4] Keep tenant review orchestration derived and persistence-neutral while passing canonical control context through `apps/platform/app/Services/TenantReviews/TenantReviewService.php` and `apps/platform/app/Models/TenantReview.php`
**Checkpoint**: User Story 4 is independently functional and first-slice consumers have one shared control-resolution path.
---
## Phase 7: Polish & Cross-Cutting Validation
**Purpose**: Remove local semantic drift, run the narrow proving lanes, and close the feature with explicit guardrail notes.
- [X] T030 [P] Search `apps/platform/app/Support/Governance/Controls/`, `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewService.php` to confirm no feature-local control-family fallback, workload-first primary vocabulary, or framework-first primary control shape remains
- [X] T031 Run the fast-feedback unit lane from `specs/236-canonical-control-catalog-foundation/quickstart.md` with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php`
- [X] T032 [P] Run the confidence feature lane from `specs/236-canonical-control-catalog-foundation/quickstart.md` with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
- [X] T033 Run formatting for touched PHP and test files with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [X] T034 Record the Guardrail close-out entry, validation commands, and any bounded follow-up note in `specs/236-canonical-control-catalog-foundation/quickstart.md` and the active PR description
- [X] T035 [P] Confirm this slice introduces no Graph client change, no `config/graph_contracts.php` change, and no provider sync job by searching `apps/platform/app/`, `apps/platform/config/graph_contracts.php`, and `apps/platform/app/Jobs/` before merge
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all story work.
- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the MVP cut.
- **User Story 2 (Phase 4)**: Depends on User Story 1 because honest detectability metadata builds on the shared canonical identity and resolver contract.
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because provider-owned bindings must resolve the full canonical metadata set.
- **User Story 4 (Phase 6)**: Depends on User Story 1 through User Story 3 because downstream consumers should adopt the final shared catalog and binding behavior instead of an interim contract.
- **Polish (Phase 7)**: Depends on all completed story work.
### User Story Dependencies
- **US1**: No dependency beyond Foundational.
- **US2**: Depends on US1 shared catalog and resolver behavior.
- **US3**: Depends on US1 and US2 shared identity plus metadata semantics.
- **US4**: Depends on US1, US2, and US3 to keep downstream consumer adoption on the final shared contract.
### Within Each User Story
- Write the story tests first and confirm they fail before implementation is considered complete.
- Keep the catalog product-seeded and in-repo; do not introduce DB-backed control authoring or migrations.
- Keep provider-specific binding metadata secondary to canonical control identity.
- Keep consumer adoption derived in evidence and review composition; do not add feature-local registries or fallback labels.
- Finish story-level validation before moving to the next dependent story.
### Parallel Opportunities
- `T001`, `T002`, and `T003` can run in parallel during Setup.
- `T004`, `T005`, `T006`, `T007`, and `T008` can run in parallel during Foundational work.
- `T009` and `T010` can run in parallel for User Story 1 before `T011` through `T013`.
- `T014` and `T015` can run in parallel for User Story 2 before `T016` through `T018`.
- `T019` and `T020` can run in parallel for User Story 3 before `T021` through `T023`.
- `T024` and `T025` can run in parallel for User Story 4 before `T026` through `T029`.
- `T031`, `T032`, and `T033` can run in parallel during final validation once implementation is complete.
---
## Parallel Example: User Story 1
```bash
# User Story 1 proof in parallel
T009 apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php
T010 apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 proof in parallel
T014 apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php
T015 apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 proof in parallel
T019 apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php
T020 apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php
```
## Parallel Example: User Story 4
```bash
# User Story 4 proof in parallel
T024 apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php
T025 apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Run `T031` before widening the slice.
### Incremental Delivery
1. Ship US1 to establish one stable canonical control identity and shared resolver contract.
2. Ship US2 to make detectability and evidence meaning explicit and honest.
3. Ship US3 to add Microsoft bindings without turning Microsoft semantics into platform truth.
4. Ship US4 to move evidence and tenant review composition onto the shared control contract.
5. Finish with final validation, formatting, and close-out notes from Phase 7.
### Parallel Team Strategy
1. One contributor can prepare the config-backed catalog and metadata primitives while another prepares the dedicated test files.
2. After Foundation is complete, one contributor can take US1 or US2 while another prepares US3 test coverage against the shared resolver.
3. Once the shared resolver is stable, evidence adoption and tenant review adoption can proceed in parallel inside US4.
---
## Notes
- `[P]` tasks target different files or independent proof surfaces and can be worked in parallel once upstream blockers are cleared.
- `[US1]`, `[US2]`, `[US3]`, and `[US4]` map directly to the feature specification user stories.
- The logical contract already exists in `specs/236-canonical-control-catalog-foundation/contracts/canonical-control-catalog.logical.openapi.yaml`; implementation tasks keep runtime behavior aligned to that shape rather than creating a public HTTP surface.
- The suggested MVP scope is Phase 1 through Phase 3 only.