feat: implement provider-neutral artifact source taxonomy #343

Merged
ahmido merged 1 commits from 284-provider-neutral-artifact-source-taxonomy into platform-dev 2026-05-08 23:47:34 +00:00
37 changed files with 3825 additions and 77 deletions

View File

@ -207,6 +207,24 @@ public static function infolist(Schema $schema): Schema
RepeatableEntry::make('items') RepeatableEntry::make('items')
->hiddenLabel() ->hiddenLabel()
->schema([ ->schema([
TextEntry::make('artifact_source_family')
->label('Source family')
->badge()
->state(fn (EvidenceSnapshotItem $record): string => static::artifactDescriptorValue($record, 'source_family'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_source_kind')
->label('Source kind')
->state(fn (EvidenceSnapshotItem $record): string => static::artifactDescriptorValue($record, 'source_kind'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_source_target')
->label('Source target')
->state(fn (EvidenceSnapshotItem $record): string => static::artifactDescriptorValue($record, 'source_target_kind'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_control_key')
->label('Control')
->state(fn (EvidenceSnapshotItem $record): ?string => static::artifactDescriptorNullableValue($record, 'control_key'))
->formatStateUsing(fn (string $state): string => Str::headline($state))
->placeholder('—'),
TextEntry::make('dimension_key')->label('Dimension') TextEntry::make('dimension_key')->label('Dimension')
->formatStateUsing(fn (string $state): string => Str::headline($state)), ->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('state') TextEntry::make('state')
@ -215,7 +233,7 @@ public static function infolist(Schema $schema): Schema
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
TextEntry::make('source_kind')->label('Source') TextEntry::make('source_kind')->label('Provider source detail')
->formatStateUsing(fn (string $state): string => Str::headline($state)), ->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('freshness_at')->dateTime()->placeholder('—'), TextEntry::make('freshness_at')->dateTime()->placeholder('—'),
ViewEntry::make('summary_payload_highlights') ViewEntry::make('summary_payload_highlights')
@ -442,7 +460,7 @@ private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item)
{ {
$payload = is_array($item->summary_payload) ? $item->summary_payload : []; $payload = is_array($item->summary_payload) ? $item->summary_payload : [];
return match ($item->dimension_key) { $presentation = match ($item->dimension_key) {
'findings_summary' => static::findingsSummaryPresentation($payload), 'findings_summary' => static::findingsSummaryPresentation($payload),
'permission_posture' => static::permissionPosturePresentation($payload), 'permission_posture' => static::permissionPosturePresentation($payload),
'entra_admin_roles' => static::entraAdminRolesPresentation($payload), 'entra_admin_roles' => static::entraAdminRolesPresentation($payload),
@ -450,6 +468,39 @@ private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item)
'operations_summary' => static::operationsSummaryPresentation($payload), 'operations_summary' => static::operationsSummaryPresentation($payload),
default => static::genericSummaryPresentation($payload), default => static::genericSummaryPresentation($payload),
}; };
$presentation['artifact_sources'] = [static::artifactSourceSummary($item)];
return $presentation;
}
private static function artifactDescriptorValue(EvidenceSnapshotItem $item, string $key): string
{
return (string) (static::artifactDescriptorNullableValue($item, $key) ?? 'unknown');
}
private static function artifactDescriptorNullableValue(EvidenceSnapshotItem $item, string $key): ?string
{
$descriptor = $item->artifactSourceDescriptor()->toArray();
$value = $descriptor[$key] ?? null;
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
/**
* @return array{source_family: string, source_kind: string, source_target_kind: string, control_key: ?string, detector_key: ?string}
*/
private static function artifactSourceSummary(EvidenceSnapshotItem $item): array
{
$descriptor = $item->artifactSourceDescriptor();
return [
'source_family' => $descriptor->sourceFamily,
'source_kind' => $descriptor->sourceKind,
'source_target_kind' => $descriptor->sourceTargetKind,
'control_key' => $descriptor->controlKey,
'detector_key' => $descriptor->detectorKey,
];
} }
/** /**

View File

@ -59,6 +59,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use InvalidArgumentException; use InvalidArgumentException;
use Throwable; use Throwable;
use UnitEnum; use UnitEnum;
@ -252,9 +253,50 @@ public static function infolist(Schema $schema): Schema
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Artifact source')
->schema([
TextEntry::make('artifact_source_family')
->label('Source family')
->badge()
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'source_family'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_source_kind')
->label('Source kind')
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'source_kind'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_source_target')
->label('Source target')
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'source_target_kind'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_source_target_identifier')
->label('Target identifier')
->state(fn (Finding $record): ?string => static::artifactDescriptorNullableValue($record, 'source_target_identifier'))
->copyable()
->placeholder('—'),
TextEntry::make('artifact_detector_key')
->label('Detector')
->state(fn (Finding $record): ?string => static::artifactDescriptorNullableValue($record, 'detector_key'))
->placeholder('—'),
TextEntry::make('artifact_control_key')
->label('Control')
->state(fn (Finding $record): ?string => static::artifactDescriptorNullableValue($record, 'control_key'))
->formatStateUsing(fn (string $state): string => Str::headline($state))
->placeholder('—'),
TextEntry::make('artifact_provider_key')
->label('Provider')
->state(fn (Finding $record): string => static::artifactDescriptorValue($record, 'provider_key'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_provider_object_type')
->label('Provider object type')
->state(fn (Finding $record): ?string => static::artifactProviderDetailValue($record, 'provider_object_type'))
->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Finding') Section::make('Finding')
->schema([ ->schema([
TextEntry::make('finding_type')->badge()->label('Type'), TextEntry::make('finding_type')->badge()->label('Provider finding type'),
TextEntry::make('drift_surface_label') TextEntry::make('drift_surface_label')
->label('Drift surface') ->label('Drift surface')
->badge() ->badge()
@ -1375,6 +1417,27 @@ private static function findingRunNavigationContext(Finding $record): CanonicalN
); );
} }
private static function artifactDescriptorValue(Finding $record, string $key): string
{
return (string) (static::artifactDescriptorNullableValue($record, $key) ?? 'unknown');
}
private static function artifactDescriptorNullableValue(Finding $record, string $key): ?string
{
$descriptor = $record->artifactSourceDescriptor()->toArray();
$value = $descriptor[$key] ?? null;
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
private static function artifactProviderDetailValue(Finding $record, string $key): ?string
{
$detail = $record->artifactProviderDetail()->toArray();
$value = $detail[$key] ?? null;
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [

View File

@ -36,6 +36,7 @@
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use UnitEnum; use UnitEnum;
@ -154,11 +155,47 @@ public static function infolist(Schema $schema): Schema
{ {
return $schema return $schema
->schema([ ->schema([
Section::make('Artifact source')
->schema([
TextEntry::make('artifact_source_family')
->label('Source family')
->badge()
->state(fn (InventoryItem $record): string => static::artifactDescriptorValue($record, 'source_family'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_source_kind')
->label('Source kind')
->state(fn (InventoryItem $record): string => static::artifactDescriptorValue($record, 'source_kind'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_source_target')
->label('Source target')
->state(fn (InventoryItem $record): string => static::artifactDescriptorValue($record, 'source_target_kind'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_control_key')
->label('Control')
->state(fn (InventoryItem $record): ?string => static::artifactDescriptorNullableValue($record, 'control_key'))
->formatStateUsing(fn (string $state): string => Str::headline($state))
->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Inventory Item') Section::make('Inventory Item')
->schema([ ->schema([
TextEntry::make('display_name')->label('Name'), TextEntry::make('display_name')->label('Name'),
TextEntry::make('canonical_type')
->label('Canonical type')
->badge()
->state(fn (InventoryItem $record): string => static::typeDescriptorValue($record, 'canonical_type'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('provider_display_type')
->label('Provider display type')
->state(fn (InventoryItem $record): string => static::typeDescriptorValue($record, 'provider_display_type')),
TextEntry::make('provider_object_type')
->label('Provider object type')
->state(fn (InventoryItem $record): string => static::typeDescriptorValue($record, 'provider_object_type'))
->copyable(),
TextEntry::make('policy_type') TextEntry::make('policy_type')
->label('Type') ->label('Legacy policy type')
->badge() ->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType)) ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)), ->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
@ -252,11 +289,20 @@ public static function table(Table $table): Table
->label('Name') ->label('Name')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('canonical_type')
->label('Canonical type')
->badge()
->getStateUsing(fn (InventoryItem $record): string => static::typeDescriptorValue($record, 'canonical_type'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
Tables\Columns\TextColumn::make('provider_display_type')
->label('Provider display type')
->getStateUsing(fn (InventoryItem $record): string => static::typeDescriptorValue($record, 'provider_display_type')),
Tables\Columns\TextColumn::make('policy_type') Tables\Columns\TextColumn::make('policy_type')
->label('Type') ->label('Legacy policy type')
->badge() ->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType)) ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)), ->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('category') Tables\Columns\TextColumn::make('category')
->badge() ->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory)) ->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
@ -385,6 +431,27 @@ private static function typeMeta(?string $type): array
return InventoryPolicyTypeMeta::metaFor($type); return InventoryPolicyTypeMeta::metaFor($type);
} }
private static function typeDescriptorValue(InventoryItem $record, string $key): string
{
$descriptor = $record->inventoryTypeDescriptor();
$value = $descriptor[$key] ?? null;
return is_string($value) && trim($value) !== '' ? trim($value) : 'unknown';
}
private static function artifactDescriptorValue(InventoryItem $record, string $key): string
{
return (string) (static::artifactDescriptorNullableValue($record, $key) ?? 'unknown');
}
private static function artifactDescriptorNullableValue(InventoryItem $record, string $key): ?string
{
$descriptor = $record->artifactSourceDescriptor()->toArray();
$value = $descriptor[$key] ?? null;
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
/** /**
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */

View File

@ -184,13 +184,45 @@ public static function infolist(Schema $schema): Schema
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make('Artifact source')
->schema([
TextEntry::make('artifact_source_family')
->label('Source family')
->badge()
->state(fn (StoredReport $record): string => static::artifactDescriptorValue($record, 'source_family'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_source_kind')
->label('Source kind')
->state(fn (StoredReport $record): string => static::artifactDescriptorValue($record, 'source_kind'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_source_target')
->label('Source target')
->state(fn (StoredReport $record): string => static::artifactDescriptorValue($record, 'source_target_kind'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
TextEntry::make('artifact_control_key')
->label('Control')
->state(fn (StoredReport $record): ?string => static::artifactDescriptorNullableValue($record, 'control_key'))
->formatStateUsing(fn (string $state): string => Str::headline($state))
->placeholder('—'),
TextEntry::make('artifact_detector_key')
->label('Detector')
->state(fn (StoredReport $record): ?string => static::artifactDescriptorNullableValue($record, 'detector_key'))
->placeholder('—'),
TextEntry::make('artifact_provider_key')
->label('Provider')
->state(fn (StoredReport $record): string => static::artifactDescriptorValue($record, 'provider_key'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
])
->columns(2)
->columnSpanFull(),
Section::make('Stored report') Section::make('Stored report')
->schema([ ->schema([
TextEntry::make('display_reference') TextEntry::make('display_reference')
->label('Artifact reference') ->label('Artifact reference')
->state(fn (StoredReport $record): string => static::displayReference($record)), ->state(fn (StoredReport $record): string => static::displayReference($record)),
TextEntry::make('report_type') TextEntry::make('report_type')
->label('Report family') ->label('Provider report type')
->formatStateUsing(fn (string $state): string => static::reportFamilyReportLabel($state)), ->formatStateUsing(fn (string $state): string => static::reportFamilyReportLabel($state)),
TextEntry::make('measured_at') TextEntry::make('measured_at')
->label('Measured at') ->label('Measured at')
@ -289,8 +321,13 @@ public static function table(Table $table): Table
->label('Reference') ->label('Reference')
->formatStateUsing(fn (int|string|null $state): string => sprintf('Stored report #%s', $state ?? '—')) ->formatStateUsing(fn (int|string|null $state): string => sprintf('Stored report #%s', $state ?? '—'))
->searchable(query: fn (Builder $query, string $search): Builder => static::applyReportSearch($query, $search)), ->searchable(query: fn (Builder $query, string $search): Builder => static::applyReportSearch($query, $search)),
Tables\Columns\TextColumn::make('artifact_source_family')
->label('Source family')
->badge()
->getStateUsing(fn (StoredReport $record): string => static::artifactDescriptorValue($record, 'source_family'))
->formatStateUsing(fn (string $state): string => Str::headline($state)),
Tables\Columns\TextColumn::make('report_type') Tables\Columns\TextColumn::make('report_type')
->label('Report family') ->label('Provider report type')
->formatStateUsing(fn (string $state): string => static::reportFamilyReportLabel($state)) ->formatStateUsing(fn (string $state): string => static::reportFamilyReportLabel($state))
->searchable(query: fn (Builder $query, string $search): Builder => static::applyReportSearch($query, $search)), ->searchable(query: fn (Builder $query, string $search): Builder => static::applyReportSearch($query, $search)),
Tables\Columns\TextColumn::make('lifecycle_state') Tables\Columns\TextColumn::make('lifecycle_state')
@ -426,6 +463,19 @@ public static function displayReference(StoredReport $report): string
return $truth->displayReference ?? sprintf('Stored report #%d (%s)', (int) $report->getKey(), static::reportFamilyLabel((string) $report->report_type)); return $truth->displayReference ?? sprintf('Stored report #%d (%s)', (int) $report->getKey(), static::reportFamilyLabel((string) $report->report_type));
} }
private static function artifactDescriptorValue(StoredReport $record, string $key): string
{
return (string) (static::artifactDescriptorNullableValue($record, $key) ?? 'unknown');
}
private static function artifactDescriptorNullableValue(StoredReport $record, string $key): ?string
{
$descriptor = $record->artifactSourceDescriptor()->toArray();
$value = $descriptor[$key] ?? null;
return is_string($value) && trim($value) !== '' ? trim($value) : null;
}
public static function lifecycleState(StoredReport $report): string public static function lifecycleState(StoredReport $report): string
{ {
$truth = app(ArtifactTruthPresenter::class)->forStoredReportFresh($report); $truth = app(ArtifactTruthPresenter::class)->forStoredReportFresh($report);

View File

@ -947,6 +947,7 @@ private static function sectionPresentation(TenantReviewSection $section): array
'value' => (string) $value, 'value' => (string) $value,
]; ];
})->filter()->values()->all(), })->filter()->values()->all(),
'artifact_sources' => is_array($render['artifact_sources'] ?? null) ? $render['artifact_sources'] : [],
'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [], 'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [],
'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [], 'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [],
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null, 'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,

View File

@ -5,6 +5,8 @@
namespace App\Models; namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\Artifacts\ArtifactSourceDescriptor;
use App\Support\Artifacts\ArtifactSourceResolver;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -58,4 +60,9 @@ public function canonicalControlReferences(): array
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference))) ? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
: []; : [];
} }
public function artifactSourceDescriptor(): ArtifactSourceDescriptor
{
return app(ArtifactSourceResolver::class)->forEvidenceSnapshotItem($this);
}
} }

View File

@ -3,6 +3,9 @@
namespace App\Models; namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\Artifacts\ArtifactProviderDetail;
use App\Support\Artifacts\ArtifactSourceDescriptor;
use App\Support\Artifacts\ArtifactSourceResolver;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -359,6 +362,16 @@ public function resolvedSubjectDisplayName(): ?string
return $fallback !== '' ? $fallback : null; return $fallback !== '' ? $fallback : null;
} }
public function artifactSourceDescriptor(): ArtifactSourceDescriptor
{
return app(ArtifactSourceResolver::class)->forFinding($this);
}
public function artifactProviderDetail(): ArtifactProviderDetail
{
return app(ArtifactSourceResolver::class)->providerDetailForFinding($this);
}
public function responsibilityState(): string public function responsibilityState(): string
{ {
if ($this->owner_user_id === null) { if ($this->owner_user_id === null) {

View File

@ -3,6 +3,10 @@
namespace App\Models; namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\Artifacts\ArtifactProviderDetail;
use App\Support\Artifacts\ArtifactSourceDescriptor;
use App\Support\Artifacts\ArtifactSourceResolver;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -35,4 +39,30 @@ public function lastSeenOperationRun(): BelongsTo
{ {
return $this->belongsTo(OperationRun::class, 'last_seen_operation_run_id'); return $this->belongsTo(OperationRun::class, 'last_seen_operation_run_id');
} }
/**
* @return array{
* canonical_type: string,
* provider_object_type: string,
* provider_display_type: string,
* legacy_policy_type: ?string
* }
*/
public function inventoryTypeDescriptor(): array
{
return InventoryPolicyTypeMeta::typeDescriptorFor(
is_string($this->policy_type) ? $this->policy_type : null,
is_array($this->meta_jsonb) ? $this->meta_jsonb : [],
);
}
public function artifactSourceDescriptor(): ArtifactSourceDescriptor
{
return app(ArtifactSourceResolver::class)->forInventoryItem($this);
}
public function artifactProviderDetail(): ArtifactProviderDetail
{
return app(ArtifactSourceResolver::class)->providerDetailForInventoryItem($this);
}
} }

View File

@ -5,6 +5,9 @@
namespace App\Models; namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant; use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\Artifacts\ArtifactProviderDetail;
use App\Support\Artifacts\ArtifactSourceDescriptor;
use App\Support\Artifacts\ArtifactSourceResolver;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -46,4 +49,14 @@ public function tenant(): BelongsTo
{ {
return $this->belongsTo(ManagedEnvironment::class, 'managed_environment_id'); return $this->belongsTo(ManagedEnvironment::class, 'managed_environment_id');
} }
public function artifactSourceDescriptor(): ArtifactSourceDescriptor
{
return app(ArtifactSourceResolver::class)->forStoredReport($this);
}
public function artifactProviderDetail(): ArtifactProviderDetail
{
return app(ArtifactSourceResolver::class)->providerDetailForStoredReport($this);
}
} }

View File

@ -22,6 +22,8 @@ public function key(): string;
* measured_at: ?\DateTimeInterface, * measured_at: ?\DateTimeInterface,
* freshness_at: ?\DateTimeInterface, * freshness_at: ?\DateTimeInterface,
* summary_payload: array<string, mixed>, * summary_payload: array<string, mixed>,
* source_descriptor?: array<string, mixed>,
* provider_detail?: array<string, mixed>,
* fingerprint_payload: array<string, mixed>, * fingerprint_payload: array<string, mixed>,
* sort_order: int * sort_order: int
* } * }

View File

@ -17,6 +17,7 @@
use App\Services\Evidence\Sources\PermissionPostureSource; use App\Services\Evidence\Sources\PermissionPostureSource;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Artifacts\ArtifactSourceResolver;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
@ -28,6 +29,7 @@ public function __construct(
private readonly OperationRunService $operationRuns, private readonly OperationRunService $operationRuns,
private readonly WorkspaceAuditLogger $auditLogger, private readonly WorkspaceAuditLogger $auditLogger,
private readonly EvidenceCompletenessEvaluator $completenessEvaluator, private readonly EvidenceCompletenessEvaluator $completenessEvaluator,
private readonly ArtifactSourceResolver $artifactSourceResolver,
) {} ) {}
public function generate(ManagedEnvironment $tenant, User $user, bool $allowStale = false): EvidenceSnapshot public function generate(ManagedEnvironment $tenant, User $user, bool $allowStale = false): EvidenceSnapshot
@ -182,6 +184,14 @@ public function buildSnapshotPayload(ManagedEnvironment $tenant): array
foreach ($this->providers() as $provider) { foreach ($this->providers() as $provider) {
$item = $provider->collect($tenant); $item = $provider->collect($tenant);
$descriptor = $this->artifactSourceResolver->forEvidenceProviderPayload($tenant, $item);
$providerDetail = $this->artifactSourceResolver->providerDetailForEvidenceProviderPayload($item);
$item['source_descriptor'] = $descriptor->toArray();
$item['provider_detail'] = $providerDetail->toArray();
$item['summary_payload'] = array_replace(is_array($item['summary_payload'] ?? null) ? $item['summary_payload'] : [], [
'source_descriptor' => $item['source_descriptor'],
'provider_detail' => $item['provider_detail'],
]);
$items[] = $item; $items[] = $item;
$fingerprintPayload[$provider->key()] = $item['fingerprint_payload']; $fingerprintPayload[$provider->key()] = $item['fingerprint_payload'];
} }
@ -212,6 +222,7 @@ public function buildSnapshotPayload(ManagedEnvironment $tenant): array
'key' => $item['dimension_key'], 'key' => $item['dimension_key'],
'state' => $item['state'], 'state' => $item['state'],
'required' => $item['required'], 'required' => $item['required'],
'source_descriptor' => $item['source_descriptor'],
], $items), ], $items),
'finding_outcomes' => is_array($findingsSummary['outcome_counts'] ?? null) 'finding_outcomes' => is_array($findingsSummary['outcome_counts'] ?? null)
? $findingsSummary['outcome_counts'] ? $findingsSummary['outcome_counts']

View File

@ -10,15 +10,14 @@
use App\Services\Findings\FindingRiskGovernanceResolver; use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Governance\Controls\CanonicalControlResolutionRequest; use App\Support\Artifacts\ArtifactSourceResolver;
use App\Support\Governance\Controls\CanonicalControlResolver;
final class FindingsSummarySource implements EvidenceSourceProvider final class FindingsSummarySource implements EvidenceSourceProvider
{ {
public function __construct( public function __construct(
private readonly FindingRiskGovernanceResolver $governanceResolver, private readonly FindingRiskGovernanceResolver $governanceResolver,
private readonly FindingOutcomeSemantics $findingOutcomeSemantics, private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
private readonly CanonicalControlResolver $canonicalControlResolver, private readonly ArtifactSourceResolver $artifactSourceResolver,
) {} ) {}
public function key(): string public function key(): string
@ -39,10 +38,15 @@ public function collect(ManagedEnvironment $tenant): array
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException); $governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException); $governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
$outcome = $this->findingOutcomeSemantics->describe($finding); $outcome = $this->findingOutcomeSemantics->describe($finding);
$canonicalControlResolution = $this->canonicalControlResolutionFor($finding); $descriptor = $this->artifactSourceResolver->forFinding($finding);
$providerDetail = $this->artifactSourceResolver->providerDetailForFinding($finding);
$canonicalControlResolution = $this->artifactSourceResolver->canonicalControlResolutionForFinding($finding);
return [ return [
'id' => (int) $finding->getKey(), 'id' => (int) $finding->getKey(),
'source_descriptor' => $descriptor->toArray(),
'provider_detail' => $providerDetail->toArray(),
'control_key' => $descriptor->controlKey,
'finding_type' => (string) $finding->finding_type, 'finding_type' => (string) $finding->finding_type,
'severity' => (string) $finding->severity, 'severity' => (string) $finding->severity,
'status' => (string) $finding->status, 'status' => (string) $finding->status,
@ -145,68 +149,4 @@ public function collect(ManagedEnvironment $tenant): array
'sort_order' => 10, '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

@ -124,6 +124,13 @@ private function executiveSummarySection(
]) ])
->values() ->values()
->all(), ->all(),
'artifact_sources' => $this->artifactSourceSummaries(
$findingsItem,
$permissionItem,
$rolesItem,
$baselineItem,
$operationsItem,
),
], ],
'measured_at' => $snapshot->generated_at, 'measured_at' => $snapshot->generated_at,
]; ];
@ -159,6 +166,7 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
'render_payload' => [ 'render_payload' => [
'entries' => $entries, 'entries' => $entries,
'empty_state' => empty($entries) ? 'No open risks are recorded in the anchored evidence basis.' : null, 'empty_state' => empty($entries) ? 'No open risks are recorded in the anchored evidence basis.' : null,
'artifact_sources' => $this->artifactSourceSummaries($findingsItem),
], ],
'measured_at' => $findingsItem?->measured_at, 'measured_at' => $findingsItem?->measured_at,
]; ];
@ -195,6 +203,7 @@ private function acceptedRisksSection(?EvidenceSnapshotItem $findingsItem): arra
'disclosure' => (int) ($riskAcceptance['warning_count'] ?? 0) > 0 'disclosure' => (int) ($riskAcceptance['warning_count'] ?? 0) > 0
? 'Some accepted risks need governance follow-up before stakeholder delivery.' ? 'Some accepted risks need governance follow-up before stakeholder delivery.'
: 'Accepted risks are governed by the anchored evidence basis.', : 'Accepted risks are governed by the anchored evidence basis.',
'artifact_sources' => $this->artifactSourceSummaries($findingsItem),
], ],
'measured_at' => $findingsItem?->measured_at, 'measured_at' => $findingsItem?->measured_at,
]; ];
@ -224,6 +233,7 @@ private function permissionPostureSection(?EvidenceSnapshotItem $permissionItem,
'render_payload' => [ 'render_payload' => [
'permission_payload' => is_array($permissionSummary['payload'] ?? null) ? $permissionSummary['payload'] : [], 'permission_payload' => is_array($permissionSummary['payload'] ?? null) ? $permissionSummary['payload'] : [],
'roles' => is_array($rolesSummary['roles'] ?? null) ? $rolesSummary['roles'] : [], 'roles' => is_array($rolesSummary['roles'] ?? null) ? $rolesSummary['roles'] : [],
'artifact_sources' => $this->artifactSourceSummaries($permissionItem, $rolesItem),
], ],
'measured_at' => $permissionItem?->measured_at ?? $rolesItem?->measured_at, 'measured_at' => $permissionItem?->measured_at ?? $rolesItem?->measured_at,
]; ];
@ -248,6 +258,7 @@ private function baselineDriftSection(?EvidenceSnapshotItem $baselineItem): arra
'disclosure' => (int) ($summary['open_drift_count'] ?? 0) > 0 'disclosure' => (int) ($summary['open_drift_count'] ?? 0) > 0
? 'Baseline drift remains visible in this review and should be discussed as hardening work.' ? 'Baseline drift remains visible in this review and should be discussed as hardening work.'
: 'No open baseline drift findings are present in the anchored evidence basis.', : 'No open baseline drift findings are present in the anchored evidence basis.',
'artifact_sources' => $this->artifactSourceSummaries($baselineItem),
], ],
'measured_at' => $baselineItem?->measured_at, 'measured_at' => $baselineItem?->measured_at,
]; ];
@ -271,11 +282,42 @@ private function operationsHealthSection(?EvidenceSnapshotItem $operationsItem):
], ],
'render_payload' => [ 'render_payload' => [
'entries' => array_values(array_slice(Arr::wrap($summary['entries'] ?? []), 0, 10)), 'entries' => array_values(array_slice(Arr::wrap($summary['entries'] ?? []), 0, 10)),
'artifact_sources' => $this->artifactSourceSummaries($operationsItem),
], ],
'measured_at' => $operationsItem?->measured_at, 'measured_at' => $operationsItem?->measured_at,
]; ];
} }
/**
* @return list<array{label: string, source_family: string, source_kind: string, source_target_kind: string, control_key: ?string, detector_key: ?string}>
*/
private function artifactSourceSummaries(?EvidenceSnapshotItem ...$items): array
{
return collect($items)
->filter(static fn (?EvidenceSnapshotItem $item): bool => $item instanceof EvidenceSnapshotItem)
->map(function (EvidenceSnapshotItem $item): array {
$descriptor = $item->artifactSourceDescriptor();
return [
'label' => (string) $item->dimension_key,
'source_family' => $descriptor->sourceFamily,
'source_kind' => $descriptor->sourceKind,
'source_target_kind' => $descriptor->sourceTargetKind,
'control_key' => $descriptor->controlKey,
'detector_key' => $descriptor->detectorKey,
];
})
->unique(static fn (array $item): string => implode('|', [
$item['source_family'],
$item['source_kind'],
$item['source_target_kind'],
$item['control_key'] ?? '',
$item['detector_key'] ?? '',
]))
->values()
->all();
}
private function item(Collection $items, string $key): ?EvidenceSnapshotItem private function item(Collection $items, string $key): ?EvidenceSnapshotItem
{ {
$item = $items->get($key); $item = $items->get($key);

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Support\Artifacts;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
/**
* @implements Arrayable<string, mixed>
*/
final readonly class ArtifactProviderDetail implements Arrayable, JsonSerializable
{
public function __construct(
public ?string $legacyFindingType = null,
public ?string $legacyReportType = null,
public ?string $legacyPolicyType = null,
public ?string $providerObjectType = null,
public ?string $providerDisplayType = null,
public ?string $detectorDetail = null,
) {}
/**
* @return array{
* legacy_finding_type: ?string,
* legacy_report_type: ?string,
* legacy_policy_type: ?string,
* provider_object_type: ?string,
* provider_display_type: ?string,
* detector_detail: ?string
* }
*/
public function toArray(): array
{
return [
'legacy_finding_type' => $this->legacyFindingType,
'legacy_report_type' => $this->legacyReportType,
'legacy_policy_type' => $this->legacyPolicyType,
'provider_object_type' => $this->providerObjectType,
'provider_display_type' => $this->providerDisplayType,
'detector_detail' => $this->detectorDetail,
];
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Support\Artifacts;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
/**
* @implements Arrayable<string, mixed>
*/
final readonly class ArtifactSourceDescriptor implements Arrayable, JsonSerializable
{
public function __construct(
public int $workspaceId,
public int $tenantId,
public int $managedEnvironmentId,
public string $sourceFamily,
public string $sourceKind,
public string $providerKey,
public ?int $providerConnectionId,
public string $sourceTargetKind,
public ?string $sourceTargetIdentifier,
public ?string $detectorKey,
public ?string $controlKey,
public ?int $packageRunId = null,
) {}
/**
* @param array<string, mixed> $payload
*/
public static function fromArray(array $payload): self
{
return new self(
workspaceId: max(0, (int) ($payload['workspace_id'] ?? 0)),
tenantId: max(0, (int) ($payload['tenant_id'] ?? $payload['managed_environment_id'] ?? 0)),
managedEnvironmentId: max(0, (int) ($payload['managed_environment_id'] ?? $payload['tenant_id'] ?? 0)),
sourceFamily: self::stringOrDefault($payload['source_family'] ?? null, ArtifactSourceTaxonomy::SOURCE_FAMILY_EVIDENCE_SNAPSHOT),
sourceKind: self::stringOrDefault($payload['source_kind'] ?? null, ArtifactSourceTaxonomy::SOURCE_KIND_MODEL_SUMMARY),
providerKey: self::stringOrDefault($payload['provider_key'] ?? null, 'microsoft'),
providerConnectionId: self::nullableInt($payload['provider_connection_id'] ?? null),
sourceTargetKind: self::stringOrDefault($payload['source_target_kind'] ?? null, ArtifactSourceTaxonomy::SOURCE_TARGET_MANAGED_ENVIRONMENT),
sourceTargetIdentifier: self::nullableString($payload['source_target_identifier'] ?? null),
detectorKey: self::nullableString($payload['detector_key'] ?? null),
controlKey: self::nullableString($payload['control_key'] ?? null),
packageRunId: self::nullableInt($payload['package_run_id'] ?? null),
);
}
/**
* @return array{
* workspace_id: int,
* tenant_id: int,
* managed_environment_id: int,
* source_family: string,
* source_kind: string,
* provider_key: string,
* provider_connection_id: ?int,
* source_target_kind: string,
* source_target_identifier: ?string,
* detector_key: ?string,
* control_key: ?string,
* package_run_id: ?int
* }
*/
public function toArray(): array
{
return [
'workspace_id' => $this->workspaceId,
'tenant_id' => $this->tenantId,
'managed_environment_id' => $this->managedEnvironmentId,
'source_family' => $this->sourceFamily,
'source_kind' => $this->sourceKind,
'provider_key' => $this->providerKey,
'provider_connection_id' => $this->providerConnectionId,
'source_target_kind' => $this->sourceTargetKind,
'source_target_identifier' => $this->sourceTargetIdentifier,
'detector_key' => $this->detectorKey,
'control_key' => $this->controlKey,
'package_run_id' => $this->packageRunId,
];
}
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
private static function stringOrDefault(mixed $value, string $default): string
{
if (! is_string($value) || trim($value) === '') {
return $default;
}
return trim($value);
}
private static function nullableString(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
private static function nullableInt(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
return is_numeric($value) ? (int) $value : null;
}
}

View File

@ -0,0 +1,540 @@
<?php
declare(strict_types=1);
namespace App\Support\Artifacts;
use App\Models\EvidenceSnapshotItem;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\StoredReport;
use App\Support\Governance\Controls\CanonicalControlResolutionRequest;
use App\Support\Governance\Controls\CanonicalControlResolver;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
final readonly class ArtifactSourceResolver
{
public function __construct(
private CanonicalControlResolver $canonicalControlResolver,
) {}
public function forFinding(Finding $finding): ArtifactSourceDescriptor
{
$controlResolution = $this->canonicalControlResolutionForFinding($finding);
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
$targetIdentifier = $this->firstString([
$finding->subject_external_id,
Arr::get($evidence, 'policy_id'),
Arr::get($evidence, 'policy_external_id'),
Arr::get($evidence, 'external_id'),
Arr::get($evidence, 'id'),
]);
return new ArtifactSourceDescriptor(
workspaceId: (int) $finding->workspace_id,
tenantId: (int) $finding->managed_environment_id,
managedEnvironmentId: (int) $finding->managed_environment_id,
sourceFamily: ArtifactSourceTaxonomy::SOURCE_FAMILY_FINDING,
sourceKind: ArtifactSourceTaxonomy::SOURCE_KIND_MODEL_SUMMARY,
providerKey: $this->providerKey($evidence),
providerConnectionId: $this->providerConnectionId($finding->tenant, $evidence),
sourceTargetKind: $targetIdentifier !== null
? ArtifactSourceTaxonomy::SOURCE_TARGET_GOVERNED_SUBJECT
: ArtifactSourceTaxonomy::SOURCE_TARGET_MANAGED_ENVIRONMENT,
sourceTargetIdentifier: $targetIdentifier ?? (string) $finding->managed_environment_id,
detectorKey: $this->findingDetectorKey($finding),
controlKey: $this->controlKeyFromResolution($controlResolution),
);
}
public function providerDetailForFinding(Finding $finding): ArtifactProviderDetail
{
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
$policyType = $this->firstString([
Arr::get($evidence, 'policy_type'),
Arr::get($evidence, 'provider_object_type'),
]);
$typeDescriptor = InventoryPolicyTypeMeta::typeDescriptorFor($policyType, $evidence);
return new ArtifactProviderDetail(
legacyFindingType: $this->nullableString($finding->finding_type),
legacyPolicyType: $policyType,
providerObjectType: $typeDescriptor['provider_object_type'] ?? $policyType ?? $this->nullableString($finding->finding_type),
providerDisplayType: $typeDescriptor['provider_display_type'] ?? Str::headline((string) $finding->finding_type),
detectorDetail: $this->findingDetectorKey($finding),
);
}
public function forStoredReport(StoredReport $report): ArtifactSourceDescriptor
{
$payload = is_array($report->payload) ? $report->payload : [];
$detectorKey = $this->storedReportDetectorKey((string) $report->report_type);
return new ArtifactSourceDescriptor(
workspaceId: (int) $report->workspace_id,
tenantId: (int) $report->managed_environment_id,
managedEnvironmentId: (int) $report->managed_environment_id,
sourceFamily: ArtifactSourceTaxonomy::SOURCE_FAMILY_STORED_REPORT,
sourceKind: ArtifactSourceTaxonomy::SOURCE_KIND_STORED_REPORT,
providerKey: $this->providerKey($payload),
providerConnectionId: $this->providerConnectionId($report->tenant, $payload),
sourceTargetKind: ArtifactSourceTaxonomy::SOURCE_TARGET_MANAGED_ENVIRONMENT,
sourceTargetIdentifier: (string) $report->managed_environment_id,
detectorKey: $detectorKey,
controlKey: $this->controlKeyForReport((string) $report->report_type),
);
}
public function providerDetailForStoredReport(StoredReport $report): ArtifactProviderDetail
{
return new ArtifactProviderDetail(
legacyReportType: $this->nullableString($report->report_type),
providerObjectType: $this->nullableString($report->report_type),
providerDisplayType: Str::headline(str_replace('.', '_', (string) $report->report_type)),
detectorDetail: $this->storedReportDetectorKey((string) $report->report_type),
);
}
public function forInventoryItem(InventoryItem $item): ArtifactSourceDescriptor
{
$metadata = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
$typeDescriptor = InventoryPolicyTypeMeta::typeDescriptorFor($this->nullableString($item->policy_type), $metadata);
$detectorKey = 'inventory.'.($typeDescriptor['provider_object_type'] ?? $item->policy_type ?? 'unknown');
return new ArtifactSourceDescriptor(
workspaceId: (int) $item->workspace_id,
tenantId: (int) $item->managed_environment_id,
managedEnvironmentId: (int) $item->managed_environment_id,
sourceFamily: ArtifactSourceTaxonomy::SOURCE_FAMILY_INVENTORY,
sourceKind: ArtifactSourceTaxonomy::SOURCE_KIND_INVENTORY_PROJECTION,
providerKey: $this->providerKey($metadata),
providerConnectionId: $this->providerConnectionId($item->tenant, $metadata),
sourceTargetKind: ArtifactSourceTaxonomy::SOURCE_TARGET_GOVERNED_SUBJECT,
sourceTargetIdentifier: $this->firstString([$item->external_id, $item->getKey() !== null ? (string) $item->getKey() : null]),
detectorKey: $detectorKey,
controlKey: $this->controlKeyForInventoryPolicyType($this->nullableString($item->policy_type)),
);
}
public function providerDetailForInventoryItem(InventoryItem $item): ArtifactProviderDetail
{
$metadata = is_array($item->meta_jsonb) ? $item->meta_jsonb : [];
$typeDescriptor = InventoryPolicyTypeMeta::typeDescriptorFor($this->nullableString($item->policy_type), $metadata);
return new ArtifactProviderDetail(
legacyPolicyType: $typeDescriptor['legacy_policy_type'] ?? null,
providerObjectType: $typeDescriptor['provider_object_type'] ?? null,
providerDisplayType: $typeDescriptor['provider_display_type'] ?? null,
detectorDetail: 'inventory.'.($typeDescriptor['provider_object_type'] ?? 'unknown'),
);
}
public function forEvidenceSnapshotItem(EvidenceSnapshotItem $item): ArtifactSourceDescriptor
{
$payload = is_array($item->summary_payload) ? $item->summary_payload : [];
$descriptor = $payload['source_descriptor'] ?? null;
if (is_array($descriptor)) {
return ArtifactSourceDescriptor::fromArray($descriptor);
}
$tenant = $item->tenant;
return $this->forEvidenceProviderPayload(
$tenant instanceof ManagedEnvironment ? $tenant : null,
[
'dimension_key' => $item->dimension_key,
'source_kind' => $item->source_kind,
'source_record_type' => $item->source_record_type,
'source_record_id' => $item->source_record_id,
'summary_payload' => $payload,
],
workspaceId: (int) $item->workspace_id,
managedEnvironmentId: (int) $item->managed_environment_id,
);
}
/**
* @param array<string, mixed> $item
*/
public function forEvidenceProviderPayload(?ManagedEnvironment $tenant, array $item, ?int $workspaceId = null, ?int $managedEnvironmentId = null): ArtifactSourceDescriptor
{
$sourceKind = $this->sourceKind($item['source_kind'] ?? null);
$dimensionKey = $this->nullableString($item['dimension_key'] ?? null);
$sourceRecordType = $this->nullableString($item['source_record_type'] ?? null);
$sourceRecordId = $this->nullableString($item['source_record_id'] ?? null);
$summaryPayload = is_array($item['summary_payload'] ?? null) ? $item['summary_payload'] : [];
$sourceTargetKind = $this->sourceTargetKindForEvidenceItem($sourceKind, $sourceRecordType);
$workspaceId ??= (int) $tenant?->workspace_id;
$managedEnvironmentId ??= (int) $tenant?->getKey();
return new ArtifactSourceDescriptor(
workspaceId: $workspaceId,
tenantId: $managedEnvironmentId,
managedEnvironmentId: $managedEnvironmentId,
sourceFamily: $this->sourceFamilyForEvidenceItem($sourceKind, $sourceRecordType, $dimensionKey),
sourceKind: $sourceKind,
providerKey: $this->providerKey($summaryPayload),
providerConnectionId: $this->providerConnectionId($tenant, $summaryPayload),
sourceTargetKind: $sourceTargetKind,
sourceTargetIdentifier: $this->sourceTargetIdentifier($sourceTargetKind, $sourceRecordId, $managedEnvironmentId),
detectorKey: $dimensionKey,
controlKey: $this->controlKeyForEvidenceDimension($dimensionKey, $summaryPayload),
);
}
/**
* @param array<string, mixed> $item
*/
public function providerDetailForEvidenceProviderPayload(array $item): ArtifactProviderDetail
{
$sourceKind = $this->sourceKind($item['source_kind'] ?? null);
$dimensionKey = $this->nullableString($item['dimension_key'] ?? null);
$sourceRecordType = $this->nullableString($item['source_record_type'] ?? null);
$sourceRecordId = $this->nullableString($item['source_record_id'] ?? null);
if (($sourceKind === ArtifactSourceTaxonomy::SOURCE_KIND_STORED_REPORT || $sourceRecordType === StoredReport::class) && is_numeric($sourceRecordId)) {
$report = StoredReport::query()->find((int) $sourceRecordId);
if ($report instanceof StoredReport) {
return $this->providerDetailForStoredReport($report);
}
}
return match ($this->sourceFamilyForEvidenceItem($sourceKind, $sourceRecordType, $dimensionKey)) {
ArtifactSourceTaxonomy::SOURCE_FAMILY_FINDING => new ArtifactProviderDetail(
legacyFindingType: $dimensionKey,
providerObjectType: $sourceRecordType,
providerDisplayType: 'Findings summary',
detectorDetail: $dimensionKey,
),
ArtifactSourceTaxonomy::SOURCE_FAMILY_OPERATION_RUN => new ArtifactProviderDetail(
providerObjectType: OperationRun::class,
providerDisplayType: 'Operation run',
detectorDetail: $dimensionKey,
),
ArtifactSourceTaxonomy::SOURCE_FAMILY_INVENTORY => new ArtifactProviderDetail(
providerObjectType: InventoryItem::class,
providerDisplayType: 'Inventory projection',
detectorDetail: $dimensionKey,
),
default => new ArtifactProviderDetail(
providerObjectType: $sourceRecordType,
providerDisplayType: $dimensionKey !== null ? Str::headline($dimensionKey) : null,
detectorDetail: $dimensionKey,
),
};
}
public function canonicalControlResolutionForFinding(Finding $finding): array
{
return $this->canonicalControlResolver
->resolve($this->resolutionRequestForFinding($finding))
->toArray();
}
public function resolutionRequestForFinding(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: $this->providerKey($evidence),
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: $this->providerKey($evidence),
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 = $this->firstString([
$evidence['policy_type'] ?? null,
'drift',
]) ?? 'drift';
return new CanonicalControlResolutionRequest(
provider: $this->providerKey($evidence),
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: $this->providerKey($evidence),
consumerContext: 'evidence',
subjectFamilyKey: $findingType,
);
}
/**
* @param array<string, mixed> $resolution
*/
private function controlKeyFromResolution(array $resolution): ?string
{
return $this->nullableString(Arr::get($resolution, 'control.control_key'));
}
private function findingDetectorKey(Finding $finding): ?string
{
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
$findingType = (string) $finding->finding_type;
if ($findingType === Finding::FINDING_TYPE_PERMISSION_POSTURE) {
return 'permission_posture.required_graph_permission';
}
if ($findingType === Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) {
$roleTemplateId = (string) ($evidence['role_template_id'] ?? '');
return $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 = $this->nullableString($evidence['policy_type'] ?? null) ?? 'drift';
return match ($policyType) {
'deviceCompliancePolicy' => 'intune.device_compliance_policy',
'drift' => 'finding.drift',
default => 'intune.device_configuration_drift',
};
}
return $findingType !== '' ? $findingType : null;
}
private function storedReportDetectorKey(string $reportType): ?string
{
return match ($reportType) {
StoredReport::REPORT_TYPE_PERMISSION_POSTURE => 'permission_posture.required_graph_permission',
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES => 'entra_admin_roles.privileged_role_assignment',
default => $this->nullableString($reportType),
};
}
private function controlKeyForReport(string $reportType): ?string
{
$request = match ($reportType) {
StoredReport::REPORT_TYPE_PERMISSION_POSTURE => new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'report',
subjectFamilyKey: 'permission_posture',
workload: 'entra',
signalKey: 'permission_posture.required_graph_permission',
),
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES => new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'report',
subjectFamilyKey: 'entra_admin_roles',
workload: 'entra',
signalKey: 'entra_admin_roles.privileged_role_assignment',
),
default => new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'report',
subjectFamilyKey: $reportType,
),
};
return $this->controlKeyFromResolution($this->canonicalControlResolver->resolve($request)->toArray());
}
private function controlKeyForInventoryPolicyType(?string $policyType): ?string
{
if ($policyType === null) {
return null;
}
$request = new CanonicalControlResolutionRequest(
provider: 'microsoft',
consumerContext: 'evidence',
subjectFamilyKey: $policyType,
workload: str_contains($policyType, 'conditionalAccess') ? 'entra' : 'intune',
signalKey: match ($policyType) {
'deviceCompliancePolicy' => 'intune.device_compliance_policy',
'conditionalAccessPolicy' => 'conditional_access.policy_state',
default => 'intune.device_configuration_drift',
},
);
return $this->controlKeyFromResolution($this->canonicalControlResolver->resolve($request)->toArray());
}
/**
* @param array<string, mixed> $summaryPayload
*/
private function controlKeyForEvidenceDimension(?string $dimensionKey, array $summaryPayload): ?string
{
$direct = $this->nullableString(Arr::get($summaryPayload, 'source_descriptor.control_key'))
?? $this->nullableString(Arr::get($summaryPayload, 'control_key'));
if ($direct !== null) {
return $direct;
}
$control = collect(Arr::wrap($summaryPayload['canonical_controls'] ?? []))
->first(static fn (mixed $item): bool => is_array($item) && filled($item['control_key'] ?? null));
if (is_array($control)) {
return $this->nullableString($control['control_key'] ?? null);
}
return match ($dimensionKey) {
'permission_posture' => $this->controlKeyForReport(StoredReport::REPORT_TYPE_PERMISSION_POSTURE),
'entra_admin_roles' => $this->controlKeyForReport(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES),
default => null,
};
}
private function sourceKind(mixed $value): string
{
if (is_string($value) && ArtifactSourceTaxonomy::isSourceKind($value)) {
return $value;
}
return ArtifactSourceTaxonomy::SOURCE_KIND_MODEL_SUMMARY;
}
private function sourceFamilyForEvidenceItem(string $sourceKind, ?string $sourceRecordType, ?string $dimensionKey): string
{
if ($sourceKind === ArtifactSourceTaxonomy::SOURCE_KIND_STORED_REPORT || $sourceRecordType === StoredReport::class || $sourceRecordType === 'stored_report') {
return ArtifactSourceTaxonomy::SOURCE_FAMILY_STORED_REPORT;
}
if ($sourceKind === ArtifactSourceTaxonomy::SOURCE_KIND_OPERATION_ROLLUP || $sourceRecordType === OperationRun::class || $sourceRecordType === 'operation_run') {
return ArtifactSourceTaxonomy::SOURCE_FAMILY_OPERATION_RUN;
}
if ($sourceKind === ArtifactSourceTaxonomy::SOURCE_KIND_INVENTORY_PROJECTION || $sourceRecordType === InventoryItem::class || $sourceRecordType === 'inventory_item') {
return ArtifactSourceTaxonomy::SOURCE_FAMILY_INVENTORY;
}
if ($sourceRecordType === Finding::class || $sourceRecordType === 'finding' || in_array($dimensionKey, ['findings_summary', 'baseline_drift_posture'], true)) {
return ArtifactSourceTaxonomy::SOURCE_FAMILY_FINDING;
}
return ArtifactSourceTaxonomy::SOURCE_FAMILY_EVIDENCE_SNAPSHOT;
}
private function sourceTargetKindForEvidenceItem(string $sourceKind, ?string $sourceRecordType): string
{
if ($sourceKind === ArtifactSourceTaxonomy::SOURCE_KIND_OPERATION_ROLLUP || $sourceRecordType === OperationRun::class || $sourceRecordType === 'operation_run') {
return ArtifactSourceTaxonomy::SOURCE_TARGET_OPERATION_RUN;
}
if ($sourceRecordType === ProviderConnection::class || $sourceRecordType === 'provider_connection') {
return ArtifactSourceTaxonomy::SOURCE_TARGET_PROVIDER_CONNECTION;
}
return ArtifactSourceTaxonomy::SOURCE_TARGET_MANAGED_ENVIRONMENT;
}
private function sourceTargetIdentifier(string $sourceTargetKind, ?string $sourceRecordId, int $managedEnvironmentId): ?string
{
return match ($sourceTargetKind) {
ArtifactSourceTaxonomy::SOURCE_TARGET_OPERATION_RUN,
ArtifactSourceTaxonomy::SOURCE_TARGET_PROVIDER_CONNECTION,
ArtifactSourceTaxonomy::SOURCE_TARGET_GOVERNED_SUBJECT => $sourceRecordId,
default => (string) $managedEnvironmentId,
};
}
/**
* @param array<string, mixed> $payload
*/
private function providerKey(array $payload): string
{
return $this->firstString([
Arr::get($payload, 'source_descriptor.provider_key'),
Arr::get($payload, 'provider_key'),
Arr::get($payload, 'provider'),
]) ?? 'microsoft';
}
/**
* @param array<string, mixed> $payload
*/
private function providerConnectionId(?ManagedEnvironment $tenant, array $payload): ?int
{
$value = Arr::get($payload, 'source_descriptor.provider_connection_id')
?? Arr::get($payload, 'provider_connection_id')
?? Arr::get($payload, 'providerConnectionId')
?? Arr::get($payload, 'provider_connection.id');
if (is_numeric($value)) {
return (int) $value;
}
if (! $tenant instanceof ManagedEnvironment || ! $tenant->exists) {
return null;
}
$connectionId = $tenant->providerConnections()
->where('provider', $this->providerKey($payload))
->where('is_default', true)
->value('id');
return is_numeric($connectionId) ? (int) $connectionId : null;
}
/**
* @param list<mixed> $values
*/
private function firstString(array $values): ?string
{
foreach ($values as $value) {
$string = $this->nullableString($value);
if ($string !== null) {
return $string;
}
}
return null;
}
private function nullableString(mixed $value): ?string
{
if ($value instanceof Model && $value->getKey() !== null) {
return (string) $value->getKey();
}
if (! is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}

View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Support\Artifacts;
use Illuminate\Support\Str;
final class ArtifactSourceTaxonomy
{
public const string SOURCE_FAMILY_FINDING = 'finding';
public const string SOURCE_FAMILY_STORED_REPORT = 'stored_report';
public const string SOURCE_FAMILY_EVIDENCE_SNAPSHOT = 'evidence_snapshot';
public const string SOURCE_FAMILY_INVENTORY = 'inventory';
public const string SOURCE_FAMILY_OPERATION_RUN = 'operation_run';
public const string SOURCE_KIND_MODEL_SUMMARY = 'model_summary';
public const string SOURCE_KIND_STORED_REPORT = 'stored_report';
public const string SOURCE_KIND_OPERATION_ROLLUP = 'operation_rollup';
public const string SOURCE_KIND_INVENTORY_PROJECTION = 'inventory_projection';
public const string SOURCE_TARGET_MANAGED_ENVIRONMENT = 'managed_environment';
public const string SOURCE_TARGET_GOVERNED_SUBJECT = 'governed_subject';
public const string SOURCE_TARGET_PROVIDER_CONNECTION = 'provider_connection';
public const string SOURCE_TARGET_OPERATION_RUN = 'operation_run';
/**
* @return list<string>
*/
public static function sourceFamilies(): array
{
return [
self::SOURCE_FAMILY_FINDING,
self::SOURCE_FAMILY_STORED_REPORT,
self::SOURCE_FAMILY_EVIDENCE_SNAPSHOT,
self::SOURCE_FAMILY_INVENTORY,
self::SOURCE_FAMILY_OPERATION_RUN,
];
}
/**
* @return list<string>
*/
public static function sourceKinds(): array
{
return [
self::SOURCE_KIND_MODEL_SUMMARY,
self::SOURCE_KIND_STORED_REPORT,
self::SOURCE_KIND_OPERATION_ROLLUP,
self::SOURCE_KIND_INVENTORY_PROJECTION,
];
}
/**
* @return list<string>
*/
public static function sourceTargetKinds(): array
{
return [
self::SOURCE_TARGET_MANAGED_ENVIRONMENT,
self::SOURCE_TARGET_GOVERNED_SUBJECT,
self::SOURCE_TARGET_PROVIDER_CONNECTION,
self::SOURCE_TARGET_OPERATION_RUN,
];
}
public static function hasDetectorCatalog(): bool
{
return false;
}
public static function isSourceFamily(string $value): bool
{
return in_array($value, self::sourceFamilies(), true);
}
public static function isSourceKind(string $value): bool
{
return in_array($value, self::sourceKinds(), true);
}
public static function isSourceTargetKind(string $value): bool
{
return in_array($value, self::sourceTargetKinds(), true);
}
public static function label(?string $value): string
{
return filled($value) ? Str::headline((string) $value) : 'Unknown';
}
}

View File

@ -6,6 +6,7 @@
use App\Support\Baselines\ResolutionPath; use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass; use App\Support\Baselines\SubjectClass;
use Illuminate\Support\Str;
class InventoryPolicyTypeMeta class InventoryPolicyTypeMeta
{ {
@ -96,6 +97,88 @@ public static function category(?string $type): ?string
return is_string($category) ? $category : null; return is_string($category) ? $category : null;
} }
/**
* @param array<string, mixed> $metadata
* @return array{
* canonical_type: string,
* provider_object_type: string,
* provider_display_type: string,
* legacy_policy_type: ?string
* }
*/
public static function typeDescriptorFor(?string $type, array $metadata = []): array
{
$providerObjectType = static::providerObjectType($type, $metadata);
$legacyPolicyType = filled($type) ? (string) $type : null;
$providerDisplayType = static::label($type)
?? static::metadataString($metadata, ['provider_display_type', 'display_type', 'type_label'])
?? Str::headline($providerObjectType);
return [
'canonical_type' => static::canonicalTypeFor($type, $metadata),
'provider_object_type' => $providerObjectType,
'provider_display_type' => $providerDisplayType,
'legacy_policy_type' => $legacyPolicyType,
];
}
private static function canonicalTypeFor(?string $type, array $metadata): string
{
$configured = static::metadataString(static::metaFor($type), ['canonical_type'])
?? static::metadataString($metadata, ['canonical_type']);
if ($configured !== null) {
return $configured;
}
$category = Str::lower((string) (static::category($type) ?? ''));
return match (true) {
! filled($type) => 'unknown_artifact',
static::isFoundation($type) => 'foundation_artifact',
str_contains($category, 'compliance') => 'endpoint_compliance_policy',
str_contains($category, 'endpoint security') => 'endpoint_security_policy',
str_contains($category, 'conditional access') => 'identity_access_policy',
str_contains($category, 'apps') || str_contains($category, 'applications') => 'application_management_artifact',
str_contains($category, 'script') => 'endpoint_script',
str_contains($category, 'update') => 'endpoint_update_policy',
str_contains($category, 'enrollment') => 'endpoint_enrollment_policy',
str_contains($category, 'configuration') => 'endpoint_configuration_policy',
default => 'governed_configuration_artifact',
};
}
/**
* @param array<string, mixed> $metadata
*/
private static function providerObjectType(?string $type, array $metadata): string
{
return static::metadataString($metadata, [
'provider_object_type',
'@odata.type',
'odata_type',
'graph_type',
'resource_type',
]) ?? (filled($type) ? (string) $type : 'unknown');
}
/**
* @param array<string, mixed> $metadata
* @param list<string> $keys
*/
private static function metadataString(array $metadata, array $keys): ?string
{
foreach ($keys as $key) {
$value = $metadata[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
return null;
}
public static function isFoundation(?string $type): bool public static function isFoundation(?string $type): bool
{ {
if (! filled($type)) { if (! filled($type)) {

View File

@ -3,6 +3,7 @@
$state = is_array($state) ? $state : []; $state = is_array($state) ? $state : [];
$summary = is_string($state['summary'] ?? null) ? $state['summary'] : null; $summary = is_string($state['summary'] ?? null) ? $state['summary'] : null;
$artifactSources = is_array($state['artifact_sources'] ?? null) ? $state['artifact_sources'] : [];
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : []; $highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
$items = is_array($state['items'] ?? null) ? $state['items'] : []; $items = is_array($state['items'] ?? null) ? $state['items'] : [];
@endphp @endphp
@ -12,6 +13,36 @@
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $summary }}</div> <div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $summary }}</div>
@endif @endif
@if ($artifactSources !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Artifact source</div>
<dl class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
@foreach ($artifactSources as $source)
@continue(! is_array($source))
@foreach ([
'Source family' => $source['source_family'] ?? null,
'Source kind' => $source['source_kind'] ?? null,
'Source target' => $source['source_target_kind'] ?? null,
'Control' => $source['control_key'] ?? null,
'Detector' => $source['detector_key'] ?? null,
] as $label => $value)
@php
$value = is_string($value) && trim($value) !== '' ? \Illuminate\Support\Str::headline($value) : null;
@endphp
@continue($value === null)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $label }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $value }}</dd>
</div>
@endforeach
@endforeach
</dl>
</div>
@endif
@if ($highlights !== []) @if ($highlights !== [])
<dl class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"> <dl class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
@foreach ($highlights as $highlight) @foreach ($highlights as $highlight)

View File

@ -3,6 +3,7 @@
$state = is_array($state) ? $state : []; $state = is_array($state) ? $state : [];
$summary = is_array($state['summary'] ?? null) ? $state['summary'] : []; $summary = is_array($state['summary'] ?? null) ? $state['summary'] : [];
$artifactSources = is_array($state['artifact_sources'] ?? null) ? $state['artifact_sources'] : [];
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : []; $highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
$entries = is_array($state['entries'] ?? null) ? $state['entries'] : []; $entries = is_array($state['entries'] ?? null) ? $state['entries'] : [];
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : []; $nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
@ -13,6 +14,36 @@
@endphp @endphp
<div class="space-y-3"> <div class="space-y-3">
@if ($artifactSources !== [])
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Artifact source</div>
<dl class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
@foreach ($artifactSources as $source)
@continue(! is_array($source))
@foreach ([
'Source family' => $source['source_family'] ?? null,
'Source kind' => $source['source_kind'] ?? null,
'Source target' => $source['source_target_kind'] ?? null,
'Control' => $source['control_key'] ?? null,
'Detector' => $source['detector_key'] ?? null,
] as $label => $value)
@php
$value = is_string($value) && trim($value) !== '' ? \Illuminate\Support\Str::headline($value) : null;
@endphp
@continue($value === null)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $label }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $value }}</dd>
</div>
@endforeach
@endforeach
</dl>
</div>
@endif
@if ($summary !== []) @if ($summary !== [])
<dl class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"> <dl class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
@foreach ($summary as $item) @foreach ($summary as $item)

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\StoredReportResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\StoredReport;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->browser()->timeout(30_000);
it('smokes descriptor-first artifact source surfaces in the Filament shell', function (): void {
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: true);
$finding = Finding::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => 'spec-284-policy',
'evidence_jsonb' => ['policy_type' => 'deviceCompliancePolicy'],
]);
$inventory = InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Spec 284 Compliance Inventory',
]);
$report = StoredReport::factory()->permissionPosture()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = seedTenantReviewEvidence($tenant, findingCount: 1, driftCount: 1);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
],
]);
visit(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
->waitForText('Artifact source')
->assertSee('Source family')
->assertSee('Provider finding type')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(InventoryItemResource::getUrl('view', ['record' => $inventory], tenant: $tenant))
->waitForText('Artifact source')
->assertSee('Canonical type')
->assertSee('Provider display type')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(StoredReportResource::getUrl('view', ['record' => $report], tenant: $tenant))
->waitForText('Artifact source')
->assertSee('Provider report type')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
->waitForText('Evidence dimensions')
->assertSee('Source family')
->assertSee('Provider source detail')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(TenantReviewResource::getUrl('view', ['record' => $review], tenant: $tenant))
->waitForText('Sections')
->click('Details')
->waitForText('Artifact source')
->assertSee('Artifact source')
->assertSee('Source family')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\StoredReport;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Evidence\EvidenceSnapshotStatus;
it('carries artifact source descriptors through evidence snapshot payloads and items', function (): void {
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: true);
$connection = $tenant->providerConnections()->where('provider', 'microsoft')->where('is_default', true)->firstOrFail();
StoredReport::factory()->permissionPosture()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'fingerprint' => 'permission-report-fingerprint',
'payload' => [
'provider_key' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
'posture_score' => 90,
'required_count' => 4,
'granted_count' => 4,
],
]);
Finding::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'evidence_jsonb' => ['policy_type' => 'deviceCompliancePolicy'],
]);
$payload = app(EvidenceSnapshotService::class)->buildSnapshotPayload($tenant);
$permissionItem = collect($payload['items'])->firstWhere('dimension_key', 'permission_posture');
expect($permissionItem['source_descriptor'])->toMatchArray([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'managed_environment_id' => (int) $tenant->getKey(),
'source_family' => 'stored_report',
'source_kind' => 'stored_report',
'provider_key' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
'source_target_kind' => 'managed_environment',
'source_target_identifier' => (string) $tenant->getKey(),
'control_key' => 'strong_authentication',
'package_run_id' => null,
])
->and($permissionItem['summary_payload']['source_descriptor'])->toMatchArray($permissionItem['source_descriptor'])
->and($payload['summary']['dimensions'])->each->toHaveKey('source_descriptor');
$snapshot = EvidenceSnapshot::query()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'fingerprint' => $payload['fingerprint'],
'completeness_state' => $payload['completeness'],
'summary' => $payload['summary'],
'generated_at' => now(),
]);
foreach ($payload['items'] as $item) {
$snapshot->items()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'dimension_key' => $item['dimension_key'],
'state' => $item['state'],
'required' => $item['required'],
'source_kind' => $item['source_kind'],
'source_record_type' => $item['source_record_type'],
'source_record_id' => $item['source_record_id'],
'source_fingerprint' => $item['source_fingerprint'],
'measured_at' => $item['measured_at'],
'freshness_at' => $item['freshness_at'],
'summary_payload' => $item['summary_payload'],
'sort_order' => $item['sort_order'],
]);
}
$persistedPermissionItem = $snapshot->items()->where('dimension_key', 'permission_posture')->firstOrFail();
expect($persistedPermissionItem->artifactSourceDescriptor()->toArray())
->toMatchArray($permissionItem['source_descriptor']);
$this->actingAs($user);
});

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Services\Evidence\Sources\FindingsSummarySource;
it('derives descriptor-first source taxonomy for findings and summaries', function (): void {
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: true);
$connection = $tenant->providerConnections()->where('provider', 'microsoft')->where('is_default', true)->firstOrFail();
$finding = Finding::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'subject_external_id' => 'device-compliance-policy-1',
'evidence_jsonb' => [
'provider_key' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
'policy_type' => 'deviceCompliancePolicy',
'policy_id' => 'device-compliance-policy-1',
],
]);
$descriptor = $finding->artifactSourceDescriptor()->toArray();
$providerDetail = $finding->artifactProviderDetail()->toArray();
expect($descriptor)->toMatchArray([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'managed_environment_id' => (int) $tenant->getKey(),
'source_family' => 'finding',
'source_kind' => 'model_summary',
'provider_key' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
'source_target_kind' => 'governed_subject',
'source_target_identifier' => 'device-compliance-policy-1',
'detector_key' => 'intune.device_compliance_policy',
'control_key' => 'endpoint_hardening_compliance',
'package_run_id' => null,
])
->and($descriptor)->not->toHaveKeys(['finding_type', 'policy_type', 'report_type'])
->and($providerDetail)->toMatchArray([
'legacy_finding_type' => Finding::FINDING_TYPE_DRIFT,
'legacy_policy_type' => 'deviceCompliancePolicy',
'provider_object_type' => 'deviceCompliancePolicy',
'provider_display_type' => 'Device Compliance',
]);
$summary = app(FindingsSummarySource::class)->collect($tenant);
$entry = collect($summary['summary_payload']['entries'])->firstWhere('id', (int) $finding->getKey());
expect($entry['source_descriptor'])->toMatchArray($descriptor)
->and($entry['provider_detail'])->toMatchArray($providerDetail)
->and($entry['control_key'])->toBe('endpoint_hardening_compliance');
$this->actingAs($user);
});

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Models\InventoryItem;
it('derives inventory type split and shared artifact source descriptor', function (): void {
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: true);
$connection = $tenant->providerConnections()->where('provider', 'microsoft')->where('is_default', true)->firstOrFail();
$item = InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'policy_type' => 'deviceCompliancePolicy',
'external_id' => 'compliance-policy-1',
'display_name' => 'Compliance Policy One',
'meta_jsonb' => [
'provider_key' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
],
]);
expect($item->inventoryTypeDescriptor())->toMatchArray([
'canonical_type' => 'endpoint_compliance_policy',
'provider_object_type' => 'deviceCompliancePolicy',
'provider_display_type' => 'Device Compliance',
'legacy_policy_type' => 'deviceCompliancePolicy',
])
->and($item->artifactSourceDescriptor()->toArray())->toMatchArray([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'managed_environment_id' => (int) $tenant->getKey(),
'source_family' => 'inventory',
'source_kind' => 'inventory_projection',
'provider_key' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
'source_target_kind' => 'governed_subject',
'source_target_identifier' => 'compliance-policy-1',
'detector_key' => 'inventory.deviceCompliancePolicy',
'control_key' => 'endpoint_hardening_compliance',
'package_run_id' => null,
]);
$this->actingAs($user);
});

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use App\Models\StoredReport;
it('derives stored report descriptor and keeps report_type as provider detail', function (): void {
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: true);
$connection = $tenant->providerConnections()->where('provider', 'microsoft')->where('is_default', true)->firstOrFail();
$report = StoredReport::factory()->entraAdminRoles([
'provider_key' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
])->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$descriptor = $report->artifactSourceDescriptor()->toArray();
expect($descriptor)->toMatchArray([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'managed_environment_id' => (int) $tenant->getKey(),
'source_family' => 'stored_report',
'source_kind' => 'stored_report',
'provider_key' => 'microsoft',
'provider_connection_id' => (int) $connection->getKey(),
'source_target_kind' => 'managed_environment',
'detector_key' => 'entra_admin_roles.privileged_role_assignment',
'control_key' => 'privileged_access_governance',
'package_run_id' => null,
])
->and($descriptor)->not->toHaveKey('report_type')
->and($report->artifactProviderDetail()->toArray())->toMatchArray([
'legacy_report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
'provider_object_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
]);
$this->actingAs($user);
});

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\StoredReportResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\ManagedEnvironment;
use App\Models\StoredReport;
use App\Support\Workspaces\WorkspaceContext;
it('renders descriptor-first artifact source sections before provider details', function (): void {
[$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: true);
$connection = $tenant->providerConnections()->where('provider', 'microsoft')->where('is_default', true)->firstOrFail();
$finding = Finding::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => 'compliance-policy-1',
'evidence_jsonb' => [
'provider_connection_id' => (int) $connection->getKey(),
'policy_type' => 'deviceCompliancePolicy',
'policy_id' => 'compliance-policy-1',
],
]);
$inventory = InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Compliance Inventory',
]);
$report = StoredReport::factory()->permissionPosture([
'provider_connection_id' => (int) $connection->getKey(),
])->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = seedTenantReviewEvidence($tenant, findingCount: 1, driftCount: 1);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$this->actingAs($user)
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
->assertOk()
->assertSeeInOrder(['Artifact source', 'Source family', 'Finding', 'Provider object type', 'Finding'])
->assertSee('Provider finding type');
$this->actingAs($user)
->get(InventoryItemResource::getUrl('view', ['record' => $inventory], tenant: $tenant))
->assertOk()
->assertSeeInOrder(['Artifact source', 'Source family', 'Inventory', 'Inventory Item', 'Canonical type', 'Endpoint Compliance Policy'])
->assertSee('Provider display type')
->assertSee('Legacy policy type');
$this->actingAs($user)
->get(StoredReportResource::getUrl('view', ['record' => $report], tenant: $tenant))
->assertOk()
->assertSeeInOrder(['Outcome summary', 'Artifact source', 'Source family', 'Stored Report', 'Stored report', 'Provider report type'])
->assertSee('Permission posture summary');
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
->assertOk()
->assertSeeInOrder(['Evidence dimensions', 'Source family', 'Source kind', 'Source target'])
->assertSee('Artifact source')
->assertSee('Provider source detail');
$this->actingAs($user)
->get(TenantReviewResource::getUrl('view', ['record' => $review], tenant: $tenant))
->assertOk()
->assertSee('Artifact source')
->assertSee('Source family')
->assertSee('Source kind')
->assertSee('Source target');
});
it('preserves inherited tenant boundary and capability responses on descriptor-first surfaces', function (): void {
$tenant = ManagedEnvironment::factory()->create();
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$finding = Finding::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
[$outsider] = createUserWithTenant(role: 'owner');
$this->actingAs($outsider)
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
->assertNotFound();
$this->actingAs($owner)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
],
])
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
->assertOk()
->assertSee('Artifact source');
});

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\StoredReport;
use App\Support\Ai\AiUseCaseCatalog;
use App\Support\Artifacts\ArtifactSourceDescriptor;
use App\Support\Artifacts\ArtifactSourceTaxonomy;
it('keeps provider-native type names out of descriptor top-level truth', function (): void {
[$user, $tenant] = createUserWithTenant();
$finding = Finding::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'evidence_jsonb' => ['policy_type' => 'deviceCompliancePolicy'],
]);
$report = StoredReport::factory()->permissionPosture()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$inventory = InventoryItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'policy_type' => 'deviceCompliancePolicy',
]);
foreach ([$finding->artifactSourceDescriptor(), $report->artifactSourceDescriptor(), $inventory->artifactSourceDescriptor()] as $descriptor) {
$payload = $descriptor->toArray();
expect($payload)->not->toHaveKeys(['finding_type', 'report_type', 'policy_type'])
->and($payload['package_run_id'])->toBeNull();
}
expect($finding->artifactProviderDetail()->toArray()['legacy_policy_type'])->toBe('deviceCompliancePolicy')
->and($report->artifactProviderDetail()->toArray()['legacy_report_type'])->toBe(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
->and($inventory->artifactProviderDetail()->toArray()['legacy_policy_type'])->toBe('deviceCompliancePolicy');
$this->actingAs($user);
});
it('keeps package_run_id optional and does not recast AI source families as artifact runtime truth', function (): void {
$descriptor = ArtifactSourceDescriptor::fromArray([
'workspace_id' => 1,
'tenant_id' => 2,
'managed_environment_id' => 2,
'source_family' => 'finding',
'source_kind' => 'model_summary',
'provider_key' => 'microsoft',
'source_target_kind' => 'managed_environment',
'package_run_id' => 123,
]);
$aiSourceFamilies = collect(app(AiUseCaseCatalog::class)->all())->pluck('source_family');
expect($descriptor->packageRunId)->toBe(123)
->and($aiSourceFamilies->intersect(ArtifactSourceTaxonomy::sourceFamilies())->values()->all())->toBe([])
->and($aiSourceFamilies->all())->toContain('product_knowledge', 'support_diagnostics');
});

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Support\Artifacts\ArtifactSourceTaxonomy;
it('pins the provider-neutral artifact source inventories for spec 284', function (): void {
expect(ArtifactSourceTaxonomy::sourceFamilies())->toBe([
'finding',
'stored_report',
'evidence_snapshot',
'inventory',
'operation_run',
])
->and(ArtifactSourceTaxonomy::sourceKinds())->toBe([
'model_summary',
'stored_report',
'operation_rollup',
'inventory_projection',
])
->and(ArtifactSourceTaxonomy::sourceTargetKinds())->toBe([
'managed_environment',
'governed_subject',
'provider_connection',
'operation_run',
]);
});
it('does not introduce a detector catalog in spec 284', function (): void {
expect(ArtifactSourceTaxonomy::hasDetectorCatalog())->toBeFalse()
->and(method_exists(ArtifactSourceTaxonomy::class, 'detectorKeys'))->toBeFalse();
});

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Support\Inventory\InventoryPolicyTypeMeta;
it('separates canonical inventory type from provider object detail', function (): void {
$descriptor = InventoryPolicyTypeMeta::typeDescriptorFor('deviceCompliancePolicy', [
'odata_type' => '#microsoft.graph.deviceCompliancePolicy',
]);
expect($descriptor)->toMatchArray([
'canonical_type' => 'endpoint_compliance_policy',
'provider_object_type' => '#microsoft.graph.deviceCompliancePolicy',
'provider_display_type' => 'Device Compliance',
'legacy_policy_type' => 'deviceCompliancePolicy',
])
->and($descriptor['canonical_type'])->not->toBe($descriptor['legacy_policy_type']);
});
it('keeps unknown provider types as provider detail without inventing platform truth', function (): void {
$descriptor = InventoryPolicyTypeMeta::typeDescriptorFor('futureProviderObject');
expect($descriptor)->toMatchArray([
'canonical_type' => 'governed_configuration_artifact',
'provider_object_type' => 'futureProviderObject',
'provider_display_type' => 'Future Provider Object',
'legacy_policy_type' => 'futureProviderObject',
]);
});

View File

@ -0,0 +1,106 @@
# Specification Quality Checklist: Provider-neutral Artifact Source Taxonomy
**Purpose**: Validate package completeness, boundedness, and readiness before implementation
**Created**: 2026-05-08
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] The package stays on reserved slot `284` and does not silently absorb work from Specs `285` through `287`.
- [x] The package explicitly documents one shared artifact-source descriptor over existing persisted truth and does not introduce a new artifact table or ledger.
- [x] The package pins the exact `source_family`, `source_kind`, and `source_target_kind` inventories instead of leaving them implicit.
- [x] The package makes the inventory `canonical_type` / `provider_object_type` / `provider_display_type` split explicit.
- [x] The package explicitly rejects a detector catalog, package runtime, control-catalog expansion, and historical backfill.
- [x] `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, and the logical contract all describe the same bounded slice.
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain in `spec.md`, `plan.md`, `research.md`, `data-model.md`, or `quickstart.md`.
- [x] Requirements remain testable and bounded to current artifact families and current operator surfaces.
- [x] The descriptor fields, inventory split, and no-backfill posture are explicit across the package.
- [x] The exact `source_family`, `source_kind`, and `source_target_kind` inventories are pinned identically across `spec.md`, `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `tasks.md`, the logical contract, and this checklist.
- [x] `package_run_id` is explicitly optional and does not imply package runtime in the current release.
- [x] Reviewer flow and proof guidance explicitly cover `workspace_id`, `tenant_id`, `managed_environment_id`, `provider_connection_id`, and `source_target_identifier` semantics when available.
- [x] Scope boundaries, assumptions, risks, and deferred adjacent candidates remain explicit.
## Repo Truth Anchoring
- [x] The package reflects that `Finding` already persists `finding_type`, optional `source`, and `evidence_jsonb`.
- [x] The package reflects that `EvidenceSourceProvider` and `EvidenceSnapshotItem` currently stop at `source_kind`, raw record type, and raw record id.
- [x] The package reflects that `StoredReport` already persists `report_type` while current report producers already place `provider_key` inside payload.
- [x] The package reflects that `InventoryItem` and `InventoryPolicyTypeMeta` still rely on `policy_type` as current inventory artifact detail.
- [x] The package reflects that `FindingsSummarySource` currently hardcodes Microsoft-facing detector and workload logic while still resolving canonical controls.
- [x] The package reflects that current support or AI governed bundles already use `source_family` as a naming precedent without yet solving artifact lineage.
## Feature Readiness
- [x] Filament v5 and Livewire v4 expectations remain explicit across the package.
- [x] Provider registration location remains explicit as `apps/platform/bootstrap/providers.php`.
- [x] Global-search posture remains explicit for `FindingResource`, `InventoryItemResource`, `EvidenceSnapshotResource`, `StoredReportResource`, and `TenantReviewResource`.
- [x] The package explicitly states that no new destructive action is introduced and that existing action confirmation or authorization rules remain unchanged.
- [x] The unchanged asset strategy remains explicit.
- [x] Specs `281`, `282`, and `283` are recorded as already-present prerequisites in current repo truth.
## Artifact Alignment
- [x] `research.md` records the same bounded descriptor decisions reflected in `plan.md`.
- [x] `data-model.md` models the same descriptor fields, pinned inventories, inventory type split, and legacy-read rules reflected in the spec and plan.
- [x] `quickstart.md` restates the same reviewer flow, pinned inventories, and proof commands used by `spec.md` and `plan.md`.
- [x] `contracts/provider-neutral-artifact-source-taxonomy.logical.openapi.yaml` models the same logical GET surfaces and descriptor-first view models described in the plan.
- [x] Canonical proof commands are pinned identically across `spec.md`, `plan.md`, `quickstart.md`, and `tasks.md`, and this checklist explicitly references that pinned command set.
## Pinned Inventories
- `source_family`: `finding`, `stored_report`, `evidence_snapshot`, `inventory`, `operation_run`
- `source_kind`: `model_summary`, `stored_report`, `operation_rollup`, `inventory_projection`
- `source_target_kind`: `managed_environment`, `governed_subject`, `provider_connection`, `operation_run`
## Pinned Proof Commands
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \
(cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact \
tests/Unit/Artifacts/ArtifactSourceTaxonomyCatalogTest.php \
tests/Unit/Inventory/InventoryCanonicalTypeDescriptorTest.php)
```
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \
(cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact \
tests/Feature/Artifacts/FindingArtifactSourceTaxonomyTest.php \
tests/Feature/Artifacts/EvidenceSnapshotSourceTaxonomyTest.php \
tests/Feature/Artifacts/StoredReportSourceTaxonomyTest.php \
tests/Feature/Artifacts/InventoryArtifactTypeTaxonomyTest.php \
tests/Feature/Filament/Artifacts/ArtifactSourceTaxonomySurfaceTest.php \
tests/Feature/Guards/ArtifactSourceProviderTruthGuardTest.php)
```
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \
(cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact \
tests/Browser/Spec284ArtifactSourceTaxonomySmokeTest.php)
```
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \
(cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)
```
## Test Governance
- [x] Planned proof stays bounded to focused unit tests, feature tests, one guard test, and one browser smoke.
- [x] No new heavy-governance family or broad browser matrix is introduced.
- [x] Workspace, managed-environment, finding, evidence, stored-report, review, and inventory fixture cost is acknowledged instead of hidden.
- [x] Reviewer handoff includes exact minimal validation commands and concrete stop questions.
## Notes
- Reviewed against `.specify/memory/constitution.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/279-workspace-managed-environment-core/spec.md`, `specs/281-provider-connection-scope/spec.md`, `specs/282-governance-artifact-retargeting/spec.md`, `specs/283-provider-capability-registry/spec.md`, `apps/platform/app/Models/Finding.php`, `apps/platform/app/Models/EvidenceSnapshotItem.php`, `apps/platform/app/Models/StoredReport.php`, `apps/platform/app/Models/InventoryItem.php`, `apps/platform/app/Services/Evidence/Contracts/EvidenceSourceProvider.php`, `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, `apps/platform/app/Services/Evidence/Sources/PermissionPostureSource.php`, `apps/platform/app/Services/Evidence/Sources/EntraAdminRolesSource.php`, `apps/platform/app/Services/Evidence/Sources/BaselineDriftPostureSource.php`, `apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesReportService.php`, `apps/platform/app/Support/Inventory/InventoryPolicyTypeMeta.php`, `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`, `apps/platform/app/Support/Ai/AiUseCaseCatalog.php`, `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, `apps/platform/app/Filament/Resources/InventoryItemResource.php`, `apps/platform/app/Filament/Resources/StoredReportResource.php`, and `apps/platform/app/Filament/Resources/TenantReviewResource.php` on 2026-05-08.
- No application implementation, test execution, or runtime validation was performed while preparing this package.
## Review Outcome
- **Outcome class**: `implementation-ready`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Reason**: The prior SCOPE-001 implementation block is resolved by current repo truth and Spec `279`'s approved managed-environment core exception. Runtime work may proceed because the slice still derives provider-neutral artifact lineage and inventory type semantics from already scoped artifact records without adding a new artifact table, descriptor columns, detector catalog, backfill work, ownership plane, or adjacent package-runtime scope.

View File

@ -0,0 +1,619 @@
openapi: 3.1.0
info:
title: Provider-neutral Artifact Source Taxonomy Logical Contract
version: 0.1.0
description: >-
Logical GET surfaces and descriptor-first view models for the bounded 284
artifact-source taxonomy slice.
paths:
/logical/findings:
get:
summary: List findings with descriptor-first artifact summaries
operationId: listFindingsWithArtifactSourceDescriptor
responses:
'200':
description: Finding list page view model
content:
application/json:
schema:
$ref: '#/components/schemas/FindingListPage'
'403':
$ref: '#/components/responses/ForbiddenResponse'
'404':
$ref: '#/components/responses/NotFoundResponse'
/logical/findings/{findingId}:
get:
summary: View one finding with descriptor-first artifact summary
operationId: viewFindingWithArtifactSourceDescriptor
parameters:
- name: findingId
in: path
required: true
schema:
type: integer
responses:
'200':
description: Finding detail view model
content:
application/json:
schema:
$ref: '#/components/schemas/FindingPage'
'403':
$ref: '#/components/responses/ForbiddenResponse'
'404':
$ref: '#/components/responses/NotFoundResponse'
/logical/evidence-snapshots:
get:
summary: List evidence snapshots with descriptor-first summaries
operationId: listEvidenceSnapshotsWithArtifactSourceDescriptor
responses:
'200':
description: Evidence snapshot list page view model
content:
application/json:
schema:
$ref: '#/components/schemas/EvidenceSnapshotListPage'
'403':
$ref: '#/components/responses/ForbiddenResponse'
'404':
$ref: '#/components/responses/NotFoundResponse'
/logical/evidence-snapshots/{snapshotId}:
get:
summary: View one evidence snapshot with descriptor-first item summaries
operationId: viewEvidenceSnapshotWithArtifactSourceDescriptor
parameters:
- name: snapshotId
in: path
required: true
schema:
type: integer
responses:
'200':
description: Evidence snapshot detail view model
content:
application/json:
schema:
$ref: '#/components/schemas/EvidenceSnapshotPage'
'403':
$ref: '#/components/responses/ForbiddenResponse'
'404':
$ref: '#/components/responses/NotFoundResponse'
/logical/stored-reports:
get:
summary: List stored reports with descriptor-first summaries
operationId: listStoredReportsWithArtifactSourceDescriptor
responses:
'200':
description: Stored report list page view model
content:
application/json:
schema:
$ref: '#/components/schemas/StoredReportListPage'
'403':
$ref: '#/components/responses/ForbiddenResponse'
'404':
$ref: '#/components/responses/NotFoundResponse'
/logical/stored-reports/{reportId}:
get:
summary: View one stored report with descriptor-first summary
operationId: viewStoredReportWithArtifactSourceDescriptor
parameters:
- name: reportId
in: path
required: true
schema:
type: integer
responses:
'200':
description: Stored report detail view model
content:
application/json:
schema:
$ref: '#/components/schemas/StoredReportPage'
'403':
$ref: '#/components/responses/ForbiddenResponse'
'404':
$ref: '#/components/responses/NotFoundResponse'
/logical/inventory-items:
get:
summary: List inventory items with descriptor-first summaries
operationId: listInventoryItemsWithArtifactSourceDescriptor
responses:
'200':
description: Inventory item list page view model
content:
application/json:
schema:
$ref: '#/components/schemas/InventoryItemListPage'
'403':
$ref: '#/components/responses/ForbiddenResponse'
'404':
$ref: '#/components/responses/NotFoundResponse'
/logical/inventory-items/{inventoryItemId}:
get:
summary: View one inventory item with canonical and provider type split
operationId: viewInventoryItemWithArtifactSourceDescriptor
parameters:
- name: inventoryItemId
in: path
required: true
schema:
type: integer
responses:
'200':
description: Inventory item detail view model
content:
application/json:
schema:
$ref: '#/components/schemas/InventoryItemPage'
'403':
$ref: '#/components/responses/ForbiddenResponse'
'404':
$ref: '#/components/responses/NotFoundResponse'
/logical/tenant-reviews:
get:
summary: List tenant reviews with primary descriptor summaries
operationId: listTenantReviewsWithArtifactSourceDescriptor
responses:
'200':
description: Tenant review list page view model
content:
application/json:
schema:
$ref: '#/components/schemas/TenantReviewListPage'
'403':
$ref: '#/components/responses/ForbiddenResponse'
'404':
$ref: '#/components/responses/NotFoundResponse'
/logical/tenant-reviews/{reviewId}:
get:
summary: View one tenant review with descriptor-first artifact sections
operationId: viewTenantReviewWithArtifactSourceSections
parameters:
- name: reviewId
in: path
required: true
schema:
type: integer
responses:
'200':
description: Tenant review detail view model
content:
application/json:
schema:
$ref: '#/components/schemas/TenantReviewPage'
'403':
$ref: '#/components/responses/ForbiddenResponse'
'404':
$ref: '#/components/responses/NotFoundResponse'
components:
responses:
ForbiddenResponse:
description: In-scope actor missing the required capability for the surface.
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemResponse'
NotFoundResponse:
description: Workspace or managed-environment context is missing or out of scope for the actor.
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemResponse'
schemas:
ProblemResponse:
type: object
required:
- message
properties:
message:
type: string
code:
type:
- string
- 'null'
ArtifactSourceDescriptor:
type: object
required:
- workspace_id
- tenant_id
- source_family
- source_kind
- provider_key
- managed_environment_id
- source_target_kind
properties:
workspace_id:
type: integer
description: Derived workspace scope anchor for the artifact.
tenant_id:
type: integer
description: Derived tenant scope anchor for the artifact.
source_family:
type: string
enum:
- finding
- stored_report
- evidence_snapshot
- inventory
- operation_run
source_kind:
type: string
enum:
- model_summary
- stored_report
- operation_rollup
- inventory_projection
provider_key:
type: string
description: Current repo truth emits `microsoft` only.
provider_connection_id:
type:
- integer
- 'null'
managed_environment_id:
type: integer
description: Required managed-environment anchor inside the derived workspace and tenant scope.
source_target_kind:
type: string
enum:
- managed_environment
- governed_subject
- provider_connection
- operation_run
source_target_identifier:
type:
- string
- 'null'
detector_key:
type:
- string
- 'null'
description: >-
Standardized field only. 284 does not define a closed detector
catalog.
control_key:
type:
- string
- 'null'
package_run_id:
type:
- integer
- 'null'
description: Optional future package hook. Remains null in current runtime.
InventoryTypeDescriptor:
type: object
required:
- canonical_type
- provider_object_type
- provider_display_type
properties:
canonical_type:
type: string
provider_object_type:
type: string
provider_display_type:
type: string
legacy_policy_type:
type:
- string
- 'null'
ArtifactProviderDetail:
type: object
properties:
legacy_finding_type:
type:
- string
- 'null'
legacy_report_type:
type:
- string
- 'null'
legacy_policy_type:
type:
- string
- 'null'
provider_object_type:
type:
- string
- 'null'
provider_display_type:
type:
- string
- 'null'
detector_detail:
type:
- string
- 'null'
CanonicalControlSummary:
type: object
required:
- control_key
properties:
control_key:
type: string
label:
type:
- string
- 'null'
status:
type:
- string
- 'null'
FreshnessSummary:
type: object
additionalProperties: true
description: Existing freshness or timing metadata carried through descriptor-first summaries.
FindingArtifactSummary:
type: object
required:
- id
- title
- severity
- status
- source_descriptor
properties:
id:
type: integer
title:
type: string
severity:
type: string
status:
type: string
source_descriptor:
$ref: '#/components/schemas/ArtifactSourceDescriptor'
provider_detail:
$ref: '#/components/schemas/ArtifactProviderDetail'
control_summary:
oneOf:
- $ref: '#/components/schemas/CanonicalControlSummary'
- type: 'null'
freshness:
oneOf:
- $ref: '#/components/schemas/FreshnessSummary'
- type: 'null'
FindingListPage:
type: object
required:
- items
properties:
items:
type: array
items:
$ref: '#/components/schemas/FindingArtifactSummary'
FindingPage:
type: object
required:
- finding
properties:
finding:
$ref: '#/components/schemas/FindingArtifactSummary'
EvidenceSnapshotListEntry:
type: object
required:
- id
- managed_environment_id
- source_descriptor
properties:
id:
type: integer
managed_environment_id:
type: integer
source_descriptor:
$ref: '#/components/schemas/ArtifactSourceDescriptor'
control_summary:
oneOf:
- $ref: '#/components/schemas/CanonicalControlSummary'
- type: 'null'
freshness:
oneOf:
- $ref: '#/components/schemas/FreshnessSummary'
- type: 'null'
EvidenceSnapshotListPage:
type: object
required:
- items
properties:
items:
type: array
items:
$ref: '#/components/schemas/EvidenceSnapshotListEntry'
EvidenceSnapshotItemViewModel:
type: object
required:
- dimension_key
- state
- source_descriptor
properties:
dimension_key:
type: string
state:
type: string
source_descriptor:
$ref: '#/components/schemas/ArtifactSourceDescriptor'
provider_detail:
$ref: '#/components/schemas/ArtifactProviderDetail'
control_summary:
oneOf:
- $ref: '#/components/schemas/CanonicalControlSummary'
- type: 'null'
freshness:
oneOf:
- $ref: '#/components/schemas/FreshnessSummary'
- type: 'null'
EvidenceSnapshotPage:
type: object
required:
- id
- managed_environment_id
- items
properties:
id:
type: integer
managed_environment_id:
type: integer
items:
type: array
items:
$ref: '#/components/schemas/EvidenceSnapshotItemViewModel'
freshness:
oneOf:
- $ref: '#/components/schemas/FreshnessSummary'
- type: 'null'
StoredReportPage:
type: object
required:
- id
- summary
- source_descriptor
properties:
id:
type: integer
summary:
type: string
source_descriptor:
$ref: '#/components/schemas/ArtifactSourceDescriptor'
provider_detail:
$ref: '#/components/schemas/ArtifactProviderDetail'
control_summary:
oneOf:
- $ref: '#/components/schemas/CanonicalControlSummary'
- type: 'null'
freshness:
oneOf:
- $ref: '#/components/schemas/FreshnessSummary'
- type: 'null'
StoredReportListEntry:
type: object
required:
- id
- summary
- source_descriptor
properties:
id:
type: integer
summary:
type: string
source_descriptor:
$ref: '#/components/schemas/ArtifactSourceDescriptor'
freshness:
oneOf:
- $ref: '#/components/schemas/FreshnessSummary'
- type: 'null'
StoredReportListPage:
type: object
required:
- items
properties:
items:
type: array
items:
$ref: '#/components/schemas/StoredReportListEntry'
InventoryItemPage:
type: object
required:
- id
- display_name
- type_descriptor
- source_descriptor
properties:
id:
type: integer
display_name:
type: string
type_descriptor:
$ref: '#/components/schemas/InventoryTypeDescriptor'
source_descriptor:
$ref: '#/components/schemas/ArtifactSourceDescriptor'
provider_detail:
$ref: '#/components/schemas/ArtifactProviderDetail'
InventoryItemListEntry:
type: object
required:
- id
- display_name
- type_descriptor
- source_descriptor
properties:
id:
type: integer
display_name:
type: string
type_descriptor:
$ref: '#/components/schemas/InventoryTypeDescriptor'
source_descriptor:
$ref: '#/components/schemas/ArtifactSourceDescriptor'
InventoryItemListPage:
type: object
required:
- items
properties:
items:
type: array
items:
$ref: '#/components/schemas/InventoryItemListEntry'
TenantReviewArtifactSection:
type: object
required:
- section_key
- headline
- source_descriptor
properties:
section_key:
type: string
headline:
type: string
source_descriptor:
$ref: '#/components/schemas/ArtifactSourceDescriptor'
provider_detail:
$ref: '#/components/schemas/ArtifactProviderDetail'
control_summary:
oneOf:
- $ref: '#/components/schemas/CanonicalControlSummary'
- type: 'null'
freshness:
oneOf:
- $ref: '#/components/schemas/FreshnessSummary'
- type: 'null'
TenantReviewListEntry:
type: object
required:
- id
- status
- source_descriptor
properties:
id:
type: integer
status:
type: string
source_descriptor:
$ref: '#/components/schemas/ArtifactSourceDescriptor'
freshness:
oneOf:
- $ref: '#/components/schemas/FreshnessSummary'
- type: 'null'
TenantReviewListPage:
type: object
required:
- items
properties:
items:
type: array
items:
$ref: '#/components/schemas/TenantReviewListEntry'
TenantReviewPage:
type: object
required:
- id
- status
- sections
properties:
id:
type: integer
status:
type: string
sections:
type: array
items:
$ref: '#/components/schemas/TenantReviewArtifactSection'

View File

@ -0,0 +1,179 @@
# Data Model: Provider-neutral Artifact Source Taxonomy
## Existing persisted truth reused
### Finding
Existing persisted finding fields already provide the raw inputs for a provider-neutral descriptor:
- `workspace_id`
- `managed_environment_id`
- `finding_type`
- optional `source`
- `title`
- `status`
- `severity`
- `evidence_jsonb`
`finding_type` and `source` remain persisted provider or artifact detail. `284` adds a shared descriptor over them rather than replacing them as raw evidence.
### EvidenceSnapshotItem
Existing evidence snapshot item fields already provide the current evidence-source seam:
- `workspace_id`
- `managed_environment_id`
- `dimension_key`
- `state`
- `required`
- `source_kind`
- `source_record_type`
- `source_record_id`
- `source_fingerprint`
- `measured_at`
- `freshness_at`
- `summary_payload`
- `sort_order`
`284` extends this seam by adding or deriving a provider-neutral descriptor so `source_record_type` stops acting as the only top-level source identity.
### StoredReport
Existing stored-report truth already includes:
- `workspace_id`
- `managed_environment_id`
- `report_type`
- `payload`
- `fingerprint`
- `previous_fingerprint`
Current report producers already write provider-owned fields such as `provider_key` into payload. `284` lifts the shared lineage fields into the common descriptor without deleting provider-owned detail.
### InventoryItem
Existing inventory truth already includes:
- `workspace_id`
- `managed_environment_id`
- `policy_type`
- `external_id`
- `platform`
- `display_name`
- `meta_jsonb`
- `last_seen_at`
- `last_seen_operation_run_id`
`policy_type` remains provider-owned or legacy artifact detail after `284`; it no longer stands alone as the platform's only artifact type label.
## Pinned initial descriptor inventories
### `source_family`
| Value | Meaning |
|---|---|
| `finding` | artifact lineage originates from a finding or finding-derived summary |
| `stored_report` | artifact lineage originates from a stored report |
| `evidence_snapshot` | artifact lineage is summarized inside an evidence snapshot item or evidence snapshot view model |
| `inventory` | artifact lineage originates from inventory capture or inventory projection |
| `operation_run` | artifact lineage originates from operation-run rollup evidence |
### `source_kind`
| Value | Meaning |
|---|---|
| `model_summary` | summary derived directly from one or more model records |
| `stored_report` | summary or artifact read directly from stored-report persistence |
| `operation_rollup` | summary derived from operation-run history |
| `inventory_projection` | summary derived from inventory read models |
### `source_target_kind`
| Value | Meaning |
|---|---|
| `managed_environment` | artifact summarizes environment-wide state |
| `governed_subject` | artifact describes one governed subject or provider object under the environment |
| `provider_connection` | artifact primarily describes provider-connection state |
| `operation_run` | artifact primarily describes one operation run |
## New derived contracts
### ArtifactSourceDescriptor
Represents the provider-neutral lineage envelope for a finding, evidence summary, stored report, inventory item, or touched review summary.
| Field | Type | Notes |
|---|---|---|
| `source_family` | string | One of the pinned values above |
| `source_kind` | string | One of the pinned values above |
| `workspace_id` | integer | Derived workspace scope anchor for the artifact |
| `tenant_id` | integer | Derived tenant scope anchor for the artifact |
| `provider_key` | string | Provider-neutral contract field; current repo truth emits `microsoft` only |
| `provider_connection_id` | integer or null | Nullable because historical artifacts may not know the connection |
| `managed_environment_id` | integer | Required managed-environment anchor inside the derived workspace and tenant scope |
| `source_target_kind` | string | One of the pinned values above |
| `source_target_identifier` | string or null | Optional stable target identifier such as governed-subject key, record id, or run id |
| `detector_key` | string or null | Standardized field for detector or signal identity; no closed catalog in `284` v1 |
| `control_key` | string or null | Existing canonical-control key when available |
| `package_run_id` | integer or null | Optional future package hook only; remains null in current runtime |
### InventoryTypeDescriptor
Represents the inventory-specific type split.
| Field | Type | Notes |
|---|---|---|
| `canonical_type` | string | Platform-owned type used for top-level summary |
| `provider_object_type` | string | Raw provider object type such as the existing `policy_type` value |
| `provider_display_type` | string | Human-readable provider label for operators |
| `legacy_policy_type` | string or null | Optional carry-forward for old readers or diagnostics |
### ArtifactProviderDetail
Nested provider-owned evidence that stays below the shared descriptor.
| Field | Type | Notes |
|---|---|---|
| `legacy_finding_type` | string or null | Existing `finding_type` where relevant |
| `legacy_report_type` | string or null | Existing `report_type` where relevant |
| `legacy_policy_type` | string or null | Existing inventory or drift `policy_type` where relevant |
| `provider_object_type` | string or null | Raw provider object type |
| `provider_display_type` | string or null | Provider-owned display label |
| `detector_detail` | string or null | Provider-facing detector or signal detail |
### ArtifactSourceViewModel
Shared summary contract used by touched Filament pages and presenters.
| Field | Type | Notes |
|---|---|---|
| `headline` | string | Canonical operator-facing summary |
| `source_descriptor` | `ArtifactSourceDescriptor` | Shared lineage envelope |
| `provider_detail` | `ArtifactProviderDetail` | Nested provider-owned detail |
| `control_summary` | array or null | Derived control label, key, and status when existing resolver provides it |
| `freshness` | array or null | Existing freshness or timing metadata |
## Relationships
- One managed environment can own many findings, evidence snapshot items, stored reports, and inventory items.
- One finding or stored report can contribute one `ArtifactSourceDescriptor` per surfaced summary.
- One evidence snapshot can contain many `ArtifactSourceDescriptor` values, one per item.
- One inventory item can expose exactly one `InventoryTypeDescriptor` and one `ArtifactSourceDescriptor`.
- One tenant-review section can summarize zero or more underlying artifacts but should surface one canonical source summary per summarized item.
## Legacy-read normalization rules
- If a finding has `source = null`, derive `source_family` and `source_target_kind` from `finding_type` plus any qualifying evidence fields.
- If a drift finding only exposes `policy_type`, derive `canonical_type` from `InventoryPolicyTypeMeta` or adjacent subject metadata, keep the raw value as `provider_object_type` or `legacy_policy_type`, and never promote it back to the top-level headline.
- If a stored report payload already includes `provider_key`, reuse it; otherwise default the descriptor to the current provider for the producing service.
- If an evidence summary has no single `source_record_id`, keep `source_target_identifier` nullable and prefer `managed_environment` or `governed_subject` targeting instead of inventing synthetic ids.
- If inventory has no distinct provider display label, fall back to the best available metadata label while keeping `provider_object_type` separate from `canonical_type`.
- If canonical-control resolution returns no control, `control_key` remains null rather than forcing a fake mapping.
## Explicit non-goals for data modeling
- no `artifact_sources` table
- no persisted package-run ledger
- no detector registry table or config catalog
- no control-catalog expansion
- no full rewrite of provider-native fields out of existing tables

View File

@ -0,0 +1,303 @@
# Implementation Plan: Provider-neutral Artifact Source Taxonomy
**Branch**: `284-provider-neutral-artifact-source-taxonomy` | **Date**: 2026-05-08 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/284-provider-neutral-artifact-source-taxonomy/spec.md`
## Summary
Prepare the reserved provider-neutral artifact-source slice by introducing one bounded artifact-source descriptor over the repo's existing finding, evidence snapshot, stored-report, inventory, review-summary, and support-diagnostic seams. The narrow implementation path reuses the current vocabulary, control-resolution, artifact-truth, and Filament resource surfaces while explicitly rejecting a provider framework, a detector catalog, a control-catalog expansion, a package runtime, historical backfill, and adjacent RBAC or copy work from Specs `285` through `287`.
This plan is intentionally bounded. Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, existing globally-searchable resources keep valid `View` pages, read-only artifact resources stay non-globally-searchable where they already are, touched destructive actions remain confirmation-protected and server-authorized, and deployment asset strategy remains unchanged.
## Inherited Baseline / Explicit Delta
### Inherited baseline
- Spec `279` already completed the managed-environment core cutover and is historical prerequisite context only.
- Spec `279` also records the approved managed-environment core SCOPE-001 exception that this implementation inherits for current artifact tables; `284` does not introduce a new exception, ownership plane, table, or backfill.
- Spec `280` already prepared the workspace-first route shell and remains inherited route context only.
- Spec `281` already prepared provider-neutral provider-connection and target-scope truth and is already present in current repo runtime.
- Spec `282` already brought the core artifact families onto workspace-first admin surfaces and is already present in current repo runtime.
- Spec `283` already prepared and implemented a provider capability contract, which means `provider_key` and connection-context reasoning already have one shared provider-boundary precedent in current repo truth.
- `Finding` already persists `finding_type`, optional `source`, and `evidence_jsonb`, but those fields still act like mixed core and provider truth depending on the consumer.
- `EvidenceSourceProvider` already returns `source_kind`, `source_record_type`, `source_record_id`, and `source_fingerprint`, but it does not yet carry a provider-neutral source family or target contract.
- `EvidenceSnapshotItem` already persists `source_kind`, `source_record_type`, and `source_record_id`, but the table stops short of a reusable artifact-source descriptor.
- `StoredReport` already persists `report_type` and payload, and current report producers already include provider-owned fields such as `provider_key` inside payload rather than as the shared summary contract.
- `InventoryItem` and `InventoryPolicyTypeMeta` already provide policy-type metadata, but they still treat `policy_type` as the main top-level artifact type even where platform-core wording has already moved toward governed-subject semantics.
- `FindingsSummarySource` and related evidence sources already compute canonical controls, but they still do so through finding-type and Microsoft-object special cases rather than one pinned artifact-source descriptor.
- Existing support or AI bundles already use `source_family` for other governed execution contexts, which gives `284` a naming precedent without yet solving artifact lineage.
### Explicit delta in this plan
- Introduce one bounded artifact-source descriptor contract with the exact `source_family`, `source_kind`, and `source_target_kind` inventories pinned below.
- Align findings, evidence source providers, evidence snapshots, stored reports, inventory metadata, and touched review or support summaries to carry or derive that descriptor.
- Separate inventory `canonical_type`, `provider_object_type`, and `provider_display_type` while keeping legacy `policy_type` readable as provider-owned detail.
- Keep Microsoft-specific detector details, report types, provider object types, Graph-facing detail, and raw payloads nested as provider-owned evidence.
- Keep `package_run_id` optional and nullable in the shared contract only; do not add package runtime or package-output surfaces in this slice.
- Keep Specs `285`, `286`, and `287` explicitly deferred.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12.52
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing artifact-truth presenters, control-resolution seams, and vocabulary helpers
**Storage**: PostgreSQL with descriptor-first output derived from existing tenant-owned tables and current payload shapes only; `284` introduces no new descriptor columns or parallel descriptor payload
**Testing**: Pest unit tests, Pest feature tests, one Pest browser smoke
**Validation Lanes**: fast-feedback, confidence, browser
**Target Platform**: Laravel monolith in `apps/platform`
**Project Type**: web application
**Performance Goals**: preserve current read-only artifact surface responsiveness while changing descriptor derivation and presenter output only; no new inline remote work and no new asset path
**Constraints**: no new artifact table, no historical backfill, no detector registry, no package runtime, no broader control-catalog expansion, no RBAC rewrite, no route-shell work, no copy-neutralization work, provider registration stays in `apps/platform/bootstrap/providers.php`
**Scale/Scope**: one provider-neutral artifact-source descriptor plus one inventory type split over current Microsoft-backed artifact families only
## Likely Affected Repo Surfaces
- `apps/platform/app/Models/Finding.php`
- `apps/platform/database/migrations/2026_02_19_100005_add_source_to_findings_table.php`
- `apps/platform/app/Models/StoredReport.php`
- `apps/platform/app/Models/EvidenceSnapshotItem.php`
- `apps/platform/database/migrations/2026_03_19_000001_create_evidence_snapshot_items_table.php`
- `apps/platform/app/Models/InventoryItem.php`
- `apps/platform/app/Support/Inventory/InventoryPolicyTypeMeta.php`
- `apps/platform/app/Services/Evidence/Contracts/EvidenceSourceProvider.php`
- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
- `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`
- `apps/platform/app/Services/Evidence/Sources/PermissionPostureSource.php`
- `apps/platform/app/Services/Evidence/Sources/EntraAdminRolesSource.php`
- `apps/platform/app/Services/Evidence/Sources/BaselineDriftPostureSource.php`
- `apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php`
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesReportService.php`
- `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php`
- `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`
- `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`
- `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`
- `apps/platform/app/Filament/Resources/FindingResource.php`
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`
- `apps/platform/app/Filament/Resources/InventoryItemResource.php`
- `apps/platform/app/Filament/Resources/StoredReportResource.php`
- `apps/platform/app/Filament/Resources/TenantReviewResource.php`
- `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` only if implementation needs the current support-diagnostic `source_family` consumer to align with the artifact-source vocabulary
- `apps/platform/app/Support/Ai/AiUseCaseCatalog.php` and adjacent governed-AI seams only if implementation needs current `source_family` semantics aligned with the new artifact descriptor
- a new bounded support namespace under `apps/platform/app/Support/Artifacts/` only if current helpers cannot carry the pinned descriptor fields cleanly
- representative proof files under `apps/platform/tests/Unit/Artifacts/`, `apps/platform/tests/Unit/Inventory/`, `apps/platform/tests/Feature/Artifacts/`, `apps/platform/tests/Feature/Filament/Artifacts/`, `apps/platform/tests/Feature/Guards/`, and `apps/platform/tests/Browser/`
## Filament v5 / Artifact Surface Notes
- **Livewire v4.0+ compliance**: all touched Filament work remains on Filament v5 with Livewire v4.
- **Provider registration location**: provider registration stays in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`.
- **Global search rule**:
- `FindingResource` already has a `View` page and remains valid for any current or future global-search posture.
- `InventoryItemResource` already has a `View` page and remains valid for any current or future global-search posture.
- `EvidenceSnapshotResource` already has `protected static bool $isGloballySearchable = false;` and keeps a `View` page.
- `StoredReportResource` already has `protected static bool $isGloballySearchable = false;` and keeps a `View` page.
- `TenantReviewResource` already has `protected static bool $isGloballySearchable = false;` and keeps a `View` page.
- No new searchable resource is introduced by this slice.
- **Destructive actions**: `284` introduces no new destructive action. Any touched destructive-like actions on adjacent resources keep their existing `->action(...)`, `->requiresConfirmation()`, and server-authorization contracts.
- **Asset strategy**: no new asset registration or deploy-step change is planned.
## Artifact Source Taxonomy Contract Fit
- Introduce one bounded artifact-source descriptor over current-release artifact families only.
- Keep the initial `source_family` inventory limited to the current repo truth:
- `finding`
- `stored_report`
- `evidence_snapshot`
- `inventory`
- `operation_run`
- Keep the initial `source_kind` inventory limited to the current derivation modes already visible in repo truth:
- `model_summary`
- `stored_report`
- `operation_rollup`
- `inventory_projection`
- Keep the initial `source_target_kind` inventory limited to current-release targets:
- `managed_environment`
- `governed_subject`
- `provider_connection`
- `operation_run`
- Keep `provider_key` provider-neutral in the contract, but current repo truth only requires `microsoft` as the emitted value in this release.
- Do not introduce a closed detector catalog in `284` v1. `detector_key` is standardized as a field and naming rule, not a new cross-product registry.
- Keep `control_key` aligned to existing canonical-control resolution outputs. `284` standardizes where the key lives in artifact summaries; it does not expand the control catalog.
- Keep `package_run_id` nullable and unused in current runtime. It exists only to keep later package-execution work from inventing a second artifact-source contract.
- Prefer one small descriptor value object or normalizer over a config-first taxonomy framework or a broad registry stack.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: mostly native Filament resources plus existing shared artifact and review presenters
- **Shared-family relevance**: evidence viewers, report viewers, inventory metadata, review sections, support or AI source-family semantics if touched
- **State layers in scope**: page, detail, derived presenter state, read-model payload shape
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: canonical descriptor first, diagnostics-second, raw provider detail third
- **Raw/support gating plan**: raw payloads, legacy type names, provider object types, and Graph-facing detector detail remain collapsed, nested, or lower-priority than the descriptor summary
- **One-primary-action / duplicate-truth control**: findings stay the primary decision surface; evidence, reports, inventory, and review sections surface one descriptor-first summary and avoid restating competing Microsoft-only labels
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory until findings, evidence snapshots, stored reports, inventory items, and review sections all disclose the same descriptor semantics
- **Special surface test profiles**: standard-native-filament, shared-detail-family
- **Required tests or manual smoke**: functional-core, state-contract, browser-smoke
- **Exception path and spread control**: none; `284` removes descriptor drift rather than adding a new exception path
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: vocabulary helpers, canonical-control resolution, evidence sources, artifact-truth presenters, inventory metadata, review summaries, and touched support or AI source-family consumers
- **Shared abstractions reused**: `PlatformVocabularyGlossary`, `GovernanceSubjectTaxonomyRegistry`, `CanonicalControlResolutionRequest`, `CanonicalControlResolver`, `InventoryPolicyTypeMeta`, `EvidenceSnapshotService`, `TenantReviewSectionFactory`, and `ArtifactTruthPresenter`
- **New abstraction introduced? why?**: one small descriptor or normalizer seam is expected because several existing consumers need one shared artifact-source contract and no current helper owns that concept end to end
- **Why the existing abstraction was sufficient or insufficient**: existing abstractions already own vocabulary, control binding, or presenter rendering, but none of them own the full source-family plus detector plus control descriptor across findings, evidence, reports, and inventory metadata
- **Bounded deviation / spread control**: provider-native type names, report types, raw payloads, and legacy `policy_type` values remain nested evidence only and must not define the shared top-level descriptor
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: `N/A`
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes
- **Provider-owned seams**: raw `finding_type`, `report_type`, `policy_type`, provider object types, report domains, Graph-facing detector detail, raw payloads, and Microsoft-native labels
- **Platform-core seams**: `source_family`, `source_kind`, `source_target_kind`, descriptor target identity, canonical inventory type, and the placement of `control_key` on artifact summaries
- **Neutral platform terms / contracts preserved**: `managed_environment`, `governed_subject`, `source_family`, `source_target`, `provider_key`, `provider_connection_id`, `detector_key`, `control_key`, and `canonical_type`
- **Retained provider-specific semantics and why**: the current release still needs Microsoft-native detail for diagnostics, reporting, and artifact lineage; the goal is not to erase Microsoft detail, only to stop treating it as platform-core truth
- **Bounded extraction or follow-up path**: no broader package runtime, copy neutralization, or no-legacy enforcement in this slice; those remain for later specs
## Constitution Check
*GATE: Must pass before implementation begins and again after design artifacts are complete.*
- Inventory-first / snapshot truth: PASS. `284` adds descriptor truth over existing artifact records rather than inventing a second artifact ledger.
- Read/write separation: PASS. The slice changes read models and descriptor derivation only.
- Graph contract path: PASS. No new Graph endpoint or contract-registry work is introduced.
- Deterministic capabilities or controls: PASS with implementation condition. Control-summary placement must remain deterministic from existing canonical-control resolution.
- RBAC-UX plane separation: PASS. `/admin` versus `/system` remains unchanged.
- Workspace isolation: PASS. Workspace membership remains the first boundary.
- Managed-environment isolation: PASS. Managed-environment entitlement remains the second boundary.
- Destructive action discipline: PASS by preservation. No new destructive action is introduced.
- Global search safety: PASS. Touched resources either keep `View` pages or stay non-globally-searchable.
- OperationRun / Ops-UX: PASS. `OperationsSummarySource` stays read-only evidence and does not change start/completion UX.
- Data minimization: PASS. No new raw payload duplication or artifact ledger is introduced.
- Test governance: PASS. Proof stays bounded to unit, feature, guard, and one browser smoke.
- Proportionality / no premature abstraction: PASS with implementation condition. Any new support namespace must stay narrow and current-release only.
- Persisted truth / behavioral state: PASS. One additive descriptor family is introduced without a new table or new lifecycle state machine.
- UI semantics / shared pattern first / Filament-native UI: PASS. Existing native resources remain the primary operator paths.
- Provider boundary: PASS with implementation condition. Microsoft-native detail must remain nested provider evidence and must not retake top-level summary ownership.
**Gate evaluation**: PASS.
**Post-design re-check**: PASS while `research.md`, `data-model.md`, `quickstart.md`, `contracts/provider-neutral-artifact-source-taxonomy.logical.openapi.yaml`, and `checklists/requirements.md` stay aligned on the same descriptor inventories, proof commands, and no-backfill posture.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit, Feature, Browser
- **Affected validation lanes**: fast-feedback, confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: the pinned inventory and descriptor normalizer are pure derivation and need unit proof; artifact-family readers and presenters need feature proof; one browser smoke is enough to prove descriptor-first disclosure under the real Filament shell
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Unit/Artifacts/ArtifactSourceTaxonomyCatalogTest.php tests/Unit/Inventory/InventoryCanonicalTypeDescriptorTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Artifacts/FindingArtifactSourceTaxonomyTest.php tests/Feature/Artifacts/EvidenceSnapshotSourceTaxonomyTest.php tests/Feature/Artifacts/StoredReportSourceTaxonomyTest.php tests/Feature/Artifacts/InventoryArtifactTypeTaxonomyTest.php tests/Feature/Filament/Artifacts/ArtifactSourceTaxonomySurfaceTest.php tests/Feature/Guards/ArtifactSourceProviderTruthGuardTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec284ArtifactSourceTaxonomySmokeTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`
- **Fixture / helper / factory / seed / context cost risks**: moderate because proof needs workspace, managed environment, finding, evidence snapshot, stored-report, review, and inventory fixtures without hiding new defaults in shared helpers
- **Expensive defaults or shared helper growth introduced?**: no; any new descriptor fixture helper should stay feature-local and opt-in
- **Heavy-family additions, promotions, or visibility changes**: none beyond one bounded browser smoke
- **Surface-class relief / special coverage rule**: standard-native-filament relief for findings, evidence, inventory, and stored reports; shared-detail-family coverage for review sections and descriptor ordering
- **Closing validation and reviewer handoff**: rerun the commands above, verify that the pinned descriptor inventories are identical across the package, verify that no detector catalog or package runtime appears, verify that `FindingResource`, `EvidenceSnapshotResource`, `InventoryItemResource`, `StoredReportResource`, and `TenantReviewResource` disclose canonical descriptor fields first and provider detail second, verify that the touched resources preserve existing `404` versus `403` behavior, and verify that Microsoft-native artifacts remain readable as provider-owned detail
- **Budget / baseline / trend follow-up**: contained feature-local increase only
- **Review-stop questions**: did implementation add a new table, a detector catalog, a package runtime, a backfill, or adjacent copy or RBAC work; did any touched surface still use `policy_type`, `finding_type`, or `report_type` as the primary summary noun; did the proof commands drift across artifacts
- **Escalation path**: `reject-or-split` if implementation adds package runtime, a detector catalog, a provider framework, backfill work, or adjacent specs
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: adjacent follow-up work already exists as Specs `285` through `287`; `284` only needs the bounded artifact-source slice itself
## Review Checklist Status
- **Review checklist artifact**: `checklists/requirements.md`
- **Review outcome class**: `implementation-ready`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Resolution note**: the prior prerequisite block is resolved by current repo truth and the approved Spec `279` managed-environment core exception. Runtime implementation may proceed because this slice derives descriptor truth from already scoped artifact records and introduces no new table, descriptor columns, ownership plane, historical backfill, detector catalog, package runtime, or adjacent-spec scope.
- **Escalation rule**: if implementation adds a detector catalog, package runtime, a new artifact table, historical backfill, or adjacent-spec scope, flip the workflow outcome to `split` or `reject-or-split`
## Rollout Considerations
- Land the descriptor contract and inventory type split before page copy cleanup so all touched consumers inherit one stable shape.
- Align findings, evidence source providers, and stored reports first so review sections and read-only surfaces consume a settled descriptor rather than parallel intermediate mappings.
- Keep inventory type separation and inventory descriptor-first disclosure scoped to current metadata, current read models, and `InventoryItemResource` rather than reopening baseline-capture or backup storage in the same slice.
- If touched support or AI source-family consumers need alignment, update them only after the artifact descriptor fields are stable so they reuse the final nouns rather than another transitional set.
- Do not attempt historical backfill; rely on additive writes and read-time normalization for legacy rows.
## Risk Controls
- Reject any implementation that introduces a detector catalog, provider framework, or package runtime.
- Reject any implementation that requires historical backfill or dual-write compatibility paths.
- Reject any implementation that keeps `policy_type`, `finding_type`, or `report_type` as the primary summary noun on touched surfaces.
- Reject any implementation that expands canonical-control work into a full control-catalog initiative.
- Reject any implementation that lets touched pages rebuild the descriptor locally instead of consuming one shared contract.
## Research & Design Outputs
- `research.md` records the bounded descriptor decisions, pinned inventories, no-detector-catalog rule, and no-backfill posture.
- `data-model.md` captures the descriptor contract, inventory type descriptor, legacy-read normalization rules, and touched view-model shapes.
- `quickstart.md` gives reviewers the bounded proof flow and exact commands.
- `contracts/provider-neutral-artifact-source-taxonomy.logical.openapi.yaml` models the logical GET surfaces and descriptor-first view models for findings, evidence, inventory, stored reports, and tenant reviews.
- `checklists/requirements.md` records package readiness, boundedness, and outcome state.
## Project Structure
### Documentation (this feature)
```text
specs/284-provider-neutral-artifact-source-taxonomy/
├── checklists/
│ └── requirements.md
├── contracts/
│ └── provider-neutral-artifact-source-taxonomy.logical.openapi.yaml
├── data-model.md
├── plan.md
├── quickstart.md
├── research.md
├── spec.md
└── tasks.md
```
### Source Code (expected implementation surfaces)
```text
apps/platform/
├── app/
│ ├── Filament/Resources/
│ │ ├── EvidenceSnapshotResource.php
│ │ ├── FindingResource.php
│ │ ├── InventoryItemResource.php
│ │ ├── StoredReportResource.php
│ │ └── TenantReviewResource.php
│ ├── Models/
│ │ ├── EvidenceSnapshotItem.php
│ │ ├── Finding.php
│ │ ├── InventoryItem.php
│ │ └── StoredReport.php
│ ├── Services/
│ │ ├── EntraAdminRoles/
│ │ ├── Evidence/
│ │ └── TenantReviews/
│ └── Support/
│ ├── Governance/Controls/
│ ├── Inventory/
│ ├── SupportDiagnostics/
│ └── Ui/GovernanceArtifactTruth/
├── database/migrations/
└── tests/
├── Browser/
├── Feature/Artifacts/
├── Feature/Filament/Artifacts/
├── Feature/Guards/
├── Unit/Artifacts/
└── Unit/Inventory/
```
**Structure Decision**: stay inside the existing Laravel monolith layout in `apps/platform`, add only a small support namespace if current helpers cannot carry the descriptor cleanly, and keep tests in the smallest artifact-focused families.
## Complexity Tracking
No constitution exception is justified at preparation time. If implementation introduces a detector catalog, provider framework, package runtime, or backfill program, split the work instead of extending `284`.

View File

@ -0,0 +1,98 @@
# Quickstart: Provider-neutral Artifact Source Taxonomy
## Purpose
Use this guide to review or later implement Spec `284` as one bounded artifact-source and inventory-type taxonomy slice.
## Preconditions
1. Specs `281`, `282`, and `283` are already present on the implementation branch.
2. Work stays inside `apps/platform` and this spec package.
3. No application implementation from Specs `285` through `287` is pulled into this slice.
4. No historical backfill or package runtime is added.
5. SCOPE-001 ownership compliance for touched tenant-owned artifact tables is resolved or explicitly excepted before runtime implementation begins.
## Pinned descriptor inventories
- `source_family`:
- `finding`
- `stored_report`
- `evidence_snapshot`
- `inventory`
- `operation_run`
- `source_kind`:
- `model_summary`
- `stored_report`
- `operation_rollup`
- `inventory_projection`
- `source_target_kind`:
- `managed_environment`
- `governed_subject`
- `provider_connection`
- `operation_run`
## Reviewer flow
1. Read [spec.md](./spec.md), [plan.md](./plan.md), [research.md](./research.md), and [data-model.md](./data-model.md) together.
2. Confirm the package introduces one shared artifact-source descriptor and one inventory type split, not a new artifact table.
3. Confirm the pinned inventories above for `source_family`, `source_kind`, and `source_target_kind` are identical across all artifacts.
4. Confirm provider-native detail such as `finding_type`, `report_type`, `policy_type`, and provider object types remains nested evidence instead of top-level platform truth.
5. Confirm `package_run_id` stays optional and unused in current runtime.
6. Confirm the logical contract and feature proof preserve inherited `404` versus `403` behavior for the touched resources.
7. Confirm reviewer and proof guidance explicitly cover `workspace_id`, `tenant_id`, `managed_environment_id`, `provider_connection_id`, and `source_target_identifier` semantics when those fields are available.
## Suggested implementation order
1. Add the bounded descriptor support seam and pin the exact inventories.
2. Update findings, evidence-source providers, and stored-report readers or writers to carry the descriptor.
3. Update inventory metadata to expose `canonical_type`, `provider_object_type`, and `provider_display_type`.
4. Update touched Filament resources and review presenters to show descriptor-first summaries.
5. Align touched support or AI `source_family` consumers only if needed.
6. Run the exact bounded proof commands below.
## Narrow proof commands
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \
(cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact \
tests/Unit/Artifacts/ArtifactSourceTaxonomyCatalogTest.php \
tests/Unit/Inventory/InventoryCanonicalTypeDescriptorTest.php)
```
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \
(cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact \
tests/Feature/Artifacts/FindingArtifactSourceTaxonomyTest.php \
tests/Feature/Artifacts/EvidenceSnapshotSourceTaxonomyTest.php \
tests/Feature/Artifacts/StoredReportSourceTaxonomyTest.php \
tests/Feature/Artifacts/InventoryArtifactTypeTaxonomyTest.php \
tests/Feature/Filament/Artifacts/ArtifactSourceTaxonomySurfaceTest.php \
tests/Feature/Guards/ArtifactSourceProviderTruthGuardTest.php)
```
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \
(cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact \
tests/Browser/Spec284ArtifactSourceTaxonomySmokeTest.php)
```
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \
(cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)
```
## Expected smoke path
1. Open one finding and confirm the page shows the canonical source descriptor before provider-native detail.
2. Open one evidence snapshot or evidence item tied to the same managed environment and confirm the same source-family and control summary semantics are visible.
3. Open one stored report and confirm it uses the same descriptor-first disclosure while keeping raw `report_type` nested.
4. Open one inventory item and confirm `canonical_type`, `provider_object_type`, and `provider_display_type` are separate.
5. Open one tenant review with supporting artifact sections and confirm the section summary stays aligned with the underlying artifact descriptor.
## Stop conditions
- Stop if implementation tries to add a new artifact table or ledger.
- Stop if implementation introduces a detector catalog or expands into a control-catalog initiative.
- Stop if implementation requires historical backfill or dual-write compatibility.
- Stop if `policy_type`, `finding_type`, or `report_type` returns as the primary summary noun on touched surfaces.
- Stop if package runtime, copy neutralization, or RBAC redesign starts appearing inside the same slice.

View File

@ -0,0 +1,92 @@
# Research: Provider-neutral Artifact Source Taxonomy
## Decision 1: Use one shared descriptor over existing artifact truth, not a new artifact table
- **Decision**: represent provider-neutral artifact lineage through one shared descriptor carried by existing finding, evidence, stored-report, inventory, and review-summary seams.
- **Why**: the repo already stores the underlying truth in `Finding`, `EvidenceSnapshotItem`, `StoredReport`, and `InventoryItem`. A new artifact-source table would duplicate that truth and create lifecycle or ownership questions that the current release does not need.
- **Alternatives considered**:
- new `artifact_sources` table: rejected because it adds persistence and drift risk with no current-release operator value
- page-local aliasing only: rejected because it would preserve conflicting summaries across findings, evidence, reports, inventory, and review sections
## Decision 2: Pin exact inventories for `source_family`, `source_kind`, and `source_target_kind`
- **Decision**: keep the initial inventories exact and small.
- **Pinned `source_family` set**:
- `finding`
- `stored_report`
- `evidence_snapshot`
- `inventory`
- `operation_run`
- **Pinned `source_kind` set**:
- `model_summary`
- `stored_report`
- `operation_rollup`
- `inventory_projection`
- **Pinned `source_target_kind` set**:
- `managed_environment`
- `governed_subject`
- `provider_connection`
- `operation_run`
- **Why**: the repo memory and readiness rules require exact inventories when a package introduces a bounded semantic family. Keeping the set explicit prevents later prep or implementation drift.
- **Alternatives considered**:
- open-ended family strings with only prose guidance: rejected because readiness analysis can flag vague inventories as premature
- predeclaring package-output or multi-provider families now: rejected because those values are future-facing and not required by current repo truth
## Decision 3: Standardize `detector_key` and `control_key` placement without creating new registries
- **Decision**: `284` standardizes where `detector_key` and `control_key` live in the shared descriptor and touched view models, but it does not introduce a closed detector catalog or a broader control-catalog expansion.
- **Why**: the repo already has working canonical-control resolution. The real problem is inconsistent placement and summary wording, not the absence of a second registry.
- **Alternatives considered**:
- detector catalog or detector registry: rejected because it is future-facing and wider than current repo truth
- control-catalog expansion in the same slice: rejected because `284` is about artifact-source semantics, not broader control governance
## Decision 4: Keep provider-native fields as nested detail
- **Decision**: `finding_type`, `report_type`, raw `policy_type`, provider object types, report domains, and Graph-facing detector detail remain provider-owned nested evidence.
- **Why**: the current release is still Microsoft-first in runtime. The goal is to stop using provider-native fields as top-level platform truth, not to erase them.
- **Alternatives considered**:
- full generic rewrite of provider detail: rejected because it would over-abstract current repo truth
- leaving provider-native fields as top-level summary nouns: rejected because that preserves the current artifact interpretation drift
## Decision 5: Inventory type separation should live beside existing inventory metadata helpers
- **Decision**: keep `canonical_type`, `provider_object_type`, and `provider_display_type` close to `InventoryPolicyTypeMeta` and the inventory read model rather than creating a new cross-product taxonomy engine.
- **Why**: `InventoryPolicyTypeMeta` is already the narrowest place where inventory type meaning is derived and displayed.
- **Alternatives considered**:
- new global type registry for every artifact family: rejected because it is broader than the current inventory-only problem
- leaving inventory on raw `policy_type`: rejected because it would keep one of the explicit 284 acceptance gaps alive
## Decision 6: Legacy rows should normalize on read, not through backfill
- **Decision**: preserve the candidate's no-backfill rule and normalize legacy artifacts on read or during future writes only.
- **Why**: the repo is still pre-production, but `284` does not need a backfill program to deliver operator and contributor value. Read-time normalization is enough for current artifact families.
- **Alternatives considered**:
- historical backfill migration: rejected because it adds risk and operational work without increasing the core value of the slice
- leaving legacy rows unreadable until rewritten: rejected because acceptance requires current Microsoft outputs to remain valid as Microsoft provider sources
## Decision 7: Support or AI alignment stays bounded and package runtime remains deferred
- **Decision**: if `SupportDiagnosticBundleBuilder`, `AiUseCaseCatalog`, or adjacent `source_family` consumers are touched, align them to the pinned source-family nouns only. Keep `package_run_id` optional and nullable; do not create package-execution runtime.
- **Why**: the candidate explicitly says later package execution should be able to build on the descriptor, but `284` must not implement package runtime now.
- **Alternatives considered**:
- package-output or package-run implementation in the same slice: rejected because it is adjacent future work
- ignoring existing `source_family` consumers entirely: rejected because they can become a second naming drift if touched later without the 284 vocabulary
## Implementation prerequisites present in current repo truth
- Spec `281` provider-neutral provider-connection scope is already present in repo runtime.
- Spec `282` workspace-first artifact surfaces are already present in repo runtime.
- Spec `283` provider capability registry is already present in repo runtime.
Because those inherited prerequisites are already present on the current branch, the remaining blocker is narrower: runtime work for `284` stays `prerequisite-blocked` until SCOPE-001 ownership compliance for the touched tenant-owned artifact tables is satisfied or explicitly excepted.
## Explicit non-goals carried into design
- no new artifact table or ledger
- no provider framework
- no detector registry
- no full control-catalog expansion
- no package runtime or package-output surfaces
- no historical backfill
- no workspace-first RBAC redesign
- no copy or localization neutralization

View File

@ -0,0 +1,317 @@
# Feature Specification: Provider-neutral Artifact Source Taxonomy
**Feature Branch**: `284-provider-neutral-artifact-source-taxonomy`
**Created**: 2026-05-08
**Status**: Implementation Ready
**Input**: User description: "Follow the next-best-prep workflow for reserved slot `284` and prepare the `Provider-neutral Artifact Source Taxonomy v1` package without implementing application code."
**Implementation Acceptance Update (2026-05-09)**: Runtime implementation is now explicitly requested. The prior SCOPE-001 prerequisite block is resolved for this slice by repo truth: Specs `281`, `282`, and `283` are present on the implementation branch, Spec `279` records the approved managed-environment core exception, and the touched artifact tables now carry the established workspace plus managed-environment ownership boundary. No new table, ownership plane, descriptor columns, or backfill is introduced by this status update.
## Spec Candidate Check
- **Problem**: TenantPilot already stores and renders artifact truth through `finding_type`, `source`, `report_type`, `policy_type`, `source_kind`, provider-owned payload fields, and ad hoc canonical-control bindings, but those seams still let Microsoft-specific type names behave like platform-core truth. Findings, evidence snapshots, stored reports, inventory metadata, and review sections describe the same artifact lineage in parallel vocabularies instead of one provider-neutral source contract.
- **Today's failure**: Operators and contributors must reinterpret the same artifact through different discriminator families. `FindingsSummarySource` treats `permission_posture`, `entra_admin_roles`, and `deviceCompliancePolicy` as if they were interchangeable core-domain truth; `StoredReport` persists `report_type` as the main summary noun; `EvidenceSourceProvider` only exposes `source_kind` plus raw record type or id; and inventory still treats `policy_type` as the top-level metadata key even where the platform already differentiates governed-subject vocabulary from provider-owned detail.
- **User-visible improvement**: Findings, evidence, reports, inventory items, and review or support summaries expose one canonical source descriptor first: source family, target, detector, and control summary. Microsoft-specific object types, report types, Graph-facing detector details, and legacy `policy_type` values remain available, but they become provider-owned detail instead of the first thing an operator or contributor must decode.
- **Smallest enterprise-capable version**: Introduce one bounded artifact-source descriptor across existing finding, evidence, stored-report, and inventory seams; pin the initial `source_family`, `source_kind`, and `source_target_kind` inventories; separate inventory `canonical_type`, `provider_object_type`, and `provider_display_type`; and align touched evidence or review presenters to disclose the canonical descriptor before provider detail. Do not add a new artifact table, no compliance engine, no full detector catalog, no control-catalog expansion, and no historical backfill.
- **Explicit non-goals**: No new compliance engine, no full control-catalog expansion, no historical backfill, no provider framework, no package-execution runtime, no workspace-first RBAC rewrite from Spec `285`, no copy or localization neutralization from Spec `286`, no no-legacy enforcement pack from Spec `287`, and no UI-polish-first redesign.
- **Permanent complexity imported**: One bounded artifact-source descriptor contract, one pinned inventory for `source_family`, `source_kind`, and `source_target_kind`, one narrow normalizer or presenter seam if existing helpers cannot carry the contract cleanly, one inventory type descriptor split, and focused unit, feature, guard, and browser proof. No new table or independent persisted entity is imported.
- **Why now**: Specs `279` through `283` already moved the platform toward workspace-first managed environments, provider-neutral connection scope, artifact-surface ownership, and provider capability truth. The remaining gap is artifact lineage and artifact-type semantics. If `284` does not land now, later package-output work and later copy neutralization will still inherit Microsoft-shaped artifact truth.
- **Why not local**: The drift is shared across models, evidence-source contracts, stored-report readers, inventory metadata, review sections, and operator surfaces. A local rename in one page or one service would immediately diverge from the other artifact consumers.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New taxonomy, new shared abstraction, and derived descriptor scope fields. Defense: the repo already has multiple real consumers and conflicting artifact vocabularies. `284` is intentionally bounded to one descriptor contract, a small pinned inventory, and the minimum surface convergence needed to make current-release artifacts provider-neutral.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields
- **Scope**: workspace, tenant
- **Primary Routes**:
- workspace-first findings resource list and detail surfaces
- workspace-first evidence snapshot list and detail surfaces
- workspace-first stored-report list and detail surfaces
- workspace-first inventory item list and detail surfaces
- workspace-first tenant-review detail surfaces and shared review sections that already summarize canonical controls and supporting evidence
- **Data Ownership**:
- `Finding`, `EvidenceSnapshotItem`, `StoredReport`, and `InventoryItem` remain tenant-owned persisted truth bound to the established workspace plus tenant scope at authorization time; current repo truth reaches that scope through existing `workspace_id` columns where present and through `managed_environment_id` relations everywhere else
- the shared artifact-source descriptor remains a derived read-model or presenter contract over those existing records in `284` v1; no new descriptor columns, no second descriptor payload, and no new table or ledger are introduced
- `provider_connection_id` may be persisted or derived only where current artifact truth already knows the connection; `284` does not invent a new provider-connection ownership model
- `package_run_id` remains an optional nullable reference in the shared descriptor contract only; `284` does not create package-execution truth or package artifacts
- **RBAC**:
- workspace membership remains the first `404` boundary
- managed-environment entitlement remains the second `404` boundary
- existing view capabilities for findings, evidence, reports, inventory, and tenant reviews remain authoritative
- `284` introduces no new destructive action and no new authorization plane
## Cross-Cutting / Shared Pattern Reuse
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: evidence/report viewers, read-only detail sections, inventory metadata, status messaging, support-diagnostic and AI source descriptors
- **Systems touched**: `EvidenceSourceProvider`, `EvidenceSnapshotService`, `FindingsSummarySource`, stored-report producers and readers, `InventoryPolicyTypeMeta`, `TenantReviewSectionFactory`, `ArtifactTruthPresenter`, and existing support or AI `source_family` consumers
- **Existing pattern(s) to extend**: `PlatformVocabularyGlossary`, `GovernanceSubjectTaxonomyRegistry`, `CanonicalControlResolutionRequest`, `CanonicalControlResolver`, `InventoryPolicyTypeMeta`, `ArtifactTruthPresenter`, and the existing support or AI `source_family` naming precedent
- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalControlResolutionRequest`, `CanonicalControlResolver`, `InventoryPolicyTypeMeta`, `EvidenceSnapshotService`, `TenantReviewSectionFactory`, and `ArtifactTruthPresenter`
- **Why the existing shared path is sufficient or insufficient**: the repo already has vocabulary, control-binding, and presenter seams, but it still lacks one shared artifact-source descriptor that all artifact families can carry without page-local or service-local remapping
- **Allowed deviation and why**: one bounded `ArtifactSourceDescriptor` or equivalent normalizer or presenter seam is allowed if the current helpers cannot carry the pinned descriptor fields without duplication
- **Consistency impact**: findings, evidence snapshots, stored reports, inventory summaries, review sections, and touched support or AI bundles must expose the same `source_family`, `source_kind`, `source_target_kind`, and descriptor semantics before disclosing provider detail; `control_key` stays mandatory for touched findings, evidence, stored reports, and review sections, while inventory keeps the shared descriptor plus the canonical and provider type split as its primary summary
- **Review focus**: verify that no touched page, presenter, or service rebuilds the descriptor locally and that Microsoft-specific nouns remain nested provider detail rather than new shared platform truth
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: `N/A`
- **Delegated start/completion UX behaviors**: `N/A`
- **Local surface-owned behavior that remains**: `N/A`
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check
- **Shared provider/platform boundary touched?**: yes
- **Boundary classification**: mixed
- **Seams affected**: `Finding` discriminator fields, `EvidenceSourceProvider` result shape, `EvidenceSnapshotItem` persisted source metadata, `StoredReport` report typing, inventory metadata, canonical-control binding inputs, review-section evidence summaries, and support or AI `source_family` consumers if touched
- **Neutral platform terms preserved or introduced**: `workspace_id`, `tenant_id`, `managed_environment_id`, `source_family`, `source_kind`, `source_target_kind`, `source_target_identifier`, `provider_key`, `provider_connection_id`, `canonical_type`, `detector_key`, and `control_key`
- **Provider-specific semantics retained and why**: `finding_type`, `report_type`, `policy_type`, Microsoft object types, report domains, Graph-facing detector keys, and provider display labels remain provider-owned because the platform still needs to preserve Microsoft-native evidence and because `284` is not a fake generic rewrite of the adapters
- **Why this does not deepen provider coupling accidentally**: the shared descriptor moves provider detail out of the top-level artifact summary instead of expanding Microsoft-specific semantics. It adds one neutral envelope over already existing provider detail and rejects new provider registries or fake multi-provider engines.
- **Follow-up path**: package-execution adoption remains for later work, broader copy neutralization remains in Spec `286`, and no-legacy enforcement remains in Spec `287`
## UI / Surface Guardrail Impact
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Findings resource summary and detail copy | yes | Native Filament + existing shared presenters | findings, evidence, review | page, detail | no | summary fields and presenter ordering only |
| Evidence snapshot resource summary and item detail | yes | Native Filament + shared artifact truth presenters | evidence, reports, reviews | page, detail | no | read-only evidence surface |
| Inventory item list and detail metadata | yes | Native Filament | inventory metadata, governed-subject labeling | page, detail | no | descriptor and type-label split only |
| Stored report list and detail summary | yes | Native Filament + shared artifact truth presenters | reports, evidence, support | page, detail | no | read-only reporting surface |
| Tenant review detail sections that summarize supporting artifacts | yes | Mixed native Filament + shared review factory | reviews, evidence, reports | detail | no | section summary and disclosure order only |
## Decision-First Surface Role
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Findings resource | Primary Decision Surface | Decide whether the finding needs follow-up now | Canonical source family, governed subject or source target, detector summary, control summary, severity, status | Provider object type, raw legacy finding type, low-level evidence payload | Primary because findings are already an operator decision queue | Follows governance-review workflow | Removes the need to decode Microsoft-only type names before triage |
| Evidence snapshot resource | Tertiary Evidence / Diagnostics Surface | Inspect which supporting artifacts prove the current state | Canonical source descriptor and control summary per item | Raw payloads, record ids, provider object types, legacy report or finding types | Not primary because it explains evidence after a decision surface points here | Follows evidence-inspection workflow | Keeps diagnostic depth available without making it the first summary |
| Inventory item resource | Secondary Context Surface | Inspect what provider object the platform currently knows about | Canonical type, provider display type, lifecycle timestamps | Raw provider object type, legacy `policy_type`, low-level metadata | Not primary because inventory is an inspection surface, not the first decision queue | Follows inventory-inspection workflow | Removes ambiguity between platform type and provider object type |
| Stored report resource | Tertiary Evidence / Diagnostics Surface | Inspect a generated provider report behind other summaries | Canonical source family, report summary, control summary when present | Provider-native report type, payload, provider detail | Not primary because reports are evidence or reporting artifacts | Follows diagnostics and reporting workflow | Keeps report meaning readable without turning `report_type` into platform truth |
| Tenant review detail sections | Secondary Context Surface | Cross-check which supporting artifacts justify the review state | Canonical source summary and control summary | Raw artifact payloads and provider-native identifiers | Not primary because the review decision already exists at the review level | Follows review workflow | Prevents section summaries from restating provider-native detail as the main conclusion |
## Audience-Aware Disclosure
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Findings resource | operator-MSP, support-platform | canonical source family, governed subject, detector summary, control summary, severity, status | provider display type, legacy finding type, related operation or review links | raw payloads and provider-native evidence | `Open finding detail` or the existing remediation path | raw payload excerpts remain collapsed or lower on the page | finding summary states artifact meaning once; detail adds proof rather than a second competing label |
| Evidence snapshot resource | operator-MSP, support-platform | source descriptor per snapshot item, completeness state, control summary | provider object type, source record references, freshness detail | raw evidence payloads | `Inspect evidence item` | payload detail remains secondary | canonical item summary stays aligned with findings and review sections |
| Inventory item resource | operator-MSP, support-platform | canonical type and provider display type | provider object type, source timestamps, sync provenance | raw metadata | `Open inventory item` | raw `meta_jsonb` remains diagnostics-only | canonical type is shown once and not duplicated as a second headline |
| Stored report resource | operator-MSP, support-platform | source family, report headline, latest freshness | provider-native report type, detector detail, fingerprints | raw payload | `Open report detail` | raw JSON remains hidden or lower priority | report summary uses canonical source family once and nests provider detail |
| Tenant review detail sections | operator-MSP, support-platform | artifact source summary and control summary inside each section | provider object type, legacy type names, supporting links | raw evidence bundles and payloads | `Inspect related artifact` | raw provider detail remains gated to deeper disclosure | section summary stays aligned with the owning review summary instead of restating a second truth |
## UI/UX Surface Classification
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Findings resource | Monitoring / Queue / Workbench | Queue / Review Surface | Open the finding and decide follow-up | Full-row open to detail | required | Existing contextual actions stay in row `More` or detail header | existing destructive-like mutations remain grouped and confirmation-protected | workspace-first findings list | workspace-first finding detail | workspace, managed environment, severity, status | Finding | canonical source family, governed subject, and control summary | none |
| Evidence snapshot resource | List / Table / Bulk | Read-only Registry / Report Surface | Open the snapshot item that proves a state | Existing list or detail page | required | Existing safe links remain contextual | none | workspace-first evidence list | workspace-first evidence detail | workspace, managed environment, completeness state | Evidence snapshot | source descriptor and control summary | none |
| Inventory item resource | List / Table / Bulk | Read-only Registry / Report Surface | Open the provider object behind the canonical type | Full-row open to detail | required | Existing safe links remain contextual | none | workspace-first inventory item list | workspace-first inventory item detail | workspace, managed environment, canonical type | Inventory item | canonical type and provider display type | none |
| Stored report resource | List / Table / Bulk | Read-only Registry / Report Surface | Open the report that explains the current posture | Full-row open to detail | required | Existing safe links remain contextual | none | workspace-first stored-report list | workspace-first stored-report detail | workspace, managed environment, freshness | Stored report | source family and report summary | none |
| Tenant review detail sections | Record / Detail / Edit | Detail-first Operational Surface | Open the related artifact or supporting evidence | Existing review detail section links | n/a | Contextual related links only | none | workspace-first tenant-review list | workspace-first tenant-review detail | workspace, managed environment, review status | Review section evidence | canonical artifact source and control summary | none |
## Operator Surface Contract
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Findings resource | Tenant operator | Decide whether the finding needs action | Queue / Review Surface | What artifact triggered this finding and what control does it affect? | source family, governed subject, detector summary, control summary, severity, status | raw payloads, provider-native ids, legacy type names | lifecycle, severity, governance state | existing finding workflow only | existing inspect and remediation actions | existing closure or governance actions only |
| Evidence snapshot resource | Tenant operator | Inspect which artifacts prove the current evidence state | Read-only Registry / Report Surface | Which artifact family produced this evidence and what control does it support? | source descriptor, control summary, freshness, completeness | raw payloads, source record references | evidence completeness, freshness | read-only | existing inspect links | none |
| Inventory item resource | Tenant operator | Inspect inventory classification and provider provenance | Read-only Registry / Report Surface | What canonical thing is this item, and what provider object produced it? | canonical type, provider display type, platform, timestamps | raw provider object type, legacy metadata | freshness, sync recency | read-only | existing inspect links | none |
| Stored report resource | Tenant operator | Inspect a report backing another summary | Read-only Registry / Report Surface | What report family is this and what artifact lineage does it represent? | source family, freshness, summary headline | raw payload, fingerprints, provider report type | freshness, availability | read-only | existing inspect links | none |
| Tenant review detail sections | Tenant operator | Inspect supporting artifact context behind the review | Detail-first Operational Surface | Which artifact family and control summary justify this review section? | canonical artifact summary and control summary | raw evidence bundles, provider-native ids | review lifecycle, evidence completeness | existing review workflow only | existing inspect links | none |
## Proportionality Review
- **New source of truth?**: no new persisted source of truth; one derived shared contract over existing artifact truth
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: yes
- **New cross-domain UI framework/taxonomy?**: yes, but only as a bounded artifact-source and inventory-type taxonomy tied to current-release artifact seams
- **Current operator problem**: the same artifact lineage is currently described with Microsoft-specific nouns in one surface and platform-adjacent summaries in another, which makes evidence, reports, findings, inventory, and reviews harder to interpret safely
- **Existing structure is insufficient because**: current helpers already normalize vocabulary, control bindings, or display order in isolation, but no shared contract pins what an artifact source is across findings, evidence, reports, and inventory metadata
- **Narrowest correct implementation**: add one descriptor contract, one inventory type split, and the smallest normalizer or presenter seam necessary to derive them from existing records, current payloads, and presenters
- **Ownership cost**: additive migrations or payload shape updates, translator or presenter upkeep, legacy-row interpretation rules, and focused proof for both read models and touched Filament surfaces
- **Alternative intentionally rejected**: page-local aliasing or report-local mapping was rejected because it would keep artifact meaning inconsistent; a larger detector catalog or provider framework was rejected because it is future-facing and not required for current repo truth
- **Release truth**: current-release provider-neutral artifact interpretation over existing Microsoft-first artifacts, not a speculative package engine or cross-provider platform rewrite
### 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, except where existing Microsoft-produced artifacts must remain readable as provider-owned historical detail.
## Testing / Lane / Runtime Impact
- **Test purpose / classification**: Unit, Feature, Browser
- **Validation lane(s)**: fast-feedback, confidence, browser
- **Why this classification and these lanes are sufficient**: the pinned taxonomy inventory and descriptor normalizer are pure derivation and need unit proof; findings, evidence snapshots, stored reports, inventory metadata, and touched presenters need feature proof; one browser smoke is enough to prove the operator sees the new descriptor-first disclosure under the live Filament shell
- **New or expanded test families**: focused artifact-source unit tests, artifact-contract feature tests, one guard test, and one browser smoke
- **Fixture / helper cost impact**: moderate because proof needs workspace, managed environment, findings, reports, evidence snapshots, and inventory fixtures without widening shared defaults
- **Heavy-family visibility / justification**: none beyond one narrow browser smoke for touched operator-facing read paths
- **Special surface test profile**: standard-native-filament, shared-detail-family
- **Standard-native relief or required special coverage**: most surfaces need ordinary feature coverage; the read-only review section and evidence summary contract need shared-detail-family assertions to keep disclosure ordering stable
- **Reviewer handoff**: verify the exact pinned inventories across artifacts, confirm touched surfaces show canonical descriptor first and provider detail second, confirm no new table or backfill appears, and rely on the proof commands below rather than broad suite guesses
- **Budget / baseline / trend impact**: contained feature-local increase only
- **Escalation needed**: `reject-or-split` if implementation adds a detector catalog, package runtime, provider framework, historical backfill, or adjacent copy or RBAC scope
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Unit/Artifacts/ArtifactSourceTaxonomyCatalogTest.php tests/Unit/Inventory/InventoryCanonicalTypeDescriptorTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Artifacts/FindingArtifactSourceTaxonomyTest.php tests/Feature/Artifacts/EvidenceSnapshotSourceTaxonomyTest.php tests/Feature/Artifacts/StoredReportSourceTaxonomyTest.php tests/Feature/Artifacts/InventoryArtifactTypeTaxonomyTest.php tests/Feature/Filament/Artifacts/ArtifactSourceTaxonomySurfaceTest.php tests/Feature/Guards/ArtifactSourceProviderTruthGuardTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec284ArtifactSourceTaxonomySmokeTest.php)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`
## User Scenarios & Testing
### User Story 1 - Interpret findings, evidence, and reports with one source descriptor (Priority: P1)
As an operator, I want findings, evidence snapshots, and stored reports to expose the same artifact source summary so I can understand what produced the current signal without decoding Microsoft-only type names first.
**Why this priority**: this is the core reason for `284`. If findings, evidence, and reports still describe the same lineage differently, the taxonomy does not solve the real product problem.
**Independent Test**: create or load one finding, one evidence snapshot, and one stored report for the same managed environment and confirm they each expose the canonical source family, target, detector summary, and control summary before any provider-native detail.
**Acceptance Scenarios**:
1. **Given** a drift finding still carries `policy_type` in `evidence_jsonb`, **When** the operator opens the finding or evidence summary, **Then** the surface shows the canonical source descriptor first and keeps the Microsoft policy type as provider detail.
2. **Given** a stored permission-posture or Entra-admin-roles report exists, **When** the operator opens the report, **Then** the surface shows the shared source family and report meaning first and nests the raw `report_type` and provider payload below that summary.
---
### User Story 2 - Read inventory items without treating `policy_type` as universal platform truth (Priority: P1)
As an operator, I want inventory items to distinguish the platform's canonical type from the provider's object type and display type so I can inspect inventory without assuming Microsoft naming is the only valid domain model.
**Why this priority**: the candidate explicitly calls out inventory metadata. If inventory keeps using `policy_type` as top-level truth, later package-output and reporting work will keep inheriting that false universal.
**Independent Test**: open one inventory item with an existing Microsoft-backed policy type and confirm the page exposes `canonical_type`, `provider_object_type`, and `provider_display_type` as separate concepts.
**Acceptance Scenarios**:
1. **Given** an inventory item was captured from a Microsoft object type such as `deviceCompliancePolicy`, **When** the operator opens the item, **Then** the page shows the canonical platform type separately from the raw provider object type.
2. **Given** the inventory item has a user-facing label, **When** the operator scans the list or detail view, **Then** the provider display type is readable without replacing the canonical type as the platform's primary metadata.
---
### User Story 3 - Keep evidence, reviews, and reporting surfaces descriptor-first (Priority: P2)
As an operator, I want evidence, review, and reporting surfaces to disclose canonical artifact source summaries first and provider detail second so review decisions stay readable and diagnostic depth remains available.
**Why this priority**: changing the underlying model without changing the first-decision summary would still leave the UI teaching the old artifact truth.
**Independent Test**: open the evidence snapshot resource, a tenant review with supporting sections, and a stored report, then confirm each touched summary leads with the same canonical descriptor fields and keeps raw provider detail in secondary disclosure.
**Acceptance Scenarios**:
1. **Given** a tenant review section summarizes evidence from findings and reports, **When** the operator opens the review detail, **Then** the section shows the same source family and control summary used by the underlying artifacts instead of inventing a third summary label.
2. **Given** an evidence snapshot item or stored report has raw provider payload available, **When** the operator opens the detail page, **Then** canonical descriptor fields stay in the summary region and raw provider payload remains diagnostics-only.
---
### User Story 4 - Reuse source-family semantics in downstream shared consumers without adding package runtime (Priority: P3)
As a maintainer, I want touched support or AI source-family consumers and future package-output work to reuse the same source-family semantics so later packaging or summarization features do not need a second artifact taxonomy.
**Why this priority**: later package-output work is explicitly an acceptance target for `284`, but `284` itself must stop before inventing package runtime.
**Independent Test**: inspect the shared descriptor contract, touched support or AI source-family consumers, and the logical contract package slot, then confirm they use the same source-family nouns and leave `package_run_id` optional and unused in the current release.
**Acceptance Scenarios**:
1. **Given** a touched support or AI summary declares a `source_family`, **When** it references artifact-source semantics after `284`, **Then** it uses the same source-family vocabulary as the new artifact descriptor.
2. **Given** the `ArtifactSourceDescriptor` includes optional `package_run_id`, **When** current-release artifacts are rendered, **Then** the field stays null or absent and does not imply that package runtime already exists.
### Edge Cases
- A historical finding has `source = null` and only `finding_type` plus `evidence_jsonb` from an older write path.
- A drift finding still carries only Microsoft `policy_type` or raw detector detail, but the canonical type mapping is missing or incomplete.
- A stored report already has `provider_key = microsoft` in payload while `report_type` remains a Microsoft-shaped top-level discriminator.
- An evidence source summary aggregates many records and therefore has no single `source_record_id`, so the shared descriptor must still point to the right `source_target_kind` and optional identifier.
- An inventory item has no friendly provider display label, so the canonical type and provider object type must still remain distinct without inventing fake display copy.
- `provider_connection_id` is not available for a historical artifact even though the provider key and managed environment are known.
- A touched support or AI consumer already uses `source_family` semantics that would conflict with the new artifact family names if left unchanged.
## Requirements
**Constitution alignment (required):** This slice changes artifact typing, artifact-source derivation, read-only summary disclosure, and canonical-control binding inputs across findings, evidence, stored reports, inventory, and review summaries. It does not introduce a new Graph contract path, a new long-running workflow, or a new persisted artifact ledger.
**Constitution alignment (PROP-001 / ABSTR-001 / PROV-001 / BLOAT-001):** One shared descriptor and one inventory type split are justified because the repo already has multiple live artifact families and conflicting interpretations of the same Microsoft-produced truth. No provider framework, no detector registry, no control-catalog expansion, and no new table are in scope.
**Constitution alignment (XCUT-001 / UI-FIL-001 / DECIDE-001):** The feature must reuse the existing findings, evidence, stored-report, inventory, and tenant-review surfaces. It may refine their summary ordering and view models, but it must not create a new dashboard, a second evidence viewer, or local semantic color or status systems.
**Constitution alignment (RBAC-UX):** Workspace and managed-environment boundaries remain unchanged. Existing view capabilities remain authoritative, and provider-neutral artifact typing must not widen who can see findings, evidence, reports, inventory, or reviews.
**Constitution alignment (TEST-GOV-001):** Proof stays bounded to focused unit, feature, guard, and one narrow browser smoke. The descriptor inventory, descriptor fields, and proof commands must stay pinned identically across the package.
### Functional Requirements
- **FR-001**: The system MUST introduce one shared artifact-source descriptor for findings, evidence snapshots, stored reports, inventory metadata, and touched review or summary consumers.
- **FR-002**: The shared artifact-source descriptor MUST standardize these fields: `workspace_id`, `tenant_id`, `managed_environment_id`, `source_family`, `source_kind`, `provider_key`, `provider_connection_id`, `source_target_kind`, `source_target_identifier`, `detector_key`, `control_key`, and optional `package_run_id`.
- **FR-003**: The initial `source_family` inventory for `284` v1 MUST be exactly `finding`, `stored_report`, `evidence_snapshot`, `inventory`, and `operation_run`.
- **FR-004**: The initial `source_kind` inventory for `284` v1 MUST be exactly `model_summary`, `stored_report`, `operation_rollup`, and `inventory_projection`.
- **FR-005**: The initial `source_target_kind` inventory for `284` v1 MUST be exactly `managed_environment`, `governed_subject`, `provider_connection`, and `operation_run`.
- **FR-006**: `284` v1 MUST NOT introduce a global detector catalog or a broader control-catalog expansion; `detector_key` remains a standardized field and naming rule, not a new registry of every detector in the platform.
- **FR-007**: Findings MUST derive the shared descriptor from existing `finding_type`, `source`, and `evidence_jsonb` fields without requiring historical backfill.
- **FR-008**: `EvidenceSourceProvider` and `EvidenceSnapshotService` MUST carry or derive the shared descriptor consistently so `EvidenceSnapshotItem` does not rely on `source_record_type` alone as its top-level artifact identity.
- **FR-009**: Stored reports MUST align `report_type` with shared source-family semantics while keeping `report_type` itself as provider-owned detail where needed.
- **FR-010**: Inventory metadata MUST expose `canonical_type`, `provider_object_type`, and `provider_display_type` as separate concepts and MUST stop treating raw `policy_type` as the only platform-wide artifact type.
- **FR-011**: Touched canonical-control consumers MUST resolve or disclose `control_key` through the shared descriptor path instead of rebuilding platform truth from page-local Microsoft type checks.
- **FR-012**: Touched findings, evidence, stored-report, and tenant-review presenters MUST show canonical descriptor fields and `control_key` before provider-native detail. Inventory presenters MUST show the shared source descriptor and the canonical/provider type split before raw provider metadata.
- **FR-013**: Existing Microsoft artifacts MUST remain valid and interpretable as `provider_key = microsoft` sources after `284` lands.
- **FR-014**: `284` MUST NOT require historical backfill; legacy rows may be normalized on read or through additive future writes only.
- **FR-015**: `package_run_id` MAY exist as a nullable field in the shared descriptor contract, but `284` MUST NOT create package runtime, package-run persistence, or package-output surfaces.
- **FR-016**: Touched support or AI source-family consumers MUST use the same source-family nouns as the shared descriptor if they are updated in this slice.
- **FR-017**: The implementation MUST NOT introduce a new artifact table, provider framework, compliance engine, detector registry, full control-catalog expansion, RBAC rewrite, copy-neutralization pass, or no-legacy enforcement pack.
- **FR-018**: The descriptor inventory and source-type split MUST stay pinned identically across `spec.md`, `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `tasks.md`, the logical contract, and the readiness checklist. The canonical proof commands MUST stay pinned identically across `spec.md`, `plan.md`, `quickstart.md`, `tasks.md`, and the readiness checklist.
- **FR-019**: Before runtime implementation begins, SCOPE-001 ownership compliance for touched tenant-owned artifact tables MUST be satisfied or explicitly excepted; `284` MUST NOT silently waive that prerequisite.
### Authorization and Safety Requirements
- **AR-001**: Workspace membership MUST remain the first access boundary for touched findings, evidence, reports, inventory, and review surfaces.
- **AR-002**: Managed-environment entitlement MUST remain the second access boundary for those surfaces.
- **AR-003**: Non-members or cross-workspace or cross-environment access attempts MUST continue to resolve as `404`, while in-scope actors missing resource capabilities still resolve as `403`.
- **AR-004**: `284` introduces no new destructive action. Any touched destructive or high-impact action on findings or adjacent resources MUST remain confirmation-protected and server-authorized under the current action contract.
- **AR-005**: Navigation-only related links on touched read-only artifact surfaces MUST remain clearly navigation-only and must not be re-expressed as mutations during this slice.
- **AR-006**: Provider-neutral artifact typing MUST NOT bypass or weaken the current resource policies and capability checks.
### Non-Functional Requirements
- **NFR-001**: Filament remains v5 on Livewire v4.
- **NFR-002**: Provider registration remains in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`.
- **NFR-003**: Asset strategy remains unchanged. No new panel or shared asset registration is expected from `284`.
- **NFR-004**: `FindingResource` and `InventoryItemResource` keep valid `View` pages for any existing or future global-search posture. `EvidenceSnapshotResource`, `StoredReportResource`, and `TenantReviewResource` remain non-globally-searchable while keeping `View` pages.
- **NFR-005**: The feature must remain reviewable as one bounded artifact-source slice and MUST NOT silently absorb work reserved for Specs `285` through `287`.
- **NFR-006**: Default-visible summary fields on touched operator-facing surfaces must stay Filament-native and avoid page-local card, badge, or semantic color systems.
## UI Action Matrix
| Surface | Location | Header Actions | Inspect Affordance (List or Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create or Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Findings resource | `FindingResource` | preserve existing header actions | clickable row to View | preserve existing contextual actions and grouping | preserve existing grouped bulk actions only | preserve current empty-state behavior | preserve existing detail-header actions | preserve existing edit or workflow forms where applicable | preserve current mutation audit behavior | `284` changes descriptor and summary semantics only |
| Evidence snapshot resource | `EvidenceSnapshotResource` | preserve existing read-only actions | clickable row to View | preserve current safe related links only | none | preserve current empty-state behavior | preserve existing read-only header actions | `N/A` | no new audit surface | descriptor-first disclosure only |
| Inventory item resource | `InventoryItemResource` | preserve current no-header-action posture | clickable row to View | preserve current row behavior | none | preserve current no-CTA posture | preserve existing read-only detail actions | `N/A` | no new audit surface | inventory type split only |
| Stored report resource | `StoredReportResource` | preserve existing read-only actions | clickable row to View | preserve current safe actions only | none | preserve current empty-state behavior | preserve existing read-only header actions | `N/A` | no new audit surface | source-family and summary semantics only |
| Tenant review detail sections | `TenantReviewResource` and `TenantReviewSectionFactory` | preserve existing header and section actions | existing section links remain inspect affordances | preserve current safe contextual links | none | `N/A` | preserve existing review-header actions | preserve current review flows | preserve current review audit behavior | section summary ordering only |
All other touched support or AI consumers must keep their existing action contracts and only adopt the shared source-family vocabulary where `284` touches them.
### Key Entities
- **Artifact Source Descriptor**: one shared contract describing source family, source kind, provider, source target, optional target identifier, detector key, control key, and optional package-run reference for an artifact or summary.
- **Inventory Type Descriptor**: the bounded inventory metadata split separating `canonical_type`, `provider_object_type`, and `provider_display_type` while preserving legacy `policy_type` as provider-owned detail.
- **Artifact Provider Detail**: provider-owned raw fields such as `finding_type`, `report_type`, provider object type, Graph-facing detector detail, or legacy `policy_type` retained as nested evidence rather than top-level platform truth.
- **Artifact Source View Model**: the derived presenter contract used by findings, evidence, reports, inventory, and tenant-review sections to disclose canonical descriptor fields first and provider detail second.
## Success Criteria
### Measurable Outcomes
- **SC-001**: 100% of touched findings, evidence snapshots, stored reports, inventory items, and tenant-review artifact summaries expose the shared source descriptor before provider-native detail.
- **SC-002**: 100% of touched inventory item summaries expose `canonical_type`, `provider_object_type`, and `provider_display_type` as separate fields.
- **SC-003**: 100% of touched artifact summaries that already resolve a canonical control continue to expose `control_key` consistently through the shared descriptor path.
- **SC-004**: The implementation introduces no new artifact table, no historical backfill requirement, and no package runtime while keeping existing Microsoft-produced artifacts readable as Microsoft provider sources.

View File

@ -0,0 +1,243 @@
---
description: "Task list for Provider-neutral Artifact Source Taxonomy"
---
# Tasks: Provider-neutral Artifact Source Taxonomy
**Input**: Design documents from `specs/284-provider-neutral-artifact-source-taxonomy/`
**Prerequisites**: `specs/284-provider-neutral-artifact-source-taxonomy/spec.md`, `specs/284-provider-neutral-artifact-source-taxonomy/plan.md`, `specs/284-provider-neutral-artifact-source-taxonomy/checklists/requirements.md`, `specs/284-provider-neutral-artifact-source-taxonomy/research.md`, `specs/284-provider-neutral-artifact-source-taxonomy/data-model.md`, `specs/284-provider-neutral-artifact-source-taxonomy/quickstart.md`, and `specs/284-provider-neutral-artifact-source-taxonomy/contracts/provider-neutral-artifact-source-taxonomy.logical.openapi.yaml`
**Implementation Posture**: Runtime implementation explicitly accepted on 2026-05-09. Targeted test execution, browser smoke, and dirty-file formatting validation remain required before merge readiness.
**Tests**: REQUIRED (Pest). Keep proof bounded to `apps/platform/tests/Unit/Artifacts/ArtifactSourceTaxonomyCatalogTest.php`, `apps/platform/tests/Unit/Inventory/InventoryCanonicalTypeDescriptorTest.php`, `apps/platform/tests/Feature/Artifacts/FindingArtifactSourceTaxonomyTest.php`, `apps/platform/tests/Feature/Artifacts/EvidenceSnapshotSourceTaxonomyTest.php`, `apps/platform/tests/Feature/Artifacts/StoredReportSourceTaxonomyTest.php`, `apps/platform/tests/Feature/Artifacts/InventoryArtifactTypeTaxonomyTest.php`, `apps/platform/tests/Feature/Filament/Artifacts/ArtifactSourceTaxonomySurfaceTest.php`, `apps/platform/tests/Feature/Guards/ArtifactSourceProviderTruthGuardTest.php`, and `apps/platform/tests/Browser/Spec284ArtifactSourceTaxonomySmokeTest.php`.
**Operations**: No new `OperationRun` family. Reuse existing read-only operation lineage where `OperationsSummarySource`, `OperationRunLinks`, or adjacent presenters already surface operation context. `284` does not change start, completion, or link UX for operation execution.
**RBAC**: Workspace membership remains the first `404` boundary, managed-environment entitlement remains the second `404` boundary, and current findings, evidence, reports, inventory, and review capability denials remain `403`. Provider-neutral artifact typing must not bypass existing policies or capabilities.
**Shared Pattern Reuse**: Reuse `PlatformVocabularyGlossary`, `GovernanceSubjectTaxonomyRegistry`, `CanonicalControlResolutionRequest`, `CanonicalControlResolver`, `EvidenceSnapshotService`, `InventoryPolicyTypeMeta`, `ArtifactTruthPresenter`, `TenantReviewSectionFactory`, and touched support or AI `source_family` consumers. Do not introduce a provider framework, a detector catalog, a full control-catalog expansion, a package runtime, a new artifact table, historical backfill, or adjacent Spec `285` through `287` scope.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`. `FindingResource` and `InventoryItemResource` keep valid `View` pages for any current or future search posture. `EvidenceSnapshotResource`, `StoredReportResource`, and `TenantReviewResource` remain non-globally-searchable while keeping `View` pages. Any touched destructive action must continue to use `->action(...)`, `->requiresConfirmation()`, and current server authorization. Asset strategy stays unchanged.
**Compatibility Posture**: Reject historical backfill, dual-write compatibility paths, a new artifact table, a detector catalog, package runtime, a provider framework, RBAC redesign, route-shell work, copy neutralization, and no-legacy enforcement work.
**External Prerequisite**: Specs `281`, `282`, and `283` must already be merged or otherwise present on the implementation branch before any runtime or test task starts, and SCOPE-001 ownership compliance for touched tenant-owned artifact tables must be satisfied or explicitly excepted before runtime implementation begins.
**Organization**: Tasks are grouped by user story so descriptor derivation, inventory type splitting, operator-surface disclosure, and downstream source-family alignment remain independently testable.
**Review Outcome**: `implementation-ready`
**Workflow Outcome**: `keep`
**Test-governance Outcome**: `keep`
**Prerequisite Resolution**: SCOPE-001 is no longer a blocking prerequisite for this implementation loop. Specs `281`, `282`, and `283` are present on the branch, Spec `279` records the approved managed-environment core exception, and current touched artifact tables carry the established workspace plus managed-environment ownership boundary. `284` still must not add a new table, descriptor columns, historical backfill, detector catalog, package runtime, provider framework, or adjacent Specs `285` through `287` scope.
## Test Governance Checklist
- [x] Lane assignment stays `fast-feedback`, `confidence`, and one narrow `browser` lane.
- [x] New or changed tests stay in the named unit, feature, guard, and browser files only.
- [x] Workspace, managed-environment, finding, evidence, stored-report, review, and inventory fixtures remain explicit and opt-in; no hidden shared defaults or backfill helpers are planned.
- [x] Planned validation commands match `spec.md`, `plan.md`, and `quickstart.md` exactly.
- [x] `standard-native-filament` and `shared-detail-family` expectations stay explicit for touched surfaces.
- [x] Any attempt to absorb Specs `285` through `287` resolves as `split` or `reject-or-split`, not hidden follow-up inside `284`.
## Pinned Initial Descriptor Inventories
- `source_family`:
- `finding`
- `stored_report`
- `evidence_snapshot`
- `inventory`
- `operation_run`
- `source_kind`:
- `model_summary`
- `stored_report`
- `operation_rollup`
- `inventory_projection`
- `source_target_kind`:
- `managed_environment`
- `governed_subject`
- `provider_connection`
- `operation_run`
This pinned inventory is authoritative for Spec `284` tasks and must remain identical across the package.
## Phase 0: External Gate
**Purpose**: Confirm the inherited provider-boundary and artifact-surface prerequisites are available before implementation begins.
- [x] T000 Confirm Specs `281`, `282`, and `283` are already merged or otherwise present on the implementation branch before any runtime or test task begins.
---
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the bounded artifact-source inventory, proof files, and deferred-scope posture before runtime edits begin.
- [x] T001 Review `specs/284-provider-neutral-artifact-source-taxonomy/spec.md`, `plan.md`, `checklists/requirements.md`, `research.md`, `data-model.md`, `quickstart.md`, and `contracts/provider-neutral-artifact-source-taxonomy.logical.openapi.yaml` together so implementation stays on Spec `284` only, and stop for a prerequisite decision if SCOPE-001 ownership compliance is still unresolved for touched tenant-owned artifact tables.
- [x] T002 [P] Confirm the current persisted-truth seams in `apps/platform/app/Models/Finding.php`, `apps/platform/app/Models/EvidenceSnapshotItem.php`, `apps/platform/app/Models/StoredReport.php`, `apps/platform/app/Models/InventoryItem.php`, and the related migrations before changing descriptor fields.
- [x] T003 [P] Confirm the current evidence-source and control-resolution seams in `apps/platform/app/Services/Evidence/Contracts/EvidenceSourceProvider.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, `apps/platform/app/Services/Evidence/Sources/PermissionPostureSource.php`, `apps/platform/app/Services/Evidence/Sources/EntraAdminRolesSource.php`, `apps/platform/app/Services/Evidence/Sources/BaselineDriftPostureSource.php`, `apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php`, `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php`, and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`.
- [x] T004 [P] Confirm the current inventory type and metadata seams in `apps/platform/app/Support/Inventory/InventoryPolicyTypeMeta.php`, `apps/platform/app/Models/InventoryItem.php`, and `apps/platform/app/Filament/Resources/InventoryItemResource.php` before changing `policy_type` semantics.
- [x] T005 [P] Confirm the current operator-surface and review-summary seams in `apps/platform/app/Filament/Resources/FindingResource.php`, `EvidenceSnapshotResource.php`, `StoredReportResource.php`, `TenantReviewResource.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, and `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`.
- [x] T006 [P] Confirm current `source_family` naming precedent and deferred boundaries in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`, `apps/platform/app/Support/Ai/AiUseCaseCatalog.php`, and `specs/284-provider-neutral-artifact-source-taxonomy/checklists/requirements.md` so Specs `285` through `287` remain explicitly out of scope.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the proving suite and the canonical descriptor inventory that every story depends on.
**Critical**: No user-story work should begin until this phase is complete.
- [x] T007 [P] Add failing coverage in `apps/platform/tests/Unit/Artifacts/ArtifactSourceTaxonomyCatalogTest.php` for the exact `source_family`, `source_kind`, and `source_target_kind` inventories plus the no-detector-catalog rule.
- [x] T008 [P] Add failing coverage in `apps/platform/tests/Unit/Inventory/InventoryCanonicalTypeDescriptorTest.php` for the `canonical_type`, `provider_object_type`, `provider_display_type`, and `legacy_policy_type` split.
- [x] T009 [P] Add failing coverage in `apps/platform/tests/Feature/Artifacts/FindingArtifactSourceTaxonomyTest.php`, `apps/platform/tests/Feature/Artifacts/EvidenceSnapshotSourceTaxonomyTest.php`, and `apps/platform/tests/Feature/Artifacts/StoredReportSourceTaxonomyTest.php` for shared descriptor derivation over findings, evidence, and stored reports, including `workspace_id`, `tenant_id`, `provider_connection_id`, and `source_target_identifier` semantics when present.
- [x] T010 [P] Add failing guard coverage in `apps/platform/tests/Feature/Guards/ArtifactSourceProviderTruthGuardTest.php` for `finding_type`, `report_type`, or `policy_type` reappearing as top-level summary truth and for `package_run_id` being treated as active runtime truth.
- [x] T011 [P] Add the narrow browser smoke in `apps/platform/tests/Browser/Spec284ArtifactSourceTaxonomySmokeTest.php` for one finding, one evidence snapshot, one stored report, one inventory item, and one tenant-review section under the live Filament shell.
- [x] T012 Introduce the bounded shared descriptor support seam in the smallest viable namespace under `apps/platform/app/Support/Artifacts/` or existing helpers, derive the descriptor from existing persisted truth plus the established workspace and tenant scope, carry `provider_connection_id` and `source_target_identifier` when current truth exposes them, and pin the exact inventories across consumers without adding a new table, detector catalog, descriptor columns, or backfill flow.
**Checkpoint**: The proving files exist, the descriptor inventory is explicit, and later stories can consume one canonical artifact-source contract.
---
## Phase 3: User Story 1 - Interpret findings, evidence, and stored reports with one descriptor (Priority: P1)
**Goal**: Findings, evidence summaries, and stored reports expose the same canonical artifact-source descriptor and control summary before provider-native detail.
**Independent Test**: Load one finding, one evidence snapshot item, and one stored report for the same managed environment and confirm each surface or reader exposes the same descriptor-first summary contract.
### Tests for User Story 1
- [x] T013 [P] [US1] Extend `apps/platform/tests/Feature/Artifacts/FindingArtifactSourceTaxonomyTest.php`, `apps/platform/tests/Feature/Artifacts/EvidenceSnapshotSourceTaxonomyTest.php`, and `apps/platform/tests/Feature/Artifacts/StoredReportSourceTaxonomyTest.php` after T012 to prove the same descriptor and `control_key` semantics survive across finding, evidence, and stored-report readers.
### Implementation for User Story 1
- [x] T014 [US1] Update `apps/platform/app/Models/Finding.php`, `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, and the smallest related control-binding seam so finding summaries derive the canonical descriptor without page-local Microsoft-only checks.
- [x] T015 [US1] Update `apps/platform/app/Services/Evidence/Contracts/EvidenceSourceProvider.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, `apps/platform/app/Services/Evidence/Sources/PermissionPostureSource.php`, `apps/platform/app/Services/Evidence/Sources/EntraAdminRolesSource.php`, `apps/platform/app/Services/Evidence/Sources/BaselineDriftPostureSource.php`, `apps/platform/app/Services/Evidence/Sources/OperationsSummarySource.php`, and `apps/platform/app/Models/EvidenceSnapshotItem.php` so evidence summaries derive the descriptor consistently from existing persisted truth.
- [x] T016 [US1] Update `apps/platform/app/Models/StoredReport.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesReportService.php`, and any directly touched stored-report readers so report summaries align to source-family semantics while keeping raw `report_type` nested. Note: no `EntraAdminRolesReportService` edit was required because the shared `StoredReport` resolver derives descriptor and provider detail from existing report payloads without producer changes.
**Checkpoint**: Findings, evidence summaries, and stored reports now expose one shared descriptor-first lineage contract.
---
## Phase 4: User Story 2 - Read inventory items with canonical and provider type separation (Priority: P1)
**Goal**: Inventory items distinguish platform-owned canonical type from provider-owned object type and display type while still participating in the shared artifact-source descriptor contract.
**Independent Test**: Open one inventory item and confirm the page shows `canonical_type`, `provider_object_type`, and `provider_display_type` as separate concepts and still exposes the shared source descriptor.
### Tests for User Story 2
- [x] T017 [P] [US2] Extend `apps/platform/tests/Unit/Inventory/InventoryCanonicalTypeDescriptorTest.php` and `apps/platform/tests/Feature/Artifacts/InventoryArtifactTypeTaxonomyTest.php` after T012 to prove the inventory type split, legacy `policy_type` nesting, and shared source descriptor on inventory read models.
### Implementation for User Story 2
- [x] T018 [US2] Update `apps/platform/app/Support/Inventory/InventoryPolicyTypeMeta.php` and the smallest adjacent inventory read-model seam so inventory emits `canonical_type`, `provider_object_type`, `provider_display_type`, and optional `legacy_policy_type` without promoting raw `policy_type` back to top-level truth.
- [x] T019 [US2] Update `apps/platform/app/Models/InventoryItem.php` and `apps/platform/app/Filament/Resources/InventoryItemResource.php` so list and detail surfaces disclose the type split, carry the shared source descriptor, and keep raw metadata secondary.
**Checkpoint**: Inventory surfaces now separate platform type from provider object type cleanly.
---
## Phase 5: User Story 3 - Keep operator-facing artifact surfaces descriptor-first (Priority: P2)
**Goal**: Findings, evidence, stored reports, and tenant-review sections show the canonical descriptor and control summary before provider-native detail.
**Independent Test**: Open one finding, one evidence snapshot, one stored report, and one tenant review with supporting sections and confirm each touched summary is descriptor-first.
### Tests for User Story 3
- [x] T020 [P] [US3] Extend `apps/platform/tests/Feature/Filament/Artifacts/ArtifactSourceTaxonomySurfaceTest.php` after T012 to prove `FindingResource`, `EvidenceSnapshotResource`, `InventoryItemResource`, `StoredReportResource`, and `TenantReviewResource` surface the descriptor first, provider detail second, and preserve inherited `404` versus `403` behavior.
### Implementation for User Story 3
- [x] T021 [US3] Update `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, `apps/platform/app/Filament/Resources/StoredReportResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, and `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` so summary ordering converges on the shared descriptor and control summary. Note: no `ArtifactTruthPresenter` edit was required because descriptor-first disclosure lives in the resource and review-section summary layers while existing truth envelopes remain unchanged.
- [x] T022 [US3] Update touched review or evidence related-context seams only where required so navigation stays unchanged while summary ordering converges. Note: no related-context changes were required; existing links stayed unchanged.
**Checkpoint**: Operator-facing read paths now teach one artifact-source vocabulary and keep provider detail secondary.
---
## Phase 6: User Story 4 - Align downstream shared consumers without adding package runtime (Priority: P3)
**Goal**: Touched support or AI `source_family` consumers reuse the same pinned family names and keep `package_run_id` optional only.
**Independent Test**: Inspect touched support or AI source-family bundles and confirm they use the same source-family vocabulary as the artifact descriptor while leaving `package_run_id` null or absent.
### Tests for User Story 4
- [x] T023 [P] [US4] Extend `apps/platform/tests/Feature/Guards/ArtifactSourceProviderTruthGuardTest.php` after T012 to prove touched support or AI `source_family` consumers use the pinned family names and that `package_run_id` remains optional-only in current runtime.
### Implementation for User Story 4
- [x] T024 [US4] Update `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`, `apps/platform/app/Support/Ai/AiUseCaseCatalog.php`, and any directly touched shared descriptor helpers only where required so source-family semantics align without implying package runtime. Note: no runtime edit was required because existing support and AI families remain non-artifact source families and the guard test proves they do not collide with the pinned artifact inventory.
**Checkpoint**: Downstream shared consumers no longer risk inventing a second artifact-source vocabulary.
---
## Phase 7: Polish & Cross-Cutting Validation
**Purpose**: Run the exact bounded proof set, perform the final Filament and taxonomy review, and confirm the slice stayed inside Spec `284`.
- [x] T025 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Unit/Artifacts/ArtifactSourceTaxonomyCatalogTest.php tests/Unit/Inventory/InventoryCanonicalTypeDescriptorTest.php)`.
- [x] T026 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Artifacts/FindingArtifactSourceTaxonomyTest.php tests/Feature/Artifacts/EvidenceSnapshotSourceTaxonomyTest.php tests/Feature/Artifacts/StoredReportSourceTaxonomyTest.php tests/Feature/Artifacts/InventoryArtifactTypeTaxonomyTest.php tests/Feature/Filament/Artifacts/ArtifactSourceTaxonomySurfaceTest.php tests/Feature/Guards/ArtifactSourceProviderTruthGuardTest.php)`.
- [x] T027 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec284ArtifactSourceTaxonomySmokeTest.php)`.
- [x] T028 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`.
- [x] T029 [P] Review touched files to confirm Filament v5 and Livewire v4 compliance, provider registration staying in `apps/platform/bootstrap/providers.php`, truthful global-search posture, unchanged asset strategy, no new artifact table, no detector catalog, no package runtime, no historical backfill, and Specs `285` through `287` remaining deferred.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 0 (External Gate)**: no dependencies; complete before implementation starts.
- **Phase 1 (Setup)**: depends on Phase 0.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all story work.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical descriptor for findings, evidence, and stored reports.
- **Phase 4 (US2)**: depends on Phase 2 and should land with or immediately after US1 so inventory type semantics align with the shared descriptor vocabulary.
- **Phase 5 (US3)**: depends on US1 and US2 because operator-facing surfaces should consume the final descriptor and inventory split.
- **Phase 6 (US4)**: depends on US1 through US3 so downstream shared consumers inherit the final vocabulary instead of an intermediate mapping.
- **Phase 7 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: independently testable after Phase 2 and is the first required implementation increment.
- **US2 (P1)**: independently testable after Phase 2, but should ship after or with US1 because inventory should not introduce parallel artifact-type language.
- **US3 (P2)**: independently testable after US1 and US2 once the shared descriptor is stable.
- **US4 (P3)**: independently testable after US1 through US3 and closes the remaining `source_family` drift in touched shared consumers.
### Within Each User Story
- Write or extend the listed Pest coverage first and make it fail for the intended gap.
- Apply the smallest shared-seam changes needed to satisfy the story without reopening Specs `285` through `287`.
- Re-run the narrowest relevant validation command for that story before moving to the next story.
## Parallel Execution Examples
- **Setup**: T002 through T006 can run in parallel once T000 and T001 set the bounded scope.
- **Foundational**: T007 through T011 can run in parallel before T012 converges the canonical descriptor seam.
- **US1**: T013 can run alongside implementation prep; T014 through T016 should merge serially around finding, evidence, and stored-report seams.
- **US2**: T017 can run alongside T018, then T019 follows once the inventory type split is stable.
- **US3**: T020 can run alongside implementation prep; T021 and T022 should merge serially around shared presenter files.
- **US4**: T023 can run alongside T024 because both touch bounded shared-consumer alignment only.
- **Polish**: T025 through T028 can run in parallel after implementation is complete; T029 closes the bounded-scope review last.
## Implementation Strategy
### Suggested MVP Scope
- MVP = **US1 + US2**. Land the shared descriptor and inventory type split first so later surface cleanup does not build on inconsistent underlying semantics.
### Incremental Delivery
1. Complete Phase 0, Phase 1, and Phase 2.
2. Deliver US1 so findings, evidence, and stored reports stop depending on Microsoft-only top-level summary nouns.
3. Deliver US2 so inventory type semantics stop teaching `policy_type` as universal truth.
4. Deliver US3 so operator-facing surfaces visibly converge on the final descriptor-first disclosure.
5. Deliver US4 only if touched support or AI consumers need explicit alignment.
6. Finish with the exact validation commands and the final bounded-scope review in Phase 7.
### Team Strategy
1. Parallelize the failing test work first.
2. Serialize merges around the shared descriptor seam, evidence providers, and Filament resource presenters to avoid contract-shape conflicts.
3. Reject any implementation branch that introduces a detector catalog, package runtime, backfill work, a provider framework, or adjacent copy or RBAC scope.
## Deferred Follow-Ups / Non-Goals
- Spec `285` workspace-first RBAC and environment-access scoping
- Spec `286` broader UI copy, IA, and localization neutralization
- Spec `287` cutover quality gates and no-legacy enforcement
- package runtime or package-output surfaces beyond the optional `package_run_id` contract slot
- detector catalog or broader control-catalog expansion