feat: implement governance artifact truth semantics #188
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -98,6 +98,8 @@ ## Active Technologies
|
||||
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
||||
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
||||
- PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice (157-reason-code-translation)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages (158-artifact-truth-semantics)
|
||||
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -117,8 +119,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 158-artifact-truth-semantics: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages
|
||||
- 157-reason-code-translation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
- 156-operator-outcome-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
- 155-tenant-review-layer: Added PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService`
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -8,10 +8,13 @@
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -87,6 +90,9 @@ public function mount(): void
|
||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||
|
||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
|
||||
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||
|
||||
return [
|
||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||
'tenant_id' => (int) $snapshot->tenant_id,
|
||||
@ -95,7 +101,21 @@ public function mount(): void
|
||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
||||
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
||||
'view_url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant),
|
||||
'artifact_truth' => [
|
||||
'label' => $truth->primaryLabel,
|
||||
'color' => $truth->primaryBadgeSpec()->color,
|
||||
'icon' => $truth->primaryBadgeSpec()->icon,
|
||||
'explanation' => $truth->primaryExplanation,
|
||||
],
|
||||
'freshness' => [
|
||||
'label' => $freshnessSpec->label,
|
||||
'color' => $freshnessSpec->color,
|
||||
'icon' => $freshnessSpec->icon,
|
||||
],
|
||||
'next_step' => $truth->nextStepText(),
|
||||
'view_url' => $snapshot->tenant
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||
: null,
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -114,6 +115,15 @@ public function table(Table $table): Table
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
||||
TextColumn::make('artifact_truth')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryLabel)
|
||||
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
@ -123,15 +133,29 @@ public function table(Table $table): Table
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
||||
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
TextColumn::make('summary.publish_blockers')
|
||||
->label('Publish blockers')
|
||||
->formatStateUsing(static function (mixed $state): string {
|
||||
if (! is_array($state) || $state === []) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return (string) count($state);
|
||||
}),
|
||||
TextColumn::make('publication_truth')
|
||||
->label('Publication')
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
)->label)
|
||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
)->color)
|
||||
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
)->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
||||
)->iconColor),
|
||||
TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
||||
->wrap(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
|
||||
@ -21,6 +21,8 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -168,10 +170,23 @@ public static function table(Table $table): Table
|
||||
->label('Captured')
|
||||
->since()
|
||||
->sortable(),
|
||||
TextColumn::make('artifact_truth')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryLabel)
|
||||
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
TextColumn::make('fidelity_summary')
|
||||
->label('Fidelity')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
TextColumn::make('snapshot_state')
|
||||
->label('State')
|
||||
->badge()
|
||||
@ -364,4 +379,9 @@ private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges
|
||||
self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
|
||||
);
|
||||
}
|
||||
|
||||
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,8 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
@ -133,6 +135,15 @@ public static function form(Schema $schema): Schema
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Artifact truth')
|
||||
->schema([
|
||||
ViewEntry::make('artifact_truth')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.governance-artifact-truth')
|
||||
->state(fn (EvidenceSnapshot $record): array => static::truthEnvelope($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Snapshot')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
@ -214,6 +225,15 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('artifact_truth')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||
->color(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
@ -225,6 +245,10 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
|
||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
@ -588,6 +612,11 @@ private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
|
||||
return $label === 'Unknown' ? null : $label;
|
||||
}
|
||||
|
||||
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
|
||||
}
|
||||
|
||||
private static function stringifySummaryValue(mixed $value): string
|
||||
{
|
||||
return match (true) {
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
@ -288,6 +289,15 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
||||
],
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'artifact_truth',
|
||||
kind: 'current_status',
|
||||
title: 'Artifact truth',
|
||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||
viewData: ['artifactTruthState' => app(ArtifactTruthPresenter::class)->forOperationRun($record)->toArray()],
|
||||
visible: $record->isGovernanceArtifactOperation(),
|
||||
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -19,11 +20,14 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
@ -111,6 +115,15 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Artifact truth')
|
||||
->schema([
|
||||
ViewEntry::make('artifact_truth')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.governance-artifact-truth')
|
||||
->state(fn (ReviewPack $record): array => static::truthEnvelope($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Status')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
@ -238,6 +251,15 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('artifact_truth')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||
->color(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
@ -257,6 +279,29 @@ public static function table(Table $table): Table
|
||||
->label('Size')
|
||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('publication_truth')
|
||||
->label('Publication')
|
||||
->badge()
|
||||
->getStateUsing(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->label)
|
||||
->color(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->color)
|
||||
->icon(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->icon)
|
||||
->iconColor(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->iconColor),
|
||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->since()
|
||||
@ -352,6 +397,11 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
|
||||
@ -28,6 +28,8 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
@ -143,6 +145,15 @@ public static function form(Schema $schema): Schema
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Artifact truth')
|
||||
->schema([
|
||||
ViewEntry::make('artifact_truth')
|
||||
->hiddenLabel()
|
||||
->view('filament.infolists.entries.governance-artifact-truth')
|
||||
->state(fn (TenantReview $record): array => static::truthEnvelope($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Review')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
@ -239,6 +250,15 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('artifact_truth')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryLabel)
|
||||
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
||||
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
||||
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
@ -251,9 +271,32 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
|
||||
Tables\Columns\TextColumn::make('publication_truth')
|
||||
->label('Publication')
|
||||
->badge()
|
||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->label)
|
||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->color)
|
||||
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->icon)
|
||||
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
||||
)->iconColor),
|
||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||
->label('Export')
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('fingerprint')
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->searchable(),
|
||||
@ -561,4 +604,9 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
||||
'links' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
|
||||
{
|
||||
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
@ -59,10 +60,12 @@ public function handle(
|
||||
AuditLogger $auditLogger,
|
||||
OperationRunService $operationRunService,
|
||||
?CurrentStateHashResolver $hashResolver = null,
|
||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
): void {
|
||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
|
||||
@ -183,7 +186,12 @@ public function handle(
|
||||
gaps: $captureGaps,
|
||||
);
|
||||
|
||||
$items = $snapshotItems['items'] ?? [];
|
||||
$normalizedItems = $snapshotItemNormalizer->deduplicate($snapshotItems['items'] ?? []);
|
||||
$items = $normalizedItems['items'];
|
||||
|
||||
if (($normalizedItems['duplicates'] ?? 0) > 0) {
|
||||
$captureGaps['duplicate_subject_reference'] = ($captureGaps['duplicate_subject_reference'] ?? 0) + (int) $normalizedItems['duplicates'];
|
||||
}
|
||||
|
||||
$identityHash = $identity->computeIdentity($items);
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\OperationCatalog;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -127,4 +128,35 @@ public function setFinishedAtAttribute(mixed $value): void
|
||||
{
|
||||
$this->completed_at = $value;
|
||||
}
|
||||
|
||||
public function isGovernanceArtifactOperation(): bool
|
||||
{
|
||||
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
||||
}
|
||||
|
||||
public function governanceArtifactFamily(): ?string
|
||||
{
|
||||
return OperationCatalog::governanceArtifactFamily((string) $this->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function artifactResultContext(): array
|
||||
{
|
||||
$context = is_array($this->context) ? $this->context : [];
|
||||
$result = is_array($context['result'] ?? null) ? $context['result'] : [];
|
||||
|
||||
return array_merge($context, ['result' => $result]);
|
||||
}
|
||||
|
||||
public function relatedArtifactId(): ?int
|
||||
{
|
||||
return match ($this->governanceArtifactFamily()) {
|
||||
'baseline_snapshot' => is_numeric(data_get($this->context, 'result.snapshot_id'))
|
||||
? (int) data_get($this->context, 'result.snapshot_id')
|
||||
: null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
90
app/Services/Baselines/BaselineSnapshotItemNormalizer.php
Normal file
90
app/Services/Baselines/BaselineSnapshotItemNormalizer.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Baselines;
|
||||
|
||||
final class BaselineSnapshotItemNormalizer
|
||||
{
|
||||
/**
|
||||
* @param list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $items
|
||||
* @return array{items: list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>, duplicates: int}
|
||||
*/
|
||||
public function deduplicate(array $items): array
|
||||
{
|
||||
$uniqueItems = [];
|
||||
$duplicates = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$key = trim((string) ($item['subject_type'] ?? '')).'|'.trim((string) ($item['subject_external_id'] ?? ''));
|
||||
|
||||
if ($key === '|') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($key, $uniqueItems)) {
|
||||
$uniqueItems[$key] = $item;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$duplicates++;
|
||||
|
||||
if ($this->shouldReplace($uniqueItems[$key], $item)) {
|
||||
$uniqueItems[$key] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => array_values($uniqueItems),
|
||||
'duplicates' => $duplicates,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $current
|
||||
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $candidate
|
||||
*/
|
||||
private function shouldReplace(array $current, array $candidate): bool
|
||||
{
|
||||
$currentFidelity = $this->fidelityRank($current);
|
||||
$candidateFidelity = $this->fidelityRank($candidate);
|
||||
|
||||
if ($candidateFidelity !== $currentFidelity) {
|
||||
return $candidateFidelity > $currentFidelity;
|
||||
}
|
||||
|
||||
$currentObservedAt = $this->observedAt($current);
|
||||
$candidateObservedAt = $this->observedAt($candidate);
|
||||
|
||||
if ($candidateObservedAt !== $currentObservedAt) {
|
||||
return $candidateObservedAt > $currentObservedAt;
|
||||
}
|
||||
|
||||
return strcmp((string) ($candidate['baseline_hash'] ?? ''), (string) ($current['baseline_hash'] ?? '')) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{meta_jsonb?: array<string, mixed>} $item
|
||||
*/
|
||||
private function fidelityRank(array $item): int
|
||||
{
|
||||
$fidelity = data_get($item, 'meta_jsonb.evidence.fidelity');
|
||||
|
||||
return match ($fidelity) {
|
||||
'content' => 2,
|
||||
'meta' => 1,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{meta_jsonb?: array<string, mixed>} $item
|
||||
*/
|
||||
private function observedAt(array $item): string
|
||||
{
|
||||
$observedAt = data_get($item, 'meta_jsonb.evidence.observed_at');
|
||||
|
||||
return is_string($observedAt) ? $observedAt : '';
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -119,6 +120,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
||||
$rendered->summaryRows,
|
||||
));
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
|
||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
||||
->header(new SummaryHeaderData(
|
||||
@ -134,6 +136,14 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
||||
))
|
||||
->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'artifact_truth',
|
||||
kind: 'current_status',
|
||||
title: 'Artifact truth',
|
||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||
viewData: ['state' => $truth->toArray()],
|
||||
description: 'Trustworthy artifact state stays separate from historical trace and support diagnostics.',
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'coverage_summary',
|
||||
kind: 'current_status',
|
||||
|
||||
@ -15,6 +15,11 @@ final class BadgeCatalog
|
||||
private const DOMAIN_MAPPERS = [
|
||||
BadgeDomain::AuditOutcome->value => Domains\AuditOutcomeBadge::class,
|
||||
BadgeDomain::AuditActorType->value => Domains\AuditActorTypeBadge::class,
|
||||
BadgeDomain::GovernanceArtifactExistence->value => Domains\GovernanceArtifactExistenceBadge::class,
|
||||
BadgeDomain::GovernanceArtifactContent->value => Domains\GovernanceArtifactContentBadge::class,
|
||||
BadgeDomain::GovernanceArtifactFreshness->value => Domains\GovernanceArtifactFreshnessBadge::class,
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness->value => Domains\GovernanceArtifactPublicationReadinessBadge::class,
|
||||
BadgeDomain::GovernanceArtifactActionability->value => Domains\GovernanceArtifactActionabilityBadge::class,
|
||||
BadgeDomain::BaselineSnapshotFidelity->value => Domains\BaselineSnapshotFidelityBadge::class,
|
||||
BadgeDomain::BaselineSnapshotGapStatus->value => Domains\BaselineSnapshotGapStatusBadge::class,
|
||||
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
||||
|
||||
@ -6,6 +6,11 @@ enum BadgeDomain: string
|
||||
{
|
||||
case AuditOutcome = 'audit_outcome';
|
||||
case AuditActorType = 'audit_actor_type';
|
||||
case GovernanceArtifactExistence = 'governance_artifact_existence';
|
||||
case GovernanceArtifactContent = 'governance_artifact_content';
|
||||
case GovernanceArtifactFreshness = 'governance_artifact_freshness';
|
||||
case GovernanceArtifactPublicationReadiness = 'governance_artifact_publication_readiness';
|
||||
case GovernanceArtifactActionability = 'governance_artifact_actionability';
|
||||
case BaselineSnapshotFidelity = 'baseline_snapshot_fidelity';
|
||||
case BaselineSnapshotGapStatus = 'baseline_snapshot_gap_status';
|
||||
case OperationRunStatus = 'operation_run_status';
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class GovernanceArtifactActionabilityBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'none' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-check'),
|
||||
'optional' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-information-circle'),
|
||||
'required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactActionability, $state, 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class GovernanceArtifactContentBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'trusted' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-check-badge'),
|
||||
'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-exclamation-triangle'),
|
||||
'missing_input' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-exclamation-circle'),
|
||||
'metadata_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-document-text'),
|
||||
'reference_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-link'),
|
||||
'empty' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-no-symbol'),
|
||||
'unsupported' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactContent, $state, 'heroicon-m-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class GovernanceArtifactExistenceBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'not_created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-clock'),
|
||||
'historical_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-archive-box'),
|
||||
'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-check-circle'),
|
||||
'created_but_not_usable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactExistence, $state, 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class GovernanceArtifactFreshnessBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'current' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-check-circle'),
|
||||
'stale' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-arrow-path'),
|
||||
'unknown' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactFreshness, $state, 'heroicon-m-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class GovernanceArtifactPublicationReadinessBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'not_applicable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-minus-circle'),
|
||||
'internal_only' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-document-duplicate'),
|
||||
'publishable' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-check-badge'),
|
||||
'blocked' => OperatorOutcomeTaxonomy::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, $state, 'heroicon-m-no-symbol'),
|
||||
default => BadgeSpec::unknown(),
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,205 @@ final class OperatorOutcomeTaxonomy
|
||||
* }>>
|
||||
*/
|
||||
private const ENTRIES = [
|
||||
'governance_artifact_existence' => [
|
||||
'not_created' => [
|
||||
'axis' => 'artifact_existence',
|
||||
'label' => 'Not created yet',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['No artifact'],
|
||||
'notes' => 'The intended artifact has not been produced yet.',
|
||||
],
|
||||
'historical_only' => [
|
||||
'axis' => 'artifact_existence',
|
||||
'label' => 'Historical artifact',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Historical only'],
|
||||
'notes' => 'The artifact remains readable for history but is no longer the current working artifact.',
|
||||
],
|
||||
'created' => [
|
||||
'axis' => 'artifact_existence',
|
||||
'label' => 'Artifact available',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Created'],
|
||||
'notes' => 'The intended artifact exists and can be inspected.',
|
||||
],
|
||||
'created_but_not_usable' => [
|
||||
'axis' => 'artifact_existence',
|
||||
'label' => 'Artifact not usable',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Created but not usable'],
|
||||
'notes' => 'The artifact record exists, but the operator cannot safely rely on it for the primary task.',
|
||||
],
|
||||
],
|
||||
'governance_artifact_content' => [
|
||||
'trusted' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Trustworthy artifact',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Trusted'],
|
||||
'notes' => 'The artifact content is fit for the primary operator workflow.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Partial',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Partially complete'],
|
||||
'notes' => 'The artifact exists but key content is incomplete.',
|
||||
],
|
||||
'missing_input' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Missing input',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Missing'],
|
||||
'notes' => 'The artifact is blocked by missing upstream inputs.',
|
||||
],
|
||||
'metadata_only' => [
|
||||
'axis' => 'evidence_depth',
|
||||
'label' => 'Metadata only',
|
||||
'color' => 'info',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Metadata-only'],
|
||||
'notes' => 'Only metadata is available. This is diagnostic context and should not replace the primary truth state.',
|
||||
],
|
||||
'reference_only' => [
|
||||
'axis' => 'evidence_depth',
|
||||
'label' => 'Reference only',
|
||||
'color' => 'info',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Reference-only'],
|
||||
'notes' => 'Only reference placeholders are available. This is diagnostic context and should not replace the primary truth state.',
|
||||
],
|
||||
'empty' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Empty snapshot',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Empty'],
|
||||
'notes' => 'The artifact exists but captured no usable content.',
|
||||
],
|
||||
'unsupported' => [
|
||||
'axis' => 'product_support_maturity',
|
||||
'label' => 'Support limited',
|
||||
'color' => 'gray',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Unsupported'],
|
||||
'notes' => 'The product is representing the source with limited fidelity. This remains diagnostic unless a stronger truth dimension applies.',
|
||||
],
|
||||
],
|
||||
'governance_artifact_freshness' => [
|
||||
'current' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Current',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Fresh'],
|
||||
'notes' => 'The available artifact is current enough for the primary task.',
|
||||
],
|
||||
'stale' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Stale',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Refresh recommended'],
|
||||
'notes' => 'The artifact exists but should be refreshed before relying on it.',
|
||||
],
|
||||
'unknown' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Freshness unknown',
|
||||
'color' => 'gray',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Unknown'],
|
||||
'notes' => 'The system cannot determine freshness from the available payload.',
|
||||
],
|
||||
],
|
||||
'governance_artifact_publication_readiness' => [
|
||||
'not_applicable' => [
|
||||
'axis' => 'publication_readiness',
|
||||
'label' => 'Not applicable',
|
||||
'color' => 'gray',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['N/A'],
|
||||
'notes' => 'Publication readiness does not apply to this artifact family.',
|
||||
],
|
||||
'internal_only' => [
|
||||
'axis' => 'publication_readiness',
|
||||
'label' => 'Internal only',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Draft'],
|
||||
'notes' => 'The artifact is useful internally but not ready for stakeholder delivery.',
|
||||
],
|
||||
'publishable' => [
|
||||
'axis' => 'publication_readiness',
|
||||
'label' => 'Publishable',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Ready'],
|
||||
'notes' => 'The artifact is ready for stakeholder publication or export.',
|
||||
],
|
||||
'blocked' => [
|
||||
'axis' => 'publication_readiness',
|
||||
'label' => 'Blocked',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Not publishable'],
|
||||
'notes' => 'The artifact exists but is blocked from publication or export.',
|
||||
],
|
||||
],
|
||||
'governance_artifact_actionability' => [
|
||||
'none' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'No action needed',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['No follow-up'],
|
||||
'notes' => 'The current non-green state is informational only and does not require action.',
|
||||
],
|
||||
'optional' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Review recommended',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Optional follow-up'],
|
||||
'notes' => 'The artifact can be used, but the operator should review the follow-up guidance.',
|
||||
],
|
||||
'required' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Action required',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Required follow-up'],
|
||||
'notes' => 'The artifact cannot be trusted for the primary task until an operator addresses the issue.',
|
||||
],
|
||||
],
|
||||
'operation_run_status' => [
|
||||
'queued' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
@ -586,6 +785,11 @@ public static function curatedExamples(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'Operation blocked by missing prerequisite', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'blocked'],
|
||||
['name' => 'Artifact exists but is not usable', 'domain' => BadgeDomain::GovernanceArtifactExistence, 'raw_value' => 'created_but_not_usable'],
|
||||
['name' => 'Artifact is trustworthy', 'domain' => BadgeDomain::GovernanceArtifactContent, 'raw_value' => 'trusted'],
|
||||
['name' => 'Artifact is stale', 'domain' => BadgeDomain::GovernanceArtifactFreshness, 'raw_value' => 'stale'],
|
||||
['name' => 'Artifact is publishable', 'domain' => BadgeDomain::GovernanceArtifactPublicationReadiness, 'raw_value' => 'publishable'],
|
||||
['name' => 'Artifact requires action', 'domain' => BadgeDomain::GovernanceArtifactActionability, 'raw_value' => 'required'],
|
||||
['name' => 'Operation completed with follow-up', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'partially_succeeded'],
|
||||
['name' => 'Operation completed successfully', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'succeeded'],
|
||||
['name' => 'Evidence not collected yet', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'missing'],
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
enum OperatorSemanticAxis: string
|
||||
{
|
||||
case ArtifactExistence = 'artifact_existence';
|
||||
case ExecutionLifecycle = 'execution_lifecycle';
|
||||
case ExecutionOutcome = 'execution_outcome';
|
||||
case ItemResult = 'item_result';
|
||||
@ -20,6 +21,7 @@ enum OperatorSemanticAxis: string
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ArtifactExistence => 'Artifact existence',
|
||||
self::ExecutionLifecycle => 'Execution lifecycle',
|
||||
self::ExecutionOutcome => 'Execution outcome',
|
||||
self::ItemResult => 'Item result',
|
||||
@ -36,6 +38,7 @@ public function label(): string
|
||||
public function definition(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ArtifactExistence => 'Whether the intended governance artifact actually exists and can be located.',
|
||||
self::ExecutionLifecycle => 'Where a run sits in its execution flow.',
|
||||
self::ExecutionOutcome => 'What happened when execution finished or stopped.',
|
||||
self::ItemResult => 'How one restore or preview item resolved.',
|
||||
|
||||
@ -105,4 +105,20 @@ public static function allowedSummaryKeys(): array
|
||||
{
|
||||
return OperationSummaryKeys::all();
|
||||
}
|
||||
|
||||
public static function governanceArtifactFamily(string $operationType): ?string
|
||||
{
|
||||
return match (trim($operationType)) {
|
||||
'baseline_capture' => 'baseline_snapshot',
|
||||
'tenant.evidence.snapshot.generate' => 'evidence_snapshot',
|
||||
'tenant.review.compose' => 'tenant_review',
|
||||
'tenant.review_pack.generate' => 'review_pack',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static function isGovernanceArtifactOperation(string $operationType): bool
|
||||
{
|
||||
return self::governanceArtifactFamily($operationType) !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,13 +5,20 @@
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
|
||||
final class OperationRunLinks
|
||||
@ -79,6 +86,14 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
$links['Drift'] = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
if ($run->type === 'baseline_capture') {
|
||||
$snapshotId = data_get($context, 'result.snapshot_id');
|
||||
|
||||
if (is_numeric($snapshotId)) {
|
||||
$links['Baseline Snapshot'] = BaselineSnapshotResource::getUrl('view', ['record' => (int) $snapshotId], panel: 'admin');
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
||||
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
|
||||
@ -101,6 +116,39 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
}
|
||||
|
||||
if ($run->type === 'tenant.evidence.snapshot.generate') {
|
||||
$snapshot = EvidenceSnapshot::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($snapshot instanceof EvidenceSnapshot) {
|
||||
$links['Evidence Snapshot'] = EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
if ($run->type === 'tenant.review.compose') {
|
||||
$review = TenantReview::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($review instanceof TenantReview) {
|
||||
$links['Tenant Review'] = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
if ($run->type === 'tenant.review_pack.generate') {
|
||||
$pack = ReviewPack::query()
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($pack instanceof ReviewPack) {
|
||||
$links['Review Pack'] = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,8 @@
|
||||
|
||||
final class ReasonPresenter
|
||||
{
|
||||
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = ReasonTranslator::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT;
|
||||
|
||||
public function __construct(
|
||||
private readonly ReasonTranslator $reasonTranslator,
|
||||
) {}
|
||||
@ -134,6 +136,22 @@ public function forRbacReason(RbacReason|string|null $reasonCode, string $surfac
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function forArtifactTruth(
|
||||
?string $reasonCode,
|
||||
string $surface = 'detail',
|
||||
array $context = [],
|
||||
): ?ReasonResolutionEnvelope {
|
||||
return $this->reasonTranslator->translate(
|
||||
reasonCode: $reasonCode,
|
||||
artifactKey: self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT,
|
||||
surface: $surface,
|
||||
context: $context,
|
||||
);
|
||||
}
|
||||
|
||||
public function diagnosticCode(?ReasonResolutionEnvelope $envelope): ?string
|
||||
{
|
||||
return $envelope?->diagnosticCode();
|
||||
|
||||
@ -18,6 +18,8 @@ final class ReasonTranslator
|
||||
|
||||
public const string RBAC_ARTIFACT = 'rbac_reason';
|
||||
|
||||
public const string GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT = 'governance_artifact_truth_reason';
|
||||
|
||||
public function __construct(
|
||||
private readonly ProviderReasonTranslator $providerReasonTranslator,
|
||||
private readonly FallbackReasonTranslator $fallbackReasonTranslator,
|
||||
@ -47,6 +49,7 @@ public function translate(
|
||||
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => TenantOperabilityReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === self::RBAC_ARTIFACT,
|
||||
$artifactKey === null && RbacReason::tryFrom($reasonCode) instanceof RbacReason => RbacReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
|
||||
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT => $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context),
|
||||
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
|
||||
default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context),
|
||||
};
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||
|
||||
use App\Support\ReasonTranslation\NextStepOption;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
|
||||
final readonly class ArtifactTruthCause
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $nextSteps
|
||||
*/
|
||||
public function __construct(
|
||||
public ?string $reasonCode,
|
||||
public ?string $translationArtifact,
|
||||
public ?string $operatorLabel,
|
||||
public ?string $shortExplanation,
|
||||
public ?string $diagnosticCode,
|
||||
public array $nextSteps = [],
|
||||
) {}
|
||||
|
||||
public static function fromReasonResolutionEnvelope(
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
?string $translationArtifact = null,
|
||||
): ?self {
|
||||
if (! $reason instanceof ReasonResolutionEnvelope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self(
|
||||
reasonCode: $reason->internalCode,
|
||||
translationArtifact: $translationArtifact,
|
||||
operatorLabel: $reason->operatorLabel,
|
||||
shortExplanation: $reason->shortExplanation,
|
||||
diagnosticCode: $reason->diagnosticCode(),
|
||||
nextSteps: array_values(array_map(
|
||||
static fn (NextStepOption $nextStep): string => $nextStep->label,
|
||||
$reason->nextSteps,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* reasonCode: ?string,
|
||||
* translationArtifact: ?string,
|
||||
* operatorLabel: ?string,
|
||||
* shortExplanation: ?string,
|
||||
* diagnosticCode: ?string,
|
||||
* nextSteps: array<int, string>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'reasonCode' => $this->reasonCode,
|
||||
'translationArtifact' => $this->translationArtifact,
|
||||
'operatorLabel' => $this->operatorLabel,
|
||||
'shortExplanation' => $this->shortExplanation,
|
||||
'diagnosticCode' => $this->diagnosticCode,
|
||||
'nextSteps' => $this->nextSteps,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final readonly class ArtifactTruthDimension
|
||||
{
|
||||
public function __construct(
|
||||
public string $axis,
|
||||
public string $state,
|
||||
public string $label,
|
||||
public string $classification,
|
||||
public ?BadgeDomain $badgeDomain = null,
|
||||
public ?string $badgeState = null,
|
||||
) {}
|
||||
|
||||
public function isPrimary(): bool
|
||||
{
|
||||
return $this->classification === 'primary';
|
||||
}
|
||||
|
||||
public function isDiagnostic(): bool
|
||||
{
|
||||
return $this->classification === 'diagnostic';
|
||||
}
|
||||
|
||||
public function badgeSpec(): ?BadgeSpec
|
||||
{
|
||||
if (! $this->badgeDomain instanceof BadgeDomain || ! is_string($this->badgeState) || trim($this->badgeState) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return \App\Support\Badges\BadgeCatalog::spec($this->badgeDomain, $this->badgeState);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* axis: string,
|
||||
* state: string,
|
||||
* label: string,
|
||||
* classification: string,
|
||||
* badgeDomain: ?string,
|
||||
* badgeState: ?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'axis' => $this->axis,
|
||||
'state' => $this->state,
|
||||
'label' => $this->label,
|
||||
'classification' => $this->classification,
|
||||
'badgeDomain' => $this->badgeDomain?->value,
|
||||
'badgeState' => $this->badgeState,
|
||||
];
|
||||
}
|
||||
}
|
||||
137
app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php
Normal file
137
app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final readonly class ArtifactTruthEnvelope
|
||||
{
|
||||
/**
|
||||
* @param array<int, ArtifactTruthDimension> $dimensions
|
||||
*/
|
||||
public function __construct(
|
||||
public string $artifactFamily,
|
||||
public string $artifactKey,
|
||||
public int $workspaceId,
|
||||
public ?int $tenantId,
|
||||
public ?string $executionOutcome,
|
||||
public string $artifactExistence,
|
||||
public string $contentState,
|
||||
public string $freshnessState,
|
||||
public ?string $publicationReadiness,
|
||||
public string $supportState,
|
||||
public string $actionability,
|
||||
public string $primaryLabel,
|
||||
public ?string $primaryExplanation,
|
||||
public ?string $diagnosticLabel,
|
||||
public ?string $nextActionLabel,
|
||||
public ?string $nextActionUrl,
|
||||
public ?int $relatedRunId,
|
||||
public ?string $relatedArtifactUrl,
|
||||
public array $dimensions = [],
|
||||
public ?ArtifactTruthCause $reason = null,
|
||||
) {}
|
||||
|
||||
public function primaryDimension(): ?ArtifactTruthDimension
|
||||
{
|
||||
foreach ($this->dimensions as $dimension) {
|
||||
if ($dimension instanceof ArtifactTruthDimension && $dimension->isPrimary()) {
|
||||
return $dimension;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function primaryBadgeSpec(): BadgeSpec
|
||||
{
|
||||
$dimension = $this->primaryDimension();
|
||||
|
||||
return $dimension?->badgeSpec() ?? \App\Support\Badges\BadgeSpec::unknown();
|
||||
}
|
||||
|
||||
public function nextStepText(): string
|
||||
{
|
||||
if (is_string($this->nextActionLabel) && trim($this->nextActionLabel) !== '') {
|
||||
return $this->nextActionLabel;
|
||||
}
|
||||
|
||||
return match ($this->actionability) {
|
||||
'none' => 'No action needed',
|
||||
'optional' => 'Review recommended',
|
||||
default => 'Action required',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* artifactFamily: string,
|
||||
* artifactKey: string,
|
||||
* workspaceId: int,
|
||||
* tenantId: ?int,
|
||||
* executionOutcome: ?string,
|
||||
* artifactExistence: string,
|
||||
* contentState: string,
|
||||
* freshnessState: string,
|
||||
* publicationReadiness: ?string,
|
||||
* supportState: string,
|
||||
* actionability: string,
|
||||
* primaryLabel: string,
|
||||
* primaryExplanation: ?string,
|
||||
* diagnosticLabel: ?string,
|
||||
* nextActionLabel: ?string,
|
||||
* nextActionUrl: ?string,
|
||||
* relatedRunId: ?int,
|
||||
* relatedArtifactUrl: ?string,
|
||||
* dimensions: array<int, array{
|
||||
* axis: string,
|
||||
* state: string,
|
||||
* label: string,
|
||||
* classification: string,
|
||||
* badgeDomain: ?string,
|
||||
* badgeState: ?string
|
||||
* }>,
|
||||
* reason: ?array{
|
||||
* reasonCode: ?string,
|
||||
* translationArtifact: ?string,
|
||||
* operatorLabel: ?string,
|
||||
* shortExplanation: ?string,
|
||||
* diagnosticCode: ?string,
|
||||
* nextSteps: array<int, string>
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'artifactFamily' => $this->artifactFamily,
|
||||
'artifactKey' => $this->artifactKey,
|
||||
'workspaceId' => $this->workspaceId,
|
||||
'tenantId' => $this->tenantId,
|
||||
'executionOutcome' => $this->executionOutcome,
|
||||
'artifactExistence' => $this->artifactExistence,
|
||||
'contentState' => $this->contentState,
|
||||
'freshnessState' => $this->freshnessState,
|
||||
'publicationReadiness' => $this->publicationReadiness,
|
||||
'supportState' => $this->supportState,
|
||||
'actionability' => $this->actionability,
|
||||
'primaryLabel' => $this->primaryLabel,
|
||||
'primaryExplanation' => $this->primaryExplanation,
|
||||
'diagnosticLabel' => $this->diagnosticLabel,
|
||||
'nextActionLabel' => $this->nextActionLabel,
|
||||
'nextActionUrl' => $this->nextActionUrl,
|
||||
'relatedRunId' => $this->relatedRunId,
|
||||
'relatedArtifactUrl' => $this->relatedArtifactUrl,
|
||||
'dimensions' => array_values(array_map(
|
||||
static fn (ArtifactTruthDimension $dimension): array => $dimension->toArray(),
|
||||
array_filter(
|
||||
$this->dimensions,
|
||||
static fn (mixed $dimension): bool => $dimension instanceof ArtifactTruthDimension,
|
||||
),
|
||||
)),
|
||||
'reason' => $this->reason?->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,773 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\GovernanceArtifactTruth;
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\TenantReview;
|
||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class ArtifactTruthPresenter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ReasonPresenter $reasonPresenter,
|
||||
) {}
|
||||
|
||||
public function for(mixed $record): ?ArtifactTruthEnvelope
|
||||
{
|
||||
return match (true) {
|
||||
$record instanceof BaselineSnapshot => $this->forBaselineSnapshot($record),
|
||||
$record instanceof EvidenceSnapshot => $this->forEvidenceSnapshot($record),
|
||||
$record instanceof TenantReview => $this->forTenantReview($record),
|
||||
$record instanceof ReviewPack => $this->forReviewPack($record),
|
||||
$record instanceof OperationRun => $this->forOperationRun($record),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
$snapshot->loadMissing('baselineProfile');
|
||||
|
||||
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
|
||||
$hasItems = (int) ($summary['total_items'] ?? 0) > 0;
|
||||
$fidelity = FidelityState::fromSummary($summary, $hasItems);
|
||||
$isHistorical = (int) ($snapshot->baselineProfile?->active_snapshot_id ?? 0) !== (int) $snapshot->getKey()
|
||||
&& $snapshot->baselineProfile !== null;
|
||||
$gapReasons = is_array(Arr::get($summary, 'gaps.by_reason')) ? Arr::get($summary, 'gaps.by_reason') : [];
|
||||
$severeGapReasons = array_filter(
|
||||
$gapReasons,
|
||||
static fn (mixed $count, string $reason): bool => is_numeric($count) && (int) $count > 0 && $reason !== 'meta_fallback',
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
);
|
||||
$reasonCode = $this->firstReasonCode($severeGapReasons);
|
||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
|
||||
$artifactExistence = match (true) {
|
||||
$isHistorical => 'historical_only',
|
||||
! $hasItems => 'created_but_not_usable',
|
||||
default => 'created',
|
||||
};
|
||||
|
||||
$contentState = match ($fidelity) {
|
||||
FidelityState::Full => $severeGapReasons === [] ? 'trusted' : 'partial',
|
||||
FidelityState::Partial => 'partial',
|
||||
FidelityState::ReferenceOnly => 'reference_only',
|
||||
FidelityState::Unsupported => $hasItems ? 'unsupported' : 'empty',
|
||||
};
|
||||
|
||||
if (! $hasItems && $reasonCode !== null) {
|
||||
$contentState = 'missing_input';
|
||||
}
|
||||
|
||||
$freshnessState = $isHistorical ? 'stale' : 'current';
|
||||
$supportState = in_array($contentState, ['reference_only', 'unsupported'], true) ? 'limited_support' : 'normal';
|
||||
$actionability = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'none',
|
||||
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
||||
$freshnessState === 'stale' => 'optional',
|
||||
in_array($contentState, ['reference_only', 'unsupported'], true) => 'optional',
|
||||
default => 'required',
|
||||
};
|
||||
|
||||
[$primaryDomain, $primaryState, $primaryExplanation, $diagnosticLabel] = match (true) {
|
||||
$artifactExistence === 'historical_only' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'historical_only',
|
||||
'This snapshot remains readable for historical comparison, but it is not the current baseline artifact.',
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
],
|
||||
$artifactExistence === 'created_but_not_usable' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'created_but_not_usable',
|
||||
$reason?->shortExplanation ?? 'A snapshot row exists, but it does not contain a trustworthy baseline artifact yet.',
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
],
|
||||
$contentState !== 'trusted' => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
$contentState,
|
||||
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
||||
$supportState === 'limited_support' ? 'Support limited' : null,
|
||||
],
|
||||
default => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
'trusted',
|
||||
'Structured capture content is available for this baseline snapshot.',
|
||||
null,
|
||||
],
|
||||
};
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'baseline_snapshot',
|
||||
artifactKey: 'baseline_snapshot:'.$snapshot->getKey(),
|
||||
workspaceId: (int) $snapshot->workspace_id,
|
||||
tenantId: null,
|
||||
executionOutcome: null,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: $freshnessState,
|
||||
publicationReadiness: null,
|
||||
supportState: $supportState,
|
||||
actionability: $actionability,
|
||||
primaryDomain: $primaryDomain,
|
||||
primaryState: $primaryState,
|
||||
primaryExplanation: $primaryExplanation,
|
||||
diagnosticLabel: $diagnosticLabel,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
match ($actionability) {
|
||||
'required' => 'Inspect the related capture diagnostics before using this snapshot',
|
||||
'optional' => 'Review the capture diagnostics before comparing this snapshot',
|
||||
default => null,
|
||||
},
|
||||
),
|
||||
nextActionUrl: null,
|
||||
relatedRunId: null,
|
||||
relatedArtifactUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'),
|
||||
includePublicationDimension: false,
|
||||
);
|
||||
}
|
||||
|
||||
public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
|
||||
{
|
||||
$snapshot->loadMissing('tenant');
|
||||
|
||||
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
||||
$missingDimensions = (int) ($summary['missing_dimensions'] ?? 0);
|
||||
$staleDimensions = (int) ($summary['stale_dimensions'] ?? 0);
|
||||
$status = (string) $snapshot->status;
|
||||
|
||||
$artifactExistence = match ($status) {
|
||||
'queued', 'generating' => 'not_created',
|
||||
'expired', 'superseded' => 'historical_only',
|
||||
'failed' => 'created_but_not_usable',
|
||||
default => 'created',
|
||||
};
|
||||
|
||||
$contentState = match (true) {
|
||||
$artifactExistence === 'not_created' => 'missing_input',
|
||||
$artifactExistence === 'historical_only' && $snapshot->completeness_state === 'missing' => 'empty',
|
||||
$status === 'failed' => 'missing_input',
|
||||
$snapshot->completeness_state === 'missing' => 'missing_input',
|
||||
$snapshot->completeness_state === 'partial' => 'partial',
|
||||
default => 'trusted',
|
||||
};
|
||||
|
||||
if ((int) ($summary['dimension_count'] ?? 0) === 0 && $artifactExistence !== 'not_created') {
|
||||
$contentState = 'empty';
|
||||
}
|
||||
|
||||
$freshnessState = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'stale',
|
||||
$snapshot->completeness_state === 'stale' || $staleDimensions > 0 => 'stale',
|
||||
in_array($status, ['queued', 'generating'], true) => 'unknown',
|
||||
default => 'current',
|
||||
};
|
||||
|
||||
$actionability = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'none',
|
||||
in_array($status, ['queued', 'generating'], true) => 'optional',
|
||||
$contentState === 'trusted' && $freshnessState === 'current' => 'none',
|
||||
$freshnessState === 'stale' => 'optional',
|
||||
default => 'required',
|
||||
};
|
||||
|
||||
$reasonCode = match (true) {
|
||||
$status === 'failed' => 'evidence_generation_failed',
|
||||
$missingDimensions > 0 => 'evidence_missing_dimensions',
|
||||
$staleDimensions > 0 => 'evidence_stale_dimensions',
|
||||
default => null,
|
||||
};
|
||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
|
||||
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
||||
$artifactExistence === 'not_created' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'not_created',
|
||||
'The evidence generation request exists, but no tenant snapshot is available yet.',
|
||||
],
|
||||
$artifactExistence === 'historical_only' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'historical_only',
|
||||
'This evidence snapshot remains available for history, but it is not the current working evidence artifact.',
|
||||
],
|
||||
$contentState !== 'trusted' => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
$contentState,
|
||||
$reason?->shortExplanation ?? $this->contentExplanation($contentState),
|
||||
],
|
||||
$freshnessState === 'stale' => [
|
||||
BadgeDomain::GovernanceArtifactFreshness,
|
||||
'stale',
|
||||
$reason?->shortExplanation ?? 'The snapshot exists, but one or more evidence dimensions should be refreshed before relying on it.',
|
||||
],
|
||||
default => [
|
||||
BadgeDomain::GovernanceArtifactContent,
|
||||
'trusted',
|
||||
'A current evidence snapshot is available for review work.',
|
||||
],
|
||||
};
|
||||
|
||||
$nextActionUrl = $snapshot->operation_run_id
|
||||
? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id)
|
||||
: null;
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'evidence_snapshot',
|
||||
artifactKey: 'evidence_snapshot:'.$snapshot->getKey(),
|
||||
workspaceId: (int) $snapshot->workspace_id,
|
||||
tenantId: $snapshot->tenant_id !== null ? (int) $snapshot->tenant_id : null,
|
||||
executionOutcome: null,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: $freshnessState,
|
||||
publicationReadiness: null,
|
||||
supportState: 'normal',
|
||||
actionability: $actionability,
|
||||
primaryDomain: $primaryDomain,
|
||||
primaryState: $primaryState,
|
||||
primaryExplanation: $primaryExplanation,
|
||||
diagnosticLabel: $missingDimensions > 0 && $staleDimensions > 0
|
||||
? sprintf('%d missing, %d stale dimensions', $missingDimensions, $staleDimensions)
|
||||
: null,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
match ($actionability) {
|
||||
'required' => 'Refresh evidence before using this snapshot',
|
||||
'optional' => in_array($status, ['queued', 'generating'], true)
|
||||
? 'Wait for evidence generation to finish'
|
||||
: 'Review the evidence freshness before relying on this snapshot',
|
||||
default => null,
|
||||
},
|
||||
),
|
||||
nextActionUrl: $nextActionUrl,
|
||||
relatedRunId: $snapshot->operation_run_id !== null ? (int) $snapshot->operation_run_id : null,
|
||||
relatedArtifactUrl: $snapshot->tenant !== null
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||
: null,
|
||||
includePublicationDimension: false,
|
||||
);
|
||||
}
|
||||
|
||||
public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
|
||||
{
|
||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
$publishBlockers = $review->publishBlockers();
|
||||
$status = $review->statusEnum();
|
||||
$completeness = $review->completenessEnum()->value;
|
||||
$sectionCounts = is_array($summary['section_state_counts'] ?? null) ? $summary['section_state_counts'] : [];
|
||||
$staleSections = (int) ($sectionCounts['stale'] ?? 0);
|
||||
|
||||
$artifactExistence = match ($status) {
|
||||
TenantReviewStatus::Archived, TenantReviewStatus::Superseded => 'historical_only',
|
||||
TenantReviewStatus::Failed => 'created_but_not_usable',
|
||||
default => 'created',
|
||||
};
|
||||
|
||||
$contentState = match ($completeness) {
|
||||
TenantReviewCompletenessState::Complete->value => 'trusted',
|
||||
TenantReviewCompletenessState::Partial->value => 'partial',
|
||||
TenantReviewCompletenessState::Missing->value => 'missing_input',
|
||||
TenantReviewCompletenessState::Stale->value => 'trusted',
|
||||
default => 'partial',
|
||||
};
|
||||
|
||||
$freshnessState = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'stale',
|
||||
$completeness === TenantReviewCompletenessState::Stale->value || $staleSections > 0 => 'stale',
|
||||
default => 'current',
|
||||
};
|
||||
|
||||
$publicationReadiness = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'internal_only',
|
||||
$status === TenantReviewStatus::Published => 'publishable',
|
||||
$publishBlockers !== [] => 'blocked',
|
||||
$status === TenantReviewStatus::Ready => 'publishable',
|
||||
default => 'internal_only',
|
||||
};
|
||||
|
||||
$actionability = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'none',
|
||||
$publicationReadiness === 'publishable' && $freshnessState === 'current' => 'none',
|
||||
$publicationReadiness === 'internal_only' && $contentState === 'trusted' => 'optional',
|
||||
$freshnessState === 'stale' && $publishBlockers === [] => 'optional',
|
||||
default => 'required',
|
||||
};
|
||||
|
||||
$reasonCode = match (true) {
|
||||
$publishBlockers !== [] => 'review_publish_blocked',
|
||||
$status === TenantReviewStatus::Failed => 'review_generation_failed',
|
||||
$completeness === TenantReviewCompletenessState::Missing->value => 'review_missing_sections',
|
||||
$completeness === TenantReviewCompletenessState::Stale->value => 'review_stale_sections',
|
||||
default => null,
|
||||
};
|
||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
|
||||
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
||||
$artifactExistence === 'historical_only' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'historical_only',
|
||||
'This review remains available as historical evidence, but it is no longer the current review artifact.',
|
||||
],
|
||||
$publicationReadiness === 'blocked' => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'blocked',
|
||||
$publishBlockers[0] ?? $reason?->shortExplanation ?? 'This review exists, but it is blocked from publication or export.',
|
||||
],
|
||||
$publicationReadiness === 'internal_only' => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'internal_only',
|
||||
'This review exists and is useful internally, but it is not yet ready for stakeholder publication.',
|
||||
],
|
||||
$freshnessState === 'stale' => [
|
||||
BadgeDomain::GovernanceArtifactFreshness,
|
||||
'stale',
|
||||
$reason?->shortExplanation ?? 'The review exists, but one or more required sections should be refreshed before publication.',
|
||||
],
|
||||
default => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'publishable',
|
||||
'This review is ready for publication and executive-pack export.',
|
||||
],
|
||||
};
|
||||
|
||||
$nextActionUrl = $review->operation_run_id
|
||||
? OperationRunLinks::tenantlessView((int) $review->operation_run_id)
|
||||
: null;
|
||||
|
||||
if ($publishBlockers !== [] && $review->tenant !== null) {
|
||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant);
|
||||
}
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'tenant_review',
|
||||
artifactKey: 'tenant_review:'.$review->getKey(),
|
||||
workspaceId: (int) $review->workspace_id,
|
||||
tenantId: $review->tenant_id !== null ? (int) $review->tenant_id : null,
|
||||
executionOutcome: null,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: $freshnessState,
|
||||
publicationReadiness: $publicationReadiness,
|
||||
supportState: 'normal',
|
||||
actionability: $actionability,
|
||||
primaryDomain: $primaryDomain,
|
||||
primaryState: $primaryState,
|
||||
primaryExplanation: $primaryExplanation,
|
||||
diagnosticLabel: $contentState !== 'trusted'
|
||||
? BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, $contentState)->label
|
||||
: null,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
match ($actionability) {
|
||||
'required' => 'Resolve the review blockers before publication',
|
||||
'optional' => 'Complete the remaining review work before publication',
|
||||
default => null,
|
||||
},
|
||||
),
|
||||
nextActionUrl: $nextActionUrl,
|
||||
relatedRunId: $review->operation_run_id !== null ? (int) $review->operation_run_id : null,
|
||||
relatedArtifactUrl: $review->tenant !== null
|
||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $review->tenant)
|
||||
: null,
|
||||
includePublicationDimension: true,
|
||||
);
|
||||
}
|
||||
|
||||
public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
|
||||
{
|
||||
$pack->loadMissing(['tenant', 'tenantReview']);
|
||||
|
||||
$summary = is_array($pack->summary) ? $pack->summary : [];
|
||||
$status = (string) $pack->status;
|
||||
$evidenceResolution = is_array($summary['evidence_resolution'] ?? null) ? $summary['evidence_resolution'] : [];
|
||||
$sourceReview = $pack->tenantReview;
|
||||
$sourceBlockers = $sourceReview instanceof TenantReview ? $sourceReview->publishBlockers() : [];
|
||||
$sourceReviewStatus = $sourceReview instanceof TenantReview ? $sourceReview->statusEnum() : null;
|
||||
|
||||
$artifactExistence = match ($status) {
|
||||
ReviewPackStatus::Queued->value, ReviewPackStatus::Generating->value => 'not_created',
|
||||
ReviewPackStatus::Expired->value => 'historical_only',
|
||||
ReviewPackStatus::Failed->value => 'created_but_not_usable',
|
||||
default => 'created',
|
||||
};
|
||||
|
||||
$contentState = match (true) {
|
||||
$artifactExistence === 'not_created' => 'missing_input',
|
||||
$status === ReviewPackStatus::Failed->value => 'missing_input',
|
||||
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'missing_input',
|
||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'partial',
|
||||
default => 'trusted',
|
||||
};
|
||||
|
||||
$freshnessState = $artifactExistence === 'historical_only' ? 'stale' : 'current';
|
||||
$publicationReadiness = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'internal_only',
|
||||
$artifactExistence === 'not_created' => 'blocked',
|
||||
$status === ReviewPackStatus::Failed->value => 'blocked',
|
||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'blocked',
|
||||
$sourceReviewStatus === TenantReviewStatus::Draft || $sourceReviewStatus === TenantReviewStatus::Failed => 'internal_only',
|
||||
default => $status === ReviewPackStatus::Ready->value ? 'publishable' : 'blocked',
|
||||
};
|
||||
|
||||
$actionability = match (true) {
|
||||
$artifactExistence === 'historical_only' => 'none',
|
||||
$publicationReadiness === 'publishable' => 'none',
|
||||
$publicationReadiness === 'internal_only' => 'optional',
|
||||
default => 'required',
|
||||
};
|
||||
|
||||
$reasonCode = match (true) {
|
||||
$status === ReviewPackStatus::Failed->value => 'review_pack_generation_failed',
|
||||
($evidenceResolution['outcome'] ?? null) !== 'resolved' => 'review_pack_missing_snapshot',
|
||||
$sourceReview instanceof TenantReview && $sourceBlockers !== [] => 'review_pack_source_not_publishable',
|
||||
$artifactExistence === 'historical_only' => 'review_pack_expired',
|
||||
default => null,
|
||||
};
|
||||
$reason = $this->reasonPresenter->forArtifactTruth($reasonCode, 'artifact_truth');
|
||||
|
||||
[$primaryDomain, $primaryState, $primaryExplanation] = match (true) {
|
||||
$artifactExistence === 'historical_only' => [
|
||||
BadgeDomain::GovernanceArtifactExistence,
|
||||
'historical_only',
|
||||
'This pack remains available as a historical export, but it is no longer the current stakeholder artifact.',
|
||||
],
|
||||
$publicationReadiness === 'blocked' => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'blocked',
|
||||
$sourceBlockers[0] ?? $reason?->shortExplanation ?? 'A pack file is not yet available for trustworthy stakeholder delivery.',
|
||||
],
|
||||
$publicationReadiness === 'internal_only' => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'internal_only',
|
||||
'This pack can be reviewed internally, but the source review is not currently publishable.',
|
||||
],
|
||||
default => [
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
'publishable',
|
||||
'This executive pack is ready for stakeholder delivery.',
|
||||
],
|
||||
};
|
||||
|
||||
$nextActionUrl = null;
|
||||
|
||||
if ($sourceReview instanceof TenantReview && $pack->tenant !== null) {
|
||||
$nextActionUrl = TenantReviewResource::tenantScopedUrl('view', ['record' => $sourceReview], $pack->tenant);
|
||||
} elseif ($pack->operation_run_id !== null) {
|
||||
$nextActionUrl = OperationRunLinks::tenantlessView((int) $pack->operation_run_id);
|
||||
}
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'review_pack',
|
||||
artifactKey: 'review_pack:'.$pack->getKey(),
|
||||
workspaceId: (int) $pack->workspace_id,
|
||||
tenantId: $pack->tenant_id !== null ? (int) $pack->tenant_id : null,
|
||||
executionOutcome: null,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: $freshnessState,
|
||||
publicationReadiness: $publicationReadiness,
|
||||
supportState: 'normal',
|
||||
actionability: $actionability,
|
||||
primaryDomain: $primaryDomain,
|
||||
primaryState: $primaryState,
|
||||
primaryExplanation: $primaryExplanation,
|
||||
diagnosticLabel: $contentState !== 'trusted'
|
||||
? BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, $contentState)->label
|
||||
: null,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
match ($actionability) {
|
||||
'required' => 'Open the source review before sharing this pack',
|
||||
'optional' => 'Review the source review before sharing this pack',
|
||||
default => null,
|
||||
},
|
||||
),
|
||||
nextActionUrl: $nextActionUrl,
|
||||
relatedRunId: $pack->operation_run_id !== null ? (int) $pack->operation_run_id : null,
|
||||
relatedArtifactUrl: $pack->tenant !== null
|
||||
? ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant)
|
||||
: null,
|
||||
includePublicationDimension: true,
|
||||
);
|
||||
}
|
||||
|
||||
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
|
||||
{
|
||||
$artifact = $this->resolveArtifactForRun($run);
|
||||
$reason = $this->reasonPresenter->forOperationRun($run, 'run_detail');
|
||||
|
||||
if ($artifact !== null) {
|
||||
$artifactEnvelope = $this->for($artifact);
|
||||
|
||||
if ($artifactEnvelope instanceof ArtifactTruthEnvelope) {
|
||||
$diagnosticParts = array_values(array_filter([
|
||||
BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label !== 'Unknown'
|
||||
? BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label
|
||||
: null,
|
||||
$artifactEnvelope->diagnosticLabel,
|
||||
]));
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'artifact_run',
|
||||
artifactKey: 'artifact_run:'.$run->getKey(),
|
||||
workspaceId: (int) $run->workspace_id,
|
||||
tenantId: $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||
executionOutcome: $run->outcome !== null ? (string) $run->outcome : null,
|
||||
artifactExistence: $artifactEnvelope->artifactExistence,
|
||||
contentState: $artifactEnvelope->contentState,
|
||||
freshnessState: $artifactEnvelope->freshnessState,
|
||||
publicationReadiness: $artifactEnvelope->publicationReadiness,
|
||||
supportState: $artifactEnvelope->supportState,
|
||||
actionability: $artifactEnvelope->actionability,
|
||||
primaryDomain: $artifactEnvelope->primaryDimension()?->badgeDomain ?? BadgeDomain::GovernanceArtifactExistence,
|
||||
primaryState: $artifactEnvelope->primaryDimension()?->badgeState ?? $artifactEnvelope->artifactExistence,
|
||||
primaryExplanation: $artifactEnvelope->primaryExplanation ?? $reason?->shortExplanation ?? 'The run finished, but the related artifact needs review.',
|
||||
diagnosticLabel: $diagnosticParts === [] ? null : implode(' · ', $diagnosticParts),
|
||||
reason: $artifactEnvelope->reason,
|
||||
nextActionLabel: $artifactEnvelope->nextActionLabel,
|
||||
nextActionUrl: $artifactEnvelope->relatedArtifactUrl ?? $artifactEnvelope->nextActionUrl,
|
||||
relatedRunId: (int) $run->getKey(),
|
||||
relatedArtifactUrl: $artifactEnvelope->relatedArtifactUrl,
|
||||
includePublicationDimension: $artifactEnvelope->publicationReadiness !== null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$artifactExistence = match ((string) $run->status) {
|
||||
OperationRunStatus::Queued->value, OperationRunStatus::Running->value => 'not_created',
|
||||
default => 'not_created',
|
||||
};
|
||||
$contentState = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
||||
? 'missing_input'
|
||||
: 'empty';
|
||||
$actionability = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
||||
? 'required'
|
||||
: 'optional';
|
||||
$primaryState = in_array((string) $run->outcome, [OperationRunOutcome::Blocked->value, OperationRunOutcome::Failed->value], true)
|
||||
? 'created_but_not_usable'
|
||||
: 'not_created';
|
||||
|
||||
return $this->makeEnvelope(
|
||||
artifactFamily: 'artifact_run',
|
||||
artifactKey: 'artifact_run:'.$run->getKey(),
|
||||
workspaceId: (int) $run->workspace_id,
|
||||
tenantId: $run->tenant_id !== null ? (int) $run->tenant_id : null,
|
||||
executionOutcome: $run->outcome !== null ? (string) $run->outcome : null,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: 'unknown',
|
||||
publicationReadiness: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|
||||
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review'
|
||||
? 'blocked'
|
||||
: null,
|
||||
supportState: 'normal',
|
||||
actionability: $actionability,
|
||||
primaryDomain: BadgeDomain::GovernanceArtifactExistence,
|
||||
primaryState: $primaryState,
|
||||
primaryExplanation: $reason?->shortExplanation ?? match ((string) $run->status) {
|
||||
OperationRunStatus::Queued->value, OperationRunStatus::Running->value => 'The artifact-producing run is still in progress, so no artifact is available yet.',
|
||||
default => 'The run finished without a usable artifact result.',
|
||||
},
|
||||
diagnosticLabel: BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $run->outcome)->label,
|
||||
reason: ArtifactTruthCause::fromReasonResolutionEnvelope($reason, ReasonPresenter::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
|
||||
nextActionLabel: $this->nextActionLabel(
|
||||
$actionability,
|
||||
$reason,
|
||||
$actionability === 'required'
|
||||
? 'Inspect the blocked run details before retrying'
|
||||
: 'Wait for the artifact-producing run to finish',
|
||||
),
|
||||
nextActionUrl: null,
|
||||
relatedRunId: (int) $run->getKey(),
|
||||
relatedArtifactUrl: null,
|
||||
includePublicationDimension: OperationCatalog::governanceArtifactFamily((string) $run->type) === 'review_pack'
|
||||
|| OperationCatalog::governanceArtifactFamily((string) $run->type) === 'tenant_review',
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveArtifactForRun(OperationRun $run): BaselineSnapshot|EvidenceSnapshot|TenantReview|ReviewPack|null
|
||||
{
|
||||
return match (OperationCatalog::governanceArtifactFamily((string) $run->type)) {
|
||||
'baseline_snapshot' => $run->relatedArtifactId() !== null
|
||||
? BaselineSnapshot::query()->with('baselineProfile')->find($run->relatedArtifactId())
|
||||
: null,
|
||||
'evidence_snapshot' => EvidenceSnapshot::query()->with('tenant')->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
||||
'tenant_review' => TenantReview::query()->with(['tenant', 'currentExportReviewPack'])->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
||||
'review_pack' => ReviewPack::query()->with(['tenant', 'tenantReview'])->where('operation_run_id', (int) $run->getKey())->latest('id')->first(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function contentExplanation(string $contentState): string
|
||||
{
|
||||
return match ($contentState) {
|
||||
'partial' => 'The artifact exists, but the captured content is incomplete for the primary operator task.',
|
||||
'missing_input' => 'The artifact is blocked by missing upstream inputs or failed capture prerequisites.',
|
||||
'metadata_only' => 'Only metadata was captured for this artifact. Use diagnostics for context, not as the primary truth signal.',
|
||||
'reference_only' => 'Only reference-level placeholders were captured for this artifact.',
|
||||
'empty' => 'The artifact row exists, but it does not contain usable captured content.',
|
||||
'unsupported' => 'Structured support is limited for this artifact family, so the current rendering should be treated as diagnostic only.',
|
||||
default => 'The artifact content is available for the intended workflow.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $reasons
|
||||
*/
|
||||
private function firstReasonCode(array $reasons): ?string
|
||||
{
|
||||
foreach ($reasons as $reason => $count) {
|
||||
if ((int) $count > 0 && is_string($reason) && trim($reason) !== '') {
|
||||
return trim($reason);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function nextActionLabel(
|
||||
string $actionability,
|
||||
?ReasonResolutionEnvelope $reason,
|
||||
?string $fallback = null,
|
||||
): ?string {
|
||||
if ($actionability === 'none') {
|
||||
return 'No action needed';
|
||||
}
|
||||
|
||||
if (is_string($fallback) && trim($fallback) !== '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if ($reason instanceof ReasonResolutionEnvelope && $reason->firstNextStep() !== null) {
|
||||
return $reason->firstNextStep()?->label;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function makeEnvelope(
|
||||
string $artifactFamily,
|
||||
string $artifactKey,
|
||||
int $workspaceId,
|
||||
?int $tenantId,
|
||||
?string $executionOutcome,
|
||||
string $artifactExistence,
|
||||
string $contentState,
|
||||
string $freshnessState,
|
||||
?string $publicationReadiness,
|
||||
string $supportState,
|
||||
string $actionability,
|
||||
BadgeDomain $primaryDomain,
|
||||
string $primaryState,
|
||||
?string $primaryExplanation,
|
||||
?string $diagnosticLabel,
|
||||
?ArtifactTruthCause $reason,
|
||||
?string $nextActionLabel,
|
||||
?string $nextActionUrl,
|
||||
?int $relatedRunId,
|
||||
?string $relatedArtifactUrl,
|
||||
bool $includePublicationDimension,
|
||||
): ArtifactTruthEnvelope {
|
||||
$primarySpec = BadgeCatalog::spec($primaryDomain, $primaryState);
|
||||
$dimensions = [
|
||||
$this->dimension(BadgeDomain::GovernanceArtifactExistence, $artifactExistence, 'artifact_existence', $primaryDomain === BadgeDomain::GovernanceArtifactExistence ? 'primary' : 'diagnostic'),
|
||||
$this->dimension(BadgeDomain::GovernanceArtifactContent, $contentState, 'content_fidelity', $primaryDomain === BadgeDomain::GovernanceArtifactContent ? 'primary' : 'diagnostic'),
|
||||
$this->dimension(BadgeDomain::GovernanceArtifactFreshness, $freshnessState, 'data_freshness', $primaryDomain === BadgeDomain::GovernanceArtifactFreshness ? 'primary' : 'diagnostic'),
|
||||
$this->dimension(BadgeDomain::GovernanceArtifactActionability, $actionability, 'operator_actionability', 'diagnostic'),
|
||||
];
|
||||
|
||||
if ($includePublicationDimension && $publicationReadiness !== null) {
|
||||
$dimensions[] = $this->dimension(
|
||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
||||
$publicationReadiness,
|
||||
'publication_readiness',
|
||||
$primaryDomain === BadgeDomain::GovernanceArtifactPublicationReadiness ? 'primary' : 'diagnostic',
|
||||
);
|
||||
}
|
||||
|
||||
if ($executionOutcome !== null && trim($executionOutcome) !== '') {
|
||||
$dimensions[] = $this->dimension(BadgeDomain::OperationRunOutcome, $executionOutcome, 'execution_outcome', 'diagnostic');
|
||||
}
|
||||
|
||||
if ($supportState === 'limited_support') {
|
||||
$dimensions[] = new ArtifactTruthDimension(
|
||||
axis: 'support_maturity',
|
||||
state: 'limited_support',
|
||||
label: 'Support limited',
|
||||
classification: 'diagnostic',
|
||||
badgeDomain: BadgeDomain::GovernanceArtifactContent,
|
||||
badgeState: 'unsupported',
|
||||
);
|
||||
}
|
||||
|
||||
return new ArtifactTruthEnvelope(
|
||||
artifactFamily: $artifactFamily,
|
||||
artifactKey: $artifactKey,
|
||||
workspaceId: $workspaceId,
|
||||
tenantId: $tenantId,
|
||||
executionOutcome: $executionOutcome,
|
||||
artifactExistence: $artifactExistence,
|
||||
contentState: $contentState,
|
||||
freshnessState: $freshnessState,
|
||||
publicationReadiness: $publicationReadiness,
|
||||
supportState: $supportState,
|
||||
actionability: $actionability,
|
||||
primaryLabel: $primarySpec->label,
|
||||
primaryExplanation: $primaryExplanation,
|
||||
diagnosticLabel: $diagnosticLabel,
|
||||
nextActionLabel: $nextActionLabel,
|
||||
nextActionUrl: $nextActionUrl,
|
||||
relatedRunId: $relatedRunId,
|
||||
relatedArtifactUrl: $relatedArtifactUrl,
|
||||
dimensions: array_values($dimensions),
|
||||
reason: $reason,
|
||||
);
|
||||
}
|
||||
|
||||
private function dimension(
|
||||
BadgeDomain $domain,
|
||||
string $state,
|
||||
string $axis,
|
||||
string $classification,
|
||||
): ArtifactTruthDimension {
|
||||
return new ArtifactTruthDimension(
|
||||
axis: $axis,
|
||||
state: $state,
|
||||
label: BadgeCatalog::spec($domain, $state)->label,
|
||||
classification: $classification,
|
||||
badgeDomain: $domain,
|
||||
badgeState: $state,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@ # Product Roadmap
|
||||
> Strategic thematic blocks and release trajectory.
|
||||
> This is the "big picture" — not individual specs.
|
||||
|
||||
**Last updated**: 2026-03-21
|
||||
**Last updated**: 2026-03-23
|
||||
|
||||
---
|
||||
|
||||
@ -26,7 +26,7 @@ ### Governance & Architecture Hardening
|
||||
|
||||
**Active specs**: 144
|
||||
**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction
|
||||
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Provider Dispatch Gate Unification (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
|
||||
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Artifact Truth Semantics → Governance Operator Outcome Compression; Provider Dispatch Gate Unification continues as the adjacent hardening lane (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
|
||||
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
||||
|
||||
### UI & Product Maturity Polish
|
||||
|
||||
@ -3,9 +3,9 @@ # Spec Candidates
|
||||
> Concrete future specs waiting for prioritization.
|
||||
> Each entry has enough structure to become a real spec when the time comes.
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
**Last reviewed**: 2026-03-21 (operator semantic taxonomy, semantic-clarity domain follow-ups, OperationRun humanization candidate, and absorbed extension targets updated)
|
||||
**Last reviewed**: 2026-03-23 (added governance operator outcome compression follow-up; promoted Spec 158 into ledger)
|
||||
|
||||
---
|
||||
|
||||
@ -27,28 +27,23 @@ ## Inbox
|
||||
|
||||
---
|
||||
|
||||
## Promoted to Spec
|
||||
|
||||
> Historical ledger for candidates that are no longer open. Keep them here so prioritization stays clean without losing decision history.
|
||||
|
||||
- Queued Execution Reauthorization and Scope Continuity → Spec 149 (`queued-execution-reauthorization`)
|
||||
- Livewire Context Locking and Trusted-State Reduction → Spec 152 (`livewire-context-locking`)
|
||||
- Evidence Domain Foundation → Spec 153 (`evidence-domain-foundation`)
|
||||
- Operator Outcome Taxonomy and Cross-Domain State Separation → Spec 156 (`operator-outcome-taxonomy`)
|
||||
- Operator Reason Code Translation and Humanization Contract → Spec 157 (`reason-code-translation`)
|
||||
- Governance Artifact Truthful Outcomes & Fidelity Semantics → Spec 158 (`artifact-truth-semantics`)
|
||||
|
||||
---
|
||||
|
||||
## Qualified
|
||||
|
||||
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
|
||||
|
||||
### Queued Execution Reauthorization and Scope Continuity
|
||||
- **Type**: hardening
|
||||
- **Source**: architecture audit 2026-03-15
|
||||
- **Problem**: Queued work still relies too heavily on dispatch-time actor and tenant state. Execution-time scope continuity and capability revalidation are not yet hardened as a canonical backend contract.
|
||||
- **Why it matters**: This is a backend trust-gap on the mutation path. It creates the class of failure where a UI action was valid at dispatch time but the queued execution is no longer legitimate when it runs.
|
||||
- **Proposed direction**: Define execution-time reauthorization, tenant operability rechecks, denial semantics, and audit visibility as a dedicated spec instead of scattering local `authorize()` patches.
|
||||
- **Dependencies**: Existing operations semantics, audit log foundation, queued job execution paths
|
||||
- **Priority**: high
|
||||
|
||||
### Livewire Context Locking and Trusted-State Reduction
|
||||
- **Type**: hardening
|
||||
- **Source**: architecture audit 2026-03-15
|
||||
- **Problem**: Complex Livewire and Filament flows still expose ownership-relevant context in public component state without one explicit repo-wide hardening standard.
|
||||
- **Why it matters**: This is a trust-boundary problem. Even without a known exploit, mutable client-visible identifiers and workflow context make future authorization and isolation mistakes more likely.
|
||||
- **Proposed direction**: Define a reusable hardening pattern for locked identifiers, server-derived workflow truth, and forged-state regression tests on tier-1 component families.
|
||||
- **Dependencies**: Managed tenant onboarding draft identity (Spec 138), onboarding lifecycle checkpoint work (Spec 140)
|
||||
- **Priority**: medium
|
||||
|
||||
### Tenant Draft Discard Lifecycle and Orphaned Draft Visibility
|
||||
- **Type**: hardening
|
||||
- **Source**: domain architecture analysis 2026-03-16 — tenant lifecycle vs onboarding workflow lifecycle review
|
||||
@ -112,77 +107,6 @@ ### Baseline Capture Truthful Outcomes and Upstream Guardrails
|
||||
- If product wants stale successful inventory fallback instead of strict "latest credible only", that needs an explicit rule rather than hidden fallback behavior.
|
||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
|
||||
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116–119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
|
||||
- **Priority**: high
|
||||
|
||||
### Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||
- **Type**: foundation / hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis
|
||||
- **Problem**: TenantPilot uses ~12 overloaded words ("failed", "partial", "missing", "gaps", "unsupported", "stale", "blocked", "complete", "ready", "reference only", "metadata only", "needs attention") across at least 8 independent meaning axes that are systematically conflated. The product does not separate execution outcome from data completeness, evidence depth from product support maturity, governance deviation from publication readiness, data freshness from operator actionability. This produces a cross-product operator-trust problem: approximately 60% of warning-colored badges communicate something that is NOT a governance problem. Examples:
|
||||
- Baseline snapshots show "Unsupported" (gray badge) and "Gaps present" (yellow badge) for policy types where the product simply uses a standard renderer — this is a product maturity fact, not a data quality failure, but it reads as a governance concern.
|
||||
- Evidence completeness shows "Missing" (red/danger) when no findings exist yet — zero findings is a valid empty state, not missing evidence, but a new tenant permanently displays red badges.
|
||||
- Restore runs show "Partial" (yellow) at both run and item level with different meanings — operators cannot determine scope of partial success or whether the tenant is in a consistent state.
|
||||
- `OperationRunOutcome::PartiallySucceeded` provides no item-level breakdown — "99 of 100 succeeded" and "1 of 100 succeeded" are visually identical.
|
||||
- "Blocked" appears across 4+ domains (operations, verification, restore, execution) without cause-specific explanation or next-action guidance.
|
||||
- "Stale" is colored gray (passive/archived) when it actually represents data that requires operator attention (freshness issue, should be yellow/orange).
|
||||
- Product support tier (e.g. fallback renderer vs. dedicated renderer) is surfaced on operator-facing badges where it should be diagnostics-only.
|
||||
- **Why it matters now**: This is not cosmetic polish — it is a governance credibility problem. TenantPilot's core value proposition is trustworthy tenant governance and review. When 60% of warning badges are false alarms, operators are trained to ignore all warnings, which then masks the badges that represent real governance gaps. Every new domain (Entra Role Governance, Enterprise App Governance, Evidence Domain) will reproduce this conflation pattern unless a shared taxonomy is established first. The semantic-clarity audit classified three of the top five findings as P0 (actively damages operator trust). The problem is systemic and cross-domain — it cannot be solved by individual surface fixes without a shared foundation. The existing badge infrastructure (`BadgeCatalog`, `BadgeRenderer`, 43 badge domains, ~500 case values) is architecturally sound; the taxonomy feeding it is structurally wrong.
|
||||
- **Proposed direction**:
|
||||
- **Define mandatory state-axis separation**: establish at minimum 8 independent axes that must never be flattened into a single badge or enum: execution lifecycle, execution outcome, item-level result, data coverage, evidence depth, product support tier, data freshness, and operator actionability. Each axis has its own vocabulary, its own badge domain, and its own color rules.
|
||||
- **Cross-domain term dictionary**: produce a canonical vocabulary where each term has exactly one meaning across the entire product. Replace the 12 overloaded terms with axis-specific alternatives (e.g. "Partially succeeded" → item-breakdown-aware messaging; "Missing" → "Not collected" / "Not generated" / "Not granted" depending on axis; "Gaps" → categorized coverage notes with cause separation; "Unsupported" → "Standard rendering" moved to diagnostics only; "Stale" → freshness axis with correct severity color).
|
||||
- **Color-severity decision rules**: codify when red/yellow/blue/green/gray are appropriate. Red = execution failure, governance violation, data loss risk. Yellow = operator action recommended, approaching threshold, mixed outcome. Blue = in-progress, informational. Green = succeeded, complete. Gray = archived, not applicable. Never use yellow for product maturity facts. Never use gray for freshness issues. Never use red for valid-empty states.
|
||||
- **Diagnostic vs. primary classification**: every piece of state information must be classified as either primary (operator-facing badge/summary) or diagnostic (expandable/secondary technical detail). Product support tier, raw reason codes, Graph API error codes, internal IDs, and renderer metadata are diagnostic-only. Execution outcome, governance status, data freshness, and operator next-actions are primary.
|
||||
- **Mandatory next-action rule**: every non-green, non-gray state must include either an inline explanation of what happened and whether action is needed, a link to a resolution path, or an explicit "No action needed — this is expected" indicator. States that fail this rule are treated as incomplete operator surfaces.
|
||||
- **Shared reference document**: produce `docs/ui/operator-semantic-taxonomy.md` (or equivalent) that all domain specs, badge mappers, and new surface implementations reference. This becomes the cross-domain truth source for operator-facing state presentation.
|
||||
- **Key domain decisions to encode**:
|
||||
- Product support maturity (renderer tier, capture mode) is NEVER operator-alarming — it belongs in diagnostics
|
||||
- Valid empty states (zero findings, zero operations, no evidence yet) are NEVER "Missing" (red) — they are "Not yet collected" (neutral) or "Empty" (informational)
|
||||
- Freshness is a separate axis from completeness — stale data requires action (yellow/orange), not archival (gray)
|
||||
- "Partial" must always be qualified: partial execution (N of M items), partial coverage (which dimensions), partial depth (which items) — never bare "Partial" without context
|
||||
- "Blocked" must always specify cause and next action
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: state-axis separation rules, term dictionary, color-severity rules, diagnostic/primary classification, next-action policy, enum/badge restructuring guidance, shared reference document, domain adoption sequence recommendations
|
||||
- **Out of scope**: individual domain adoption (baseline cleanup, evidence reclassification, restore semantic cleanup, etc. are separate domain follow-up specs that consume this foundation), badge rendering infrastructure changes (BadgeCatalog/BadgeRenderer are architecturally sound — the taxonomy they consume is the problem), visual design system or theme work, new component development, operation naming vocabulary (tracked separately as "Operations Naming Harmonization")
|
||||
- **Why this should be one coherent candidate rather than fragmented domain fixes**: The semantic-clarity audit proves the problem is structural, not local. The same 12 overloaded terms leak into every domain independently. Fixing baselines without a shared taxonomy produces a different vocabulary than fixing evidence, which produces a different vocabulary than fixing operations. Domain-by-domain cleanup without a shared foundation guarantees vocabulary drift between domains. This foundation spec defines rules; domain specs apply them. The foundation is small and decisive (it produces a reference document and restructuring guidelines); the domain adoption specs do the actual refactoring.
|
||||
- **Affected workflow families / surfaces**: Operations (all run types), Baselines (snapshots, profiles, compare), Evidence (snapshots, completeness), Findings (governance validity, diff messages), Reviews / Review Packs (completeness, freshness, publication readiness), Restore (run status, item results, preview decisions), Inventory (KPI badges, coverage, snapshot mode), Onboarding / Verification (report status, check status), Alerts / Notifications (delivery status, failure messages)
|
||||
- **Dependencies**: None — this is foundational work that other candidates consume. The badge infrastructure (`BadgeCatalog`, `BadgeRenderer`) is stable and does not need changes — only the taxonomy it serves.
|
||||
- **Related specs / candidates**: Baseline Capture Truthful Outcomes (consumes this taxonomy for baseline-specific reason codes), Operator Presentation & Lifecycle Action Hardening (complementary — rendering enforcement), Operations Naming Harmonization (complementary — operation type vocabulary), Surface Signal-to-Noise Optimization (complementary — visual weight hierarchy), Admin Visual Language Canon (broader visual convention codification), semantic-clarity-audit.md (source audit)
|
||||
- **Strategic sequencing**: This is the recommended FIRST candidate in the operator-truth initiative sequence. The Reason Code Translation candidate depends on this taxonomy to define human-readable label targets. The Provider Dispatch Gate candidate benefits from shared outcome vocabulary for preflight results. Without this foundation, both downstream candidates will invent local vocabularies.
|
||||
- **Priority**: high
|
||||
|
||||
### Operator Reason Code Translation and Humanization Contract
|
||||
- **Type**: hardening
|
||||
- **Source**: semantic clarity & operator-language audit 2026-03-21, prerequisite-handling architecture analysis, cross-domain reason code inventory
|
||||
- **Problem**: TenantPilot has 6 distinct reason-code artifacts across 4 different structural patterns (final class with string constants, backed enum with `->message()`, backed enum without `->message()`, spec-level taxonomy) spread across provider, baseline, execution, operability, RBAC, and verification domains. These reason codes are the backend's primary mechanism for explaining why an operation was blocked, denied, degraded, or failed. But the product lacks a consistent translation/humanization contract for surfacing these codes to operators:
|
||||
- `ProviderReasonCodes` (24 codes) and `BaselineReasonCodes` (7 codes) are raw string constants with **no human-readable translation** — they leak directly into run detail contexts, notifications, and banners as technical fragments like `RateLimited`, `ProviderAuthFailed`, `ConsentNotGranted`, `CredentialsInvalid`.
|
||||
- `TenantOperabilityReasonCode` (10 cases) and `RbacReason` (10 cases) are backed enums with **no `->message()` method** — they can reach operator-facing surfaces as raw enum values without translation.
|
||||
- `BaselineCompareReasonCode` (5 cases) and `ExecutionDenialReasonCode` (9 cases) have inline `->message()` methods with hardcoded English strings — better, but inconsistent with the rest.
|
||||
- `RunFailureSanitizer` already performs ad-hoc normalization across reason taxonomies using heuristic string-matching and its own `REASON_*` constants, proving the need for a systematic approach.
|
||||
- `ProviderNextStepsRegistry` maps some provider reason codes to link-only remediation steps — but this is limited to provider-domain codes and provides navigation links, not human-readable explanations.
|
||||
- Notification payloads (`OperationRunQueued`, `OperationRunCompleted`, `OperationUxPresenter`) include sanitized but still technical reason strings — operators receive "Execution was blocked. Rate limited." without retry guidance or contextual explanation.
|
||||
- Run summary counts expose raw internal keys (`errors_recorded`, `report_deduped`, `posture_score`) in operator-facing summary lines via `SummaryCountsNormalizer`.
|
||||
- **Why it matters now**: Reason codes are the backend's richest source of "why did this happen?" truth. The backend frequently already knows the cause, the prerequisite, and the next step — but this knowledge reaches operators as raw technical fragments because there is no systematic translation layer. As TenantPilot adds more provider domains, more operation types, and more governance workflows, every new reason code that lacks a human-readable translation reproduces the same operator-trust degradation. Some parts of the product already translate codes well (`BaselineCompareReasonCode::message()`, `RunbookReason::options()`), proving the pattern is viable. The gap is not the pattern — it is its inconsistent adoption across all 6+ reason-code families.
|
||||
- **Proposed direction**:
|
||||
- **Reason code humanization contract**: every reason-code artifact (whether `final class` constants or backed enums) must provide a `->label()` or equivalent method that returns a human-readable, operator-appropriate string. Raw string constants that cannot provide methods must be wrapped in or migrated to enums that can.
|
||||
- **Translation target vocabulary**: human-readable labels must use the vocabulary defined by the Operator Outcome Taxonomy. Internal codes remain stable for machines/logs/tests; operator-facing surfaces exclusively use translated labels. The translation contract is the bridge between internal precision and external clarity.
|
||||
- **Structured reason resolution**: reason code translation should return not just a label but a structured resolution envelope containing: (1) human-readable label, (2) optional short explanation, (3) optional next-action link/text, (4) severity classification (retryable transient vs. permanent configuration vs. prerequisite missing). This envelope replaces the current pattern of ad-hoc string formatting in `RunFailureSanitizer`, notification builders, and presenter classes.
|
||||
- **Cross-domain registry or convention**: either a central `ReasonCodeTranslator` service that dispatches to domain-specific translators, or a mandatory `Translatable` interface/trait that all reason-code artifacts must implement. Prefer the latter (each domain owns its translations) over a central monolith, but enforce the contract architecturally.
|
||||
- **Summary count humanization**: `SummaryCountsNormalizer` (or its successor) must map internal metric keys to operator-readable labels. `errors_recorded` → "Errors", `report_deduped` → "Reports deduplicated", etc. Raw internal keys must never reach operator-facing summary lines.
|
||||
- **Next-steps enrichment**: expand `ProviderNextStepsRegistry` pattern to all reason-code families — not just provider codes. Every operator-visible reason code that implies a prerequisite or recoverable condition should include actionable next-step guidance (link, instruction, or "contact support" fallback).
|
||||
- **Notification payload cleanup**: notification builders (`OperationUxPresenter`, terminal notifications) must consume translated labels, not raw reason strings. Failure messages must include cause + retryability + next action, not just a sanitized error string.
|
||||
- **Key decisions to encode**:
|
||||
- Internal codes remain stable and must not be renamed for cosmetic reasons — they are machine contracts used in logs, tests, and audit events
|
||||
- Operator-facing surfaces exclusively use translated labels — raw codes move to diagnostic/secondary detail areas
|
||||
- Every reason code must be classifiable as retryable-transient, permanent-configuration, or prerequisite-missing — this classification drives notification tone and next-action guidance
|
||||
- `RunFailureSanitizer` should be superseded by the structured translation contract, not extended with more heuristic string-matching
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: reason code humanization contract, translation interface/trait, structured resolution envelope, migration plan for existing 6 artifacts, summary count humanization, notification payload cleanup, next-steps enrichment across all reason families
|
||||
- **Out of scope**: creating new reason codes (domain specs own that), changing the semantic meaning of existing codes, badge infrastructure changes (badges consume translated labels — the rendering infrastructure is stable), operation naming vocabulary (tracked separately), individual domain-specific notification redesign beyond label substitution
|
||||
- **Affected workflow families / surfaces**: Operations (run detail, summary, notifications), Provider (connection health, blocked notifications, next-steps banners), Baselines (compare skip reasons, capture precondition reasons), Restore (run result messages, check severity explanations), Verification (check status explanations), RBAC (health check reasons), Onboarding (lifecycle denial reasons), Findings (diff unavailable messages, governance validity labels), System Console (triage, failure details)
|
||||
- **Why this should be one coherent candidate rather than fragmented per-domain fixes**: The 6 reason-code artifacts share the same structural gap (no consistent humanization contract), and per-domain fixes would each invent a different translation pattern. The contract must be defined once so that all domains implement it consistently. A per-domain approach would produce 6 different label formats, 6 different next-step patterns, and no shared resolution envelope — exactly the inconsistency this candidate eliminates. The implementation touches each domain's reason-code artifact, but the contract that governs all of them must be a single decision.
|
||||
- **Dependencies**: Operator Outcome Taxonomy (provides the target vocabulary that reason code labels translate into). Soft dependency — translation work can begin with pragmatic labels before the full taxonomy is ratified, but labels should converge with the taxonomy once available.
|
||||
- **Related specs / candidates**: Operator Outcome Taxonomy and Cross-Domain State Separation (provides vocabulary target), Operator Presentation & Lifecycle Action Hardening (provides rendering enforcement), Baseline Capture Truthful Outcomes (consumes baseline-specific reason code translations), Provider Connection Resolution Normalization (provides backend connection plumbing that gate results reference), Operations Naming Harmonization (complementary — operation type labels vs. reason code labels)
|
||||
- **Strategic sequencing**: Recommended SECOND in the operator-truth initiative sequence, after the Outcome Taxonomy. Can begin in parallel if pragmatic interim labels are acceptable, but final label convergence depends on the taxonomy.
|
||||
- **Priority**: high
|
||||
|
||||
### Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||
- **Type**: hardening
|
||||
@ -214,21 +138,106 @@ ### Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||
- **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence.
|
||||
- **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation).
|
||||
- **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results)
|
||||
- **Strategic sequencing**: Recommended THIRD in the operator-truth initiative sequence. Benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation. However, the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with the other two candidates.
|
||||
- **Strategic sequencing**: Recommended as the adjacent hardening lane after the shared taxonomy and translation work are in place, while governance-surface adoption proceeds through Spec 158 and the governance compression follow-up. It benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation, but much of the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with governance-surface work.
|
||||
- **Priority**: high
|
||||
|
||||
### Governance Operator Outcome Compression
|
||||
- **Type**: hardening
|
||||
- **Source**: product follow-up recommendation 2026-03-23; direct continuation of Spec 158 (`artifact-truth-semantics`)
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Spec 158 establishes the correct internal truth model for governance artifacts, but several governance-facing list and summary surfaces still risk exposing too many internal semantic axes as first-class UI language. On baseline, evidence, review, and pack surfaces the product can still read as academically correct but operator-heavy: multiple adjacent status badges, architecture-derived labels, and equal treatment of existence, readiness, freshness, completeness, and publication semantics. Normal operators are forced to synthesize the answer to three simple workflow questions themselves: Is this artifact usable, why not, and what should I do next?
|
||||
- **Why it matters**: This is the cockpit follow-up to Spec 158's engine work. Without it, TenantPilot preserves semantic correctness internally but leaks too much of that structure directly into governance UX. The result is lower scanability, weaker operator confidence, and a real risk that baseline, evidence, review, and pack domains each evolve their own local status dialect despite sharing the same truth foundation. Shipping this follow-up before broader governance expansion stabilizes operator language where MSP admins actually work.
|
||||
- **Proposed direction**:
|
||||
- Introduce a **compressed operator outcome layer** for governance artifacts that consumes the existing `ArtifactTruthEnvelope`, outcome taxonomy, and reason translation contracts without discarding any internal truth dimensions
|
||||
- Define rendering rules that classify each truth dimension as **primary operator view**, **secondary explanatory detail**, or **diagnostics only**
|
||||
- Make list and overview rows answer three questions first: **primary state**, **short reason**, **next action**
|
||||
- Normalize visible operator language so internal architectural terms such as `artifact truth`, `missing_input`, `metadata_only`, or `publication truth` do not dominate primary workflow surfaces
|
||||
- Clarify where **publication readiness** is the primary business statement versus where it is only one secondary dimension, especially for tenant reviews and review packs
|
||||
- Keep diagnostics available on detail and run-detail pages, but demote raw reason structures, fidelity sub-axes, JSON context, and renderer/support facts behind the primary operator explanation
|
||||
- **Primary adoption surfaces**:
|
||||
- Baseline snapshot lists and detail pages
|
||||
- Evidence snapshot lists and detail pages
|
||||
- Evidence overview
|
||||
- Tenant review lists and detail pages
|
||||
- Review register
|
||||
- Review pack lists and detail pages
|
||||
- Shared governance detail templates and artifact-truth presenter surfaces
|
||||
- Artifact-oriented run-detail pages only where the run is explaining baseline, evidence, review, or review-pack truth
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: visible operator labels, list-column hierarchy, detail-page information hierarchy, mapping from artifact-truth envelopes to compressed operator states, explicit separation between default operator view and diagnostic detail, review/pack publication-readiness primacy rules, governance run-detail explanation hierarchy
|
||||
- **Out of scope**: full operations-list redesign, broad visual polish, color or spacing retuning as the primary goal, new semantic foundation axes, broad findings or workspace overview rewrites, compliance/audit PDF output changes, alert routing or notification copy rewrites, domain-model refactors that change the underlying truth representation
|
||||
- **Core product principles to encode**:
|
||||
- One primary operator statement per artifact on scan surfaces
|
||||
- No truth loss: internal artifact truth, reason structures, APIs, audit context, and JSON diagnostics remain intact and available
|
||||
- Diagnostics are second-layer, not the default operator language
|
||||
- Context-specific business language beats architecture-first vocabulary on primary governance surfaces
|
||||
- Lists are scan surfaces, not diagnosis surfaces
|
||||
- **Candidate requirements**:
|
||||
- **R1 Composite operator outcome**: governance artifacts expose a compressed operator-facing outcome derived from the existing truth and reason model
|
||||
- **R2 Primary / secondary / diagnostic rendering rules**: the system defines which semantic dimensions may appear in each rendering tier
|
||||
- **R3 List-surface simplification**: governance lists stop defaulting to multi-column badge explosions for separate semantic axes
|
||||
- **R4 Detail-surface hierarchy**: details lead with outcome, explanation, and next action before diagnostics
|
||||
- **R5 Operator language normalization**: internal architecture terms are translated or removed from primary governance UI
|
||||
- **R6 Review / pack publication clarity**: review and pack surfaces explicitly state when publishability is the main business decision and when it is not
|
||||
- **R7 No truth loss**: APIs, audit, diagnostics, and raw context remain available even when the primary presentation is compressed
|
||||
- **Acceptance points**:
|
||||
- Governance lists no longer present multiple equal-weight semantic badge columns as the default mental model
|
||||
- `artifact truth` and sibling architecture-first labels stop dominating primary operator surfaces
|
||||
- Governance detail pages clearly separate primary state, explanatory reason, next action, and diagnostics
|
||||
- Review and pack surfaces clearly answer whether the artifact is ready to publish or share
|
||||
- Baseline and evidence surfaces clearly answer whether the artifact is trustworthy and usable
|
||||
- Governance run-detail pages make the dominant problem and next action understandable without reading raw JSON
|
||||
- The internal truth model remains fully usable for diagnostics, audit, and downstream APIs
|
||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), shared governance detail templates, review-layer and evidence-domain adoption surfaces already in flight
|
||||
- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Baseline Snapshot Fidelity Semantics candidate, Compliance Readiness & Executive Review Packs candidate
|
||||
- **Strategic sequencing**: Recommended immediately after Spec 158 and before any major additional governance-surface expansion. This is the adoption layer that turns the truth semantics foundation into an operator-tolerable cockpit instead of a direct dump of internal semantic richness.
|
||||
- **Priority**: high
|
||||
|
||||
### Humanized Diagnostic Summaries for Governance Operations
|
||||
- **Type**: hardening
|
||||
- **Source**: operator-facing governance run-detail gap analysis 2026-03-23; follow-up to Specs 156, 157, and 158
|
||||
- **Vehicle**: new standalone candidate
|
||||
- **Problem**: Governance run-detail pages now have the right outcome, reason, and artifact-truth semantics, but the operator explanation often still lives in raw JSON. A run can read as `Completed with follow-up`, `Partial`, `Blocked`, or `Missing input` while the important meaning stays hidden: how much was affected, which cause dominates, whether retry or resume helps, and whether the resulting artifact is actually trustworthy.
|
||||
- **Why it matters**: This is the missing middle layer between Spec 158's truth engine and the operator's actual decision. Without it, TenantPilot stays semantically correct but too technical on one of its highest-trust governance surfaces. Raw JSON remains part of normal troubleshooting when it should be optional.
|
||||
- **Proposed direction**:
|
||||
- Add a humanized diagnostic summary layer on governance run-detail pages between semantic verdicts and raw JSON
|
||||
- Lead with impact, dominant cause, artifact trustworthiness, and next action instead of forcing operators to infer those from badges plus raw context
|
||||
- Render a compact dominant-cause breakdown for multi-cause degraded runs, including counts or relative scale where useful
|
||||
- Separate processing-success counters from artifact usability so technically correct metrics do not read as false-green artifact success
|
||||
- Upgrade generic `Inspect diagnostics` guidance into cause-aware next steps such as retry later, resume capture, refresh policy inventory, verify missing policies, review ambiguous matches, or fix access or scope configuration
|
||||
- Keep raw JSON and low-level context fully available, but explicitly secondary
|
||||
- **Primary adoption surfaces**:
|
||||
- Canonical Monitoring run-detail pages for governance operation types
|
||||
- Shared tenantless canonical run viewers and run-detail templates
|
||||
- Governance run detail reached from baseline capture, baseline compare, evidence refresh or snapshot generation, tenant review generation, and review-pack generation
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: run-detail explanation hierarchy, humanized impact summaries, dominant-cause breakdowns, clearer processing-versus-artifact terminology, reusable guidance pattern for governance run families
|
||||
- **Out of scope**: full Operations redesign, broad list or dashboard overhaul, new persistence models for summaries, removal of raw JSON, new truth axes beyond the existing outcome or artifact-truth model, generalized rewrite of all governance artifact detail pages
|
||||
- **Acceptance points**:
|
||||
- A normal operator can understand the dominant problem and next step on a governance run-detail page without opening raw JSON
|
||||
- Runs with technically successful processing but degraded artifacts explicitly explain why those truths diverge
|
||||
- Multi-cause degraded runs show the dominant causes and their scale instead of only one flattened abstract state
|
||||
- Guidance distinguishes retryable or resumable situations from structural prerequisite problems when the data supports that distinction
|
||||
- Raw JSON remains present for audit and debugging but is clearly secondary in the page hierarchy
|
||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), canonical operation viewer work, shared governance detail templates
|
||||
- **Related specs / candidates**: Spec 144 (canonical operation viewer context decoupling), Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Governance Operator Outcome Compression candidate
|
||||
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Governance Operator Outcome Compression. Compression improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in.
|
||||
- **Priority**: high
|
||||
|
||||
> **Operator Truth Initiative — Sequencing Note**
|
||||
>
|
||||
> The three candidates above (Operator Outcome Taxonomy, Reason Code Translation, Provider Dispatch Gate Unification) form a coherent cross-product initiative addressing the systemic gap between backend truth richness and operator-facing truth quality. They are sequenced as a dependency chain with parallelization opportunities:
|
||||
> The operator-truth work now has two connected lanes: a shared truth-foundation lane and a governance-surface compression lane. Together they address the systemic gap between backend truth richness and operator-facing truth quality without forcing operators to parse raw internal semantics.
|
||||
>
|
||||
> **Recommended order:**
|
||||
> 1. **Operator Outcome Taxonomy and Cross-Domain State Separation** — defines the shared vocabulary, state-axis separation rules, and color-severity conventions that all other operator-facing work references. This is the smallest deliverable (a reference document + restructuring guidelines) but the highest-leverage decision. Without it, the other two candidates will invent local vocabularies that diverge.
|
||||
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
|
||||
> 3. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from both upstream candidates but has significant backend scope (gate extension + scope-busy enforcement) that can proceed independently.
|
||||
> 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
|
||||
> 4. **Governance Operator Outcome Compression** — applies the foundation to governance workflow surfaces so lists and details answer the operator's primary question first, while preserving diagnostics as second-layer detail.
|
||||
> 5. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from the shared vocabulary work but remains a parallel hardening lane rather than a governance-surface adoption slice.
|
||||
>
|
||||
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation and gate unification are both P1-level (strongly confusing, should be fixed soon). The taxonomy is also the smallest and most decisive deliverable — it produces a reference that all other candidates consume. Shipping the taxonomy first prevents the other two candidates from making locally correct but globally inconsistent vocabulary choices. The gate unification has the largest implementation surface (~20 services) but much of its backend work (extending `ProviderOperationStartGate` scope, adding connection locking, dedup enforcement) can proceed in parallel once the taxonomy establishes the shared vocabulary for gate result presentation.
|
||||
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The compression follow-up is then what turns that engine into a scanable operator cockpit before more governance features land. Gate unification remains highly valuable, but it is a neighboring hardening lane rather than the immediate follow-up needed to make governance truth semantics feel product-ready.
|
||||
>
|
||||
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundaries. The taxonomy is a cross-cutting decision document. Reason code translation touches 6+ reason-code artifacts and notification builders. Gate unification touches ~20 services, the gate class, Filament action handlers, and notification templates. Merging them would create an unshippable monolith. Keeping them as a sequenced initiative preserves independent delivery while ensuring vocabulary convergence.
|
||||
> **Why these are not one spec:** Each candidate has a different implementation surface, different stakeholders, and different shippability boundary. The taxonomy is a cross-cutting decision document. Reason code translation touches reason-code artifacts and notification builders. Spec 158 defines the richer artifact truth engine. Governance operator outcome compression is a UI-information-architecture adoption slice across governance surfaces. Gate unification touches provider dispatch and notification plumbing across ~20 services. Merging them would create an unshippable monolith. Keeping them sequenced preserves independent delivery while still converging on one operator language.
|
||||
|
||||
### Baseline Snapshot Fidelity Semantics
|
||||
- **Type**: hardening
|
||||
@ -269,18 +278,6 @@ ### Exception / Risk-Acceptance Workflow for Findings
|
||||
- **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134)
|
||||
- **Priority**: high
|
||||
|
||||
### Evidence Domain Foundation
|
||||
- **Type**: feature
|
||||
- **Source**: HANDOVER gap, R2 theme completion
|
||||
- **Vehicle note**: Promoted into existing Spec 153. Do not create a second evidence-domain candidate for semantic cleanup; extend Spec 153 when evidence completeness / freshness semantics need to be corrected.
|
||||
- **Problem**: Review pack export (Spec 109) and permission posture reports (104/105) exist as separate output artifacts. There is no first-class evidence domain model that curates, bundles, and tracks these artifacts as a coherent compliance deliverable for external audit submission.
|
||||
- **Why it matters**: Enterprise customers need a single, versioned, auditor-ready package — not a collection of separate exports assembled manually. The gap is not export packaging (Spec 109 handles that); it is the absence of an evidence domain layer that owns curation, completeness tracking, and audit-trail linkage.
|
||||
- **Proposed direction**: Evidence domain model with curated artifact references (review packs, posture reports, findings summaries, baseline governance snapshots). Completeness metadata. Immutable snapshots with generation timestamp and actor. Not a re-implementation of export — a higher-order assembly layer.
|
||||
- **Explicit non-goals**: Not a presentation or reporting layer — this candidate owns data curation, completeness tracking, artifact storage, and immutable snapshots. Executive summaries, framework-oriented readiness views, management-ready outputs, and stakeholder-facing packaging belong to the Compliance Readiness & Executive Review Packs candidate, which consumes this foundation. Not a replacement for Spec 109's export packaging. Not a generic BI or data warehouse initiative.
|
||||
- **Boundary with Compliance Readiness**: Evidence Domain Foundation = lower-level data assembly (what artifacts exist, are they complete, are they immutable). Compliance Readiness = upper-level presentation (how to arrange evidence into framework-oriented, stakeholder-facing deliverables). This candidate is a prerequisite; Compliance Readiness is a downstream consumer.
|
||||
- **Dependencies**: Review pack export (109), permission posture (104/105)
|
||||
- **Priority**: high
|
||||
|
||||
### Compliance Readiness & Executive Review Packs
|
||||
- **Type**: feature
|
||||
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance
|
||||
|
||||
@ -77,9 +77,9 @@ class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm fo
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||
{{ $fact['label'] ?? 'Fact' }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
@if ($displayValue !== null)
|
||||
<span>{{ $displayValue }}</span>
|
||||
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
|
||||
@endif
|
||||
|
||||
@if ($badge !== null)
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
:collapsed="(bool) ($section['collapsed'] ?? false)"
|
||||
>
|
||||
@if ($view !== null)
|
||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
||||
@elseif ($items !== [])
|
||||
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
||||
'items' => $items,
|
||||
|
||||
@ -19,9 +19,9 @@
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||
{{ $item['label'] ?? 'Detail' }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
@if ($displayValue !== null)
|
||||
<span>{{ $displayValue }}</span>
|
||||
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
|
||||
@endif
|
||||
|
||||
@if ($badge !== null)
|
||||
|
||||
@ -35,7 +35,7 @@ class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text
|
||||
|
||||
<div class="mt-4">
|
||||
@if ($view !== null)
|
||||
@include($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])
|
||||
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
|
||||
@elseif ($items !== [])
|
||||
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $items])
|
||||
@elseif ($emptyState !== null)
|
||||
|
||||
@ -20,10 +20,10 @@
|
||||
@if ($view !== null)
|
||||
@if ($entries !== [])
|
||||
<div class="mt-4">
|
||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
||||
</div>
|
||||
@else
|
||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
|
||||
@endif
|
||||
@elseif ($emptyState !== null)
|
||||
<div @class(['mt-4' => $entries !== []])>
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
$resolvedState = isset($getState) ? $getState() : ($artifactTruthState ?? ($state ?? null));
|
||||
$state = is_array($resolvedState) ? $resolvedState : [];
|
||||
$dimensions = collect(is_array($state['dimensions'] ?? null) ? $state['dimensions'] : []);
|
||||
|
||||
$primary = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['classification'] ?? null) === 'primary');
|
||||
$existence = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'artifact_existence');
|
||||
$freshness = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'data_freshness');
|
||||
$publication = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'publication_readiness');
|
||||
$actionability = $dimensions->first(fn (mixed $dimension): bool => is_array($dimension) && ($dimension['axis'] ?? null) === 'operator_actionability');
|
||||
|
||||
$specFor = static function (mixed $dimension): ?\App\Support\Badges\BadgeSpec {
|
||||
if (! is_array($dimension)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_string($dimension['badgeDomain'] ?? null) || ! is_string($dimension['badgeState'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BadgeCatalog::spec(BadgeDomain::from($dimension['badgeDomain']), $dimension['badgeState']);
|
||||
};
|
||||
|
||||
$primarySpec = $specFor($primary);
|
||||
$existenceSpec = $specFor($existence);
|
||||
$freshnessSpec = $specFor($freshness);
|
||||
$publicationSpec = $specFor($publication);
|
||||
$actionabilitySpec = $specFor($actionability);
|
||||
$reason = is_array($state['reason'] ?? null) ? $state['reason'] : [];
|
||||
$nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : [];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start gap-2">
|
||||
@if ($primarySpec)
|
||||
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
|
||||
{{ $primarySpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($actionabilitySpec)
|
||||
<x-filament::badge :color="$actionabilitySpec->color" :icon="$actionabilitySpec->icon" size="sm">
|
||||
{{ $actionabilitySpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="text-sm font-medium text-gray-950 dark:text-gray-100">
|
||||
{{ $state['primaryLabel'] ?? 'Artifact truth' }}
|
||||
</div>
|
||||
|
||||
@if (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '')
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $state['primaryExplanation'] }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (is_string($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
|
||||
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Diagnostic: {{ $state['diagnosticLabel'] }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@if ($existenceSpec)
|
||||
<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">Artifact exists</dt>
|
||||
<dd class="mt-1">
|
||||
<x-filament::badge :color="$existenceSpec->color" :icon="$existenceSpec->icon" size="sm">
|
||||
{{ $existenceSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($freshnessSpec)
|
||||
<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">Freshness</dt>
|
||||
<dd class="mt-1">
|
||||
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
|
||||
{{ $freshnessSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($publicationSpec)
|
||||
<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">Publication</dt>
|
||||
<dd class="mt-1">
|
||||
<x-filament::badge :color="$publicationSpec->color" :icon="$publicationSpec->icon" size="sm">
|
||||
{{ $publicationSpec->label }}
|
||||
</x-filament::badge>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<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">Next step</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ $state['nextActionLabel'] ?? 'No action needed' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if ($nextSteps !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($nextSteps as $step)
|
||||
@continue(! is_string($step) || trim($step) === '')
|
||||
|
||||
<li class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 dark:border-primary-900/40 dark:bg-primary-950/30">
|
||||
{{ $step }}
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -1,8 +1,3 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
@if ($rows === [])
|
||||
@ -21,28 +16,36 @@
|
||||
<thead class="bg-gray-50 text-left text-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Tenant</th>
|
||||
<th class="px-4 py-3 font-medium">Completeness</th>
|
||||
<th class="px-4 py-3 font-medium">Artifact truth</th>
|
||||
<th class="px-4 py-3 font-medium">Freshness</th>
|
||||
<th class="px-4 py-3 font-medium">Generated</th>
|
||||
<th class="px-4 py-3 font-medium">Not collected yet</th>
|
||||
<th class="px-4 py-3 font-medium">Refresh recommended</th>
|
||||
<th class="px-4 py-3 font-medium">Next step</th>
|
||||
<th class="px-4 py-3 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white text-gray-900">
|
||||
@foreach ($rows as $row)
|
||||
@php
|
||||
$completenessSpec = BadgeRenderer::spec(BadgeDomain::EvidenceCompleteness, $row['completeness_state'] ?? null);
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="px-4 py-3">{{ $row['tenant_name'] }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<x-filament::badge :color="$completenessSpec->color" :icon="$completenessSpec->icon" size="sm">
|
||||
{{ $completenessSpec->label }}
|
||||
<x-filament::badge :color="data_get($row, 'artifact_truth.color', 'gray')" :icon="data_get($row, 'artifact_truth.icon')" size="sm">
|
||||
{{ data_get($row, 'artifact_truth.label', 'Unknown') }}
|
||||
</x-filament::badge>
|
||||
@if (is_string(data_get($row, 'artifact_truth.explanation')) && trim((string) data_get($row, 'artifact_truth.explanation')) !== '')
|
||||
<div class="mt-1 text-xs text-gray-500">{{ data_get($row, 'artifact_truth.explanation') }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<x-filament::badge :color="data_get($row, 'freshness.color', 'gray')" :icon="data_get($row, 'freshness.icon')" size="sm">
|
||||
{{ data_get($row, 'freshness.label', 'Unknown') }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ $row['generated_at'] ?? '—' }}</td>
|
||||
<td class="px-4 py-3">{{ $row['missing_dimensions'] }}</td>
|
||||
<td class="px-4 py-3">{{ $row['stale_dimensions'] }}</td>
|
||||
<td class="px-4 py-3">{{ $row['next_step'] ?? 'No action needed' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ $row['view_url'] }}" class="text-primary-600 hover:text-primary-500">View tenant evidence</a>
|
||||
</td>
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
# Specification Quality Checklist: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-22
|
||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/158-artifact-truth-semantics/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated on 2026-03-22 after first-pass authoring.
|
||||
- Scope is intentionally bounded to governance artifacts and artifact-targeted run truth, not general provider or restore lifecycle semantics.
|
||||
|
||||
*** Delete File: /Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/001-artifact-truth-semantics/spec.md
|
||||
@ -0,0 +1,409 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Governance Artifact Truth Internal Contract
|
||||
version: 0.1.0
|
||||
summary: Internal planning contract for Spec 158 operator-surface read models.
|
||||
description: |
|
||||
This contract documents the normalized truth-envelope expected by the first
|
||||
implementation slice for baseline snapshots, evidence snapshots, tenant
|
||||
reviews, review packs, and artifact-targeted operation runs. These paths map
|
||||
to existing Filament surfaces and internal action handlers; they are not a
|
||||
commitment to a public JSON API.
|
||||
servers:
|
||||
- url: /admin
|
||||
paths:
|
||||
/baseline-snapshots:
|
||||
get:
|
||||
summary: List workspace baseline snapshots with normalized truth summaries
|
||||
operationId: listBaselineSnapshotsWithTruth
|
||||
responses:
|
||||
'200':
|
||||
description: Baseline snapshot list rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
||||
/baseline-snapshots/{snapshotId}:
|
||||
get:
|
||||
summary: Read one workspace baseline snapshot with truth envelope and diagnostics
|
||||
operationId: viewBaselineSnapshotWithTruth
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/SnapshotId'
|
||||
responses:
|
||||
'200':
|
||||
description: Baseline snapshot detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ArtifactDetailResponse'
|
||||
/evidence/overview:
|
||||
get:
|
||||
summary: List canonical workspace evidence rows limited to entitled tenants
|
||||
operationId: listEvidenceOverviewWithTruth
|
||||
parameters:
|
||||
- in: query
|
||||
name: tenant_id
|
||||
schema:
|
||||
type: integer
|
||||
required: false
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical evidence overview rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
||||
/t/{tenantId}/evidence:
|
||||
get:
|
||||
summary: List tenant evidence snapshots with truth summaries
|
||||
operationId: listEvidenceSnapshotsWithTruth
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: Evidence snapshot list rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
||||
/t/{tenantId}/evidence/{snapshotId}:
|
||||
get:
|
||||
summary: Read one evidence snapshot with truth envelope, per-dimension state, and diagnostics
|
||||
operationId: viewEvidenceSnapshotWithTruth
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/SnapshotId'
|
||||
responses:
|
||||
'200':
|
||||
description: Evidence snapshot detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ArtifactDetailResponse'
|
||||
/reviews:
|
||||
get:
|
||||
summary: List canonical review register rows limited to entitled tenants
|
||||
operationId: listReviewRegisterWithTruth
|
||||
parameters:
|
||||
- in: query
|
||||
name: tenant_id
|
||||
schema:
|
||||
type: integer
|
||||
required: false
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical review register rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
||||
/t/{tenantId}/reviews:
|
||||
get:
|
||||
summary: List tenant reviews with lifecycle, completeness, and publication truth
|
||||
operationId: listTenantReviewsWithTruth
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant review list rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
||||
/t/{tenantId}/reviews/{reviewId}:
|
||||
get:
|
||||
summary: Read one tenant review with truth envelope and blocker explanations
|
||||
operationId: viewTenantReviewWithTruth
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/ReviewId'
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant review detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ArtifactDetailResponse'
|
||||
/t/{tenantId}/review-packs:
|
||||
get:
|
||||
summary: List review packs with lifecycle and provenance truth
|
||||
operationId: listReviewPacksWithTruth
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: Review-pack list rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ArtifactCollectionResponse'
|
||||
/t/{tenantId}/review-packs/{packId}:
|
||||
get:
|
||||
summary: Read one review pack with truth envelope, provenance, and diagnostics
|
||||
operationId: viewReviewPackWithTruth
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/PackId'
|
||||
responses:
|
||||
'200':
|
||||
description: Review-pack detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ArtifactDetailResponse'
|
||||
/operations/{runId}:
|
||||
get:
|
||||
summary: Read canonical artifact-targeted run detail with normalized artifact result
|
||||
operationId: viewArtifactRunWithTruth
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/RunId'
|
||||
responses:
|
||||
'200':
|
||||
description: Operation-run detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ArtifactRunDetailResponse'
|
||||
components:
|
||||
parameters:
|
||||
TenantId:
|
||||
in: path
|
||||
name: tenantId
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
SnapshotId:
|
||||
in: path
|
||||
name: snapshotId
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
ReviewId:
|
||||
in: path
|
||||
name: reviewId
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
PackId:
|
||||
in: path
|
||||
name: packId
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
RunId:
|
||||
in: path
|
||||
name: runId
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
schemas:
|
||||
ArtifactCollectionResponse:
|
||||
type: object
|
||||
required:
|
||||
- rows
|
||||
properties:
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ArtifactSummaryRow'
|
||||
ArtifactDetailResponse:
|
||||
type: object
|
||||
required:
|
||||
- artifact
|
||||
- truth
|
||||
properties:
|
||||
artifact:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
truth:
|
||||
$ref: '#/components/schemas/ArtifactTruthEnvelope'
|
||||
diagnostics:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
ArtifactRunDetailResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ArtifactDetailResponse'
|
||||
- type: object
|
||||
properties:
|
||||
run:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
ArtifactSummaryRow:
|
||||
type: object
|
||||
required:
|
||||
- artifactKey
|
||||
- artifactFamily
|
||||
- truth
|
||||
properties:
|
||||
artifactKey:
|
||||
type: string
|
||||
artifactFamily:
|
||||
type: string
|
||||
enum:
|
||||
- baseline_snapshot
|
||||
- evidence_snapshot
|
||||
- tenant_review
|
||||
- review_pack
|
||||
- artifact_run
|
||||
displayTitle:
|
||||
type: string
|
||||
tenantName:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
truth:
|
||||
$ref: '#/components/schemas/ArtifactTruthEnvelope'
|
||||
ArtifactTruthEnvelope:
|
||||
type: object
|
||||
required:
|
||||
- artifactFamily
|
||||
- artifactExistence
|
||||
- contentState
|
||||
- freshnessState
|
||||
- supportState
|
||||
- actionability
|
||||
- primaryLabel
|
||||
properties:
|
||||
artifactFamily:
|
||||
type: string
|
||||
executionOutcome:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum:
|
||||
- pending
|
||||
- succeeded
|
||||
- partially_succeeded
|
||||
- blocked
|
||||
- failed
|
||||
- null
|
||||
artifactExistence:
|
||||
type: string
|
||||
enum:
|
||||
- not_created
|
||||
- historical_only
|
||||
- created
|
||||
- created_but_not_usable
|
||||
contentState:
|
||||
type: string
|
||||
enum:
|
||||
- trusted
|
||||
- partial
|
||||
- missing_input
|
||||
- metadata_only
|
||||
- reference_only
|
||||
- empty
|
||||
- unsupported
|
||||
freshnessState:
|
||||
type: string
|
||||
enum:
|
||||
- current
|
||||
- stale
|
||||
- unknown
|
||||
publicationReadiness:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
enum:
|
||||
- not_applicable
|
||||
- internal_only
|
||||
- publishable
|
||||
- blocked
|
||||
- null
|
||||
supportState:
|
||||
type: string
|
||||
enum:
|
||||
- normal
|
||||
- limited_support
|
||||
actionability:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- optional
|
||||
- required
|
||||
primaryLabel:
|
||||
type: string
|
||||
primaryExplanation:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
reason:
|
||||
$ref: '#/components/schemas/ArtifactTruthCause'
|
||||
nextAction:
|
||||
$ref: '#/components/schemas/ArtifactTruthNextAction'
|
||||
dimensions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ArtifactTruthDimension'
|
||||
ArtifactTruthDimension:
|
||||
type: object
|
||||
required:
|
||||
- axis
|
||||
- state
|
||||
- label
|
||||
- classification
|
||||
properties:
|
||||
axis:
|
||||
type: string
|
||||
enum:
|
||||
- execution_outcome
|
||||
- artifact_existence
|
||||
- content_fidelity
|
||||
- data_freshness
|
||||
- publication_readiness
|
||||
- support_maturity
|
||||
- operator_actionability
|
||||
state:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
classification:
|
||||
type: string
|
||||
enum:
|
||||
- primary
|
||||
- secondary
|
||||
- diagnostic
|
||||
badgeDomain:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
badgeState:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
ArtifactTruthCause:
|
||||
type: object
|
||||
properties:
|
||||
reasonCode:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
operatorLabel:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
shortExplanation:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
diagnosticCode:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
nextSteps:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
ArtifactTruthNextAction:
|
||||
type: object
|
||||
properties:
|
||||
label:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
url:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
required:
|
||||
type: boolean
|
||||
175
specs/158-artifact-truth-semantics/data-model.md
Normal file
175
specs/158-artifact-truth-semantics/data-model.md
Normal file
@ -0,0 +1,175 @@
|
||||
# Data Model: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces a shared read-model for operator-facing artifact truth across existing governance artifacts. The first slice does not require a new primary table. Instead, it standardizes how existing persisted artifact records are projected into one truth envelope.
|
||||
|
||||
## Entities
|
||||
|
||||
### ArtifactTruthEnvelope
|
||||
|
||||
Operator-facing read model derived from one governance artifact or artifact-targeted run.
|
||||
|
||||
**Fields**:
|
||||
- `artifactFamily` (enum): `baseline_snapshot`, `evidence_snapshot`, `tenant_review`, `review_pack`, `artifact_run`
|
||||
- `artifactKey` (string): stable identifier in the form `{family}:{id}`
|
||||
- `workspaceId` (int)
|
||||
- `tenantId` (int|null): `null` for workspace-owned baseline snapshots and workspace-level runs
|
||||
- `executionOutcome` (enum|null): `pending`, `succeeded`, `partially_succeeded`, `blocked`, `failed`
|
||||
- `artifactExistence` (enum): `not_created`, `historical_only`, `created`, `created_but_not_usable`
|
||||
- `contentState` (enum): `trusted`, `partial`, `missing_input`, `metadata_only`, `reference_only`, `empty`, `unsupported`
|
||||
- `freshnessState` (enum): `current`, `stale`, `unknown`
|
||||
- `publicationReadiness` (enum|null): `not_applicable`, `internal_only`, `publishable`, `blocked`
|
||||
- `supportState` (enum): `normal`, `limited_support`
|
||||
- `actionability` (enum): `none`, `optional`, `required`
|
||||
- `primaryReasonCode` (string|null)
|
||||
- `primaryLabel` (string): top-level operator-facing state summary
|
||||
- `primaryExplanation` (string|null): concise explanation of the main degraded dimension
|
||||
- `diagnosticLabel` (string|null): renderer or implementation-oriented label shown only secondarily
|
||||
- `nextActionLabel` (string|null)
|
||||
- `nextActionUrl` (string|null)
|
||||
- `relatedRunId` (int|null)
|
||||
- `relatedArtifactUrl` (string|null)
|
||||
|
||||
**Validation rules**:
|
||||
- `artifactExistence = not_created` MUST NOT coexist with `contentState = trusted`.
|
||||
- `publicationReadiness` MAY be non-null only for review and review-pack families.
|
||||
- `supportState = limited_support` MUST NOT be used as the primary failure dimension if another truth dimension explains operator impact.
|
||||
- `actionability = required` MUST include either `nextActionLabel` or a stable reason explanation.
|
||||
|
||||
### ArtifactTruthDimension
|
||||
|
||||
Normalized dimension entry for badges, helper text, summaries, and canonical filter values.
|
||||
|
||||
**Fields**:
|
||||
- `axis` (enum): `execution_outcome`, `artifact_existence`, `content_fidelity`, `data_freshness`, `publication_readiness`, `support_maturity`, `operator_actionability`
|
||||
- `state` (string)
|
||||
- `label` (string)
|
||||
- `classification` (enum): `primary`, `secondary`, `diagnostic`
|
||||
- `badgeDomain` (string|null)
|
||||
- `badgeState` (string|null)
|
||||
|
||||
**Validation rules**:
|
||||
- At most one `primary` dimension may drive the top-level alert state at a time.
|
||||
- Diagnostic dimensions MUST still be preserved for detail pages even when not rendered on list pages.
|
||||
|
||||
### ArtifactTruthCause
|
||||
|
||||
Stable explanation payload for degraded states.
|
||||
|
||||
**Fields**:
|
||||
- `reasonCode` (string|null)
|
||||
- `translationArtifact` (string|null): `provider_reason_codes`, `execution_denial_reason_code`, `tenant_operability_reason_code`, `rbac_reason`, or a bounded baseline/review artifact
|
||||
- `operatorLabel` (string|null)
|
||||
- `shortExplanation` (string|null)
|
||||
- `diagnosticCode` (string|null)
|
||||
- `nextSteps` (list<string>)
|
||||
|
||||
**Validation rules**:
|
||||
- If `reasonCode` is translated, `operatorLabel` SHOULD come from the centralized translator.
|
||||
- Unknown codes MAY fall back to persisted message text, but raw codes remain diagnostics-only.
|
||||
|
||||
## Source Projections
|
||||
|
||||
### BaselineSnapshotProjection
|
||||
|
||||
Existing workspace-owned source record: `BaselineSnapshot`.
|
||||
|
||||
**Source fields used**:
|
||||
- `id`, `workspace_id`, `captured_at`
|
||||
- `summary_jsonb.fidelity_counts`
|
||||
- `summary_jsonb.gaps`
|
||||
- derived fidelity from `FidelityState::fromSummary(...)`
|
||||
|
||||
**Derived truth rules**:
|
||||
- Full artifact trust requires usable captured content and no misleading gap interpretation.
|
||||
- `unsupported` fidelity remains diagnostic-only unless it also implies that no trustworthy comparison artifact exists.
|
||||
- Zero-subject or unusable-upstream cases should be explained from related run context where available.
|
||||
|
||||
### EvidenceSnapshotProjection
|
||||
|
||||
Existing tenant-owned source record: `EvidenceSnapshot`.
|
||||
|
||||
**Source fields used**:
|
||||
- `id`, `workspace_id`, `tenant_id`, `status`, `completeness_state`, `generated_at`, `expires_at`
|
||||
- `summary.missing_dimensions`, `summary.stale_dimensions`
|
||||
- child `items.state`, `items.source_kind`, `items.freshness_at`
|
||||
- `operation_run_id`
|
||||
|
||||
**Derived truth rules**:
|
||||
- `status` models lifecycle; `completeness_state` models coverage/freshness and must not be collapsed into lifecycle.
|
||||
- `missing_dimensions > 0` indicates incomplete coverage, not run failure.
|
||||
- `stale_dimensions > 0` indicates freshness follow-up, not absence.
|
||||
|
||||
### TenantReviewProjection
|
||||
|
||||
Existing tenant-owned source record: `TenantReview`.
|
||||
|
||||
**Source fields used**:
|
||||
- `id`, `workspace_id`, `tenant_id`, `status`, `completeness_state`, `generated_at`, `published_at`, `archived_at`
|
||||
- `summary.publish_blockers`
|
||||
- `summary.section_state_counts`
|
||||
- `evidence_snapshot_id`, `current_export_review_pack_id`, `operation_run_id`
|
||||
- child `sections.completeness_state`, `sections.measured_at`
|
||||
|
||||
**Derived truth rules**:
|
||||
- `status` answers lifecycle (`draft`, `ready`, `published`, etc.), not evidence completeness by itself.
|
||||
- Publish blockers control `publicationReadiness = blocked` even when the review artifact exists.
|
||||
- A review may be internally useful while still not publishable.
|
||||
|
||||
### ReviewPackProjection
|
||||
|
||||
Existing tenant-owned source record: `ReviewPack`.
|
||||
|
||||
**Source fields used**:
|
||||
- `id`, `workspace_id`, `tenant_id`, `status`, `generated_at`, `expires_at`, `file_size`
|
||||
- `summary.review_status`
|
||||
- `summary.evidence_resolution.*`
|
||||
- `tenant_review_id`, `evidence_snapshot_id`, `operation_run_id`
|
||||
|
||||
**Derived truth rules**:
|
||||
- `status = ready` means a file exists, not necessarily that the source review is still publishable.
|
||||
- Provenance must consider linked review state and evidence completeness at generation time.
|
||||
- `expired` is historical-only rather than a runtime failure.
|
||||
|
||||
### ArtifactRunProjection
|
||||
|
||||
Existing source record: `OperationRun` limited to artifact-targeted families.
|
||||
|
||||
**Source fields used**:
|
||||
- `id`, `workspace_id`, `tenant_id`, `type`, `status`, `outcome`, `summary_counts`, `failure_summary`, `context`
|
||||
- translated reason via `ReasonPresenter`
|
||||
- related links via `OperationRunLinks`
|
||||
|
||||
**Derived truth rules**:
|
||||
- Run lifecycle and run outcome remain distinct from whether an artifact was actually produced.
|
||||
- Artifact-targeted families in this slice: `baseline_capture`, `tenant.evidence.snapshot.generate`, `tenant.review.compose`, `tenant.review_pack.generate`
|
||||
- The run truth section may reuse persisted `context` or summary enrichment to state whether the intended artifact exists and is usable.
|
||||
|
||||
## State Transitions
|
||||
|
||||
### Truth-envelope transitions
|
||||
|
||||
These are read-model transitions, not persistence transitions:
|
||||
|
||||
1. `not_created` → `created`
|
||||
- Trigger: artifact record becomes available and usable.
|
||||
2. `created` → `created_but_not_usable`
|
||||
- Trigger: artifact exists but content, freshness, or readiness makes it unsafe for the primary operator task.
|
||||
3. `created` or `created_but_not_usable` → `historical_only`
|
||||
- Trigger: artifact remains intelligible for history, but is expired, superseded, or no longer current for primary use.
|
||||
4. `publicationReadiness = blocked` → `publishable`
|
||||
- Trigger: review blockers clear or a derived pack is generated from a publishable review.
|
||||
|
||||
### Existing persisted lifecycle references
|
||||
|
||||
- `EvidenceSnapshot.status`: `queued` → `generating` → `active` → `superseded|expired|failed`
|
||||
- `TenantReview.status`: `draft` → `ready` → `published|archived|superseded|failed`
|
||||
- `ReviewPack.status`: `queued` → `generating` → `ready|failed|expired`
|
||||
- `OperationRun.status/outcome`: service-owned lifecycle that remains unchanged by this feature
|
||||
|
||||
## No New Primary Persistence in First Slice
|
||||
|
||||
- No new top-level table is required.
|
||||
- Backward-compatible enrichment of existing JSON payloads is allowed if a family cannot otherwise satisfy truthful artifact provenance.
|
||||
- Any enrichment must remain optional for historical records and degrade gracefully when absent.
|
||||
129
specs/158-artifact-truth-semantics/plan.md
Normal file
129
specs/158-artifact-truth-semantics/plan.md
Normal file
@ -0,0 +1,129 @@
|
||||
# Implementation Plan: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
||||
|
||||
**Branch**: `158-artifact-truth-semantics` | **Date**: 2026-03-22 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/158-artifact-truth-semantics/spec.md`
|
||||
|
||||
**Note**: This plan covers the first bounded adoption slice for truthful artifact semantics across baseline snapshots, evidence snapshots, tenant reviews, review packs, and artifact-targeted operation-run detail. It intentionally excludes provider dispatch gating, restore-lifecycle cleanup, and broad platform-wide status redesign.
|
||||
|
||||
## Summary
|
||||
|
||||
Implement a shared governance-artifact truth layer that separates artifact existence, execution outcome, completeness or fidelity, freshness, publication readiness, support maturity, and operator actionability across existing governance surfaces. The first slice is presentation-first: reuse current persisted enums, `summary` payloads, `context` payloads, badge domains, reason translation, and canonical RBAC filters wherever they already encode the truth, then add only targeted derived helpers or minimal summary/context enrichment when a surface cannot distinguish trustworthy vs merely present artifacts or cannot explain current-vs-historical artifact provenance. Integration lands in existing Filament resources and canonical pages, plus the tenantless `OperationRun` detail view, with focused regression coverage for false-green baselines, partial or stale evidence, non-publishable reviews, review-pack provenance, empty-state distinctions, and tenant-safe canonical summaries.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages
|
||||
**Storage**: PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice, but minimal additive summary/context enrichment is allowed where truthful provenance cannot otherwise be derived
|
||||
**Testing**: Pest feature tests, Pest unit tests, and Livewire/Filament component tests
|
||||
**Target Platform**: Laravel Sail web application with Filament admin and tenant panels
|
||||
**Project Type**: Web application monolith
|
||||
**Performance Goals**: All adopted list/detail surfaces remain DB-only at render time; canonical evidence/review pages keep entitlement filtering query-first; no added remote calls; artifact-truth presentation adds negligible overhead beyond current eager-loading and summary rendering
|
||||
**Constraints**: No new Microsoft Graph call path; no new artifact-producing operation family; existing destructive actions keep confirmation and audit semantics; `OperationRun.status` and `OperationRun.outcome` remain service-owned; canonical pages must not leak unauthorized tenant counts, filters, or labels
|
||||
**Scale/Scope**: First slice covers 8 existing surfaces across 4 artifact families plus tenantless run detail, using one shared truth vocabulary and preserving historical artifact intelligibility without widening into inventory, onboarding, or restore domains
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- **Pre-Phase-0 Gate: PASS**
|
||||
- Inventory-first: PASS. The feature reclassifies existing baseline, evidence, review, and review-pack artifacts; it does not change the inventory/evidence collection model or promote snapshots over last-observed state.
|
||||
- Read/write separation: PASS. The slice is primarily presentational. Existing writes such as refresh, publish, export, archive, and expire remain explicit, confirmed where destructive, audited where already required, and tested.
|
||||
- Graph contract path: PASS. No new Graph calls or contract changes are introduced.
|
||||
- Deterministic capabilities: PASS. Existing capabilities remain canonical (`workspace baselines`, `evidence.view/manage`, `tenant_review.view/manage`, `review_pack.view/manage`); no raw role checks are added.
|
||||
- RBAC-UX and isolation: PASS. Tenant detail remains tenant-scoped; workspace canonical evidence/review pages stay query-scoped to entitled tenants only; non-members remain 404, in-scope capability denials remain 403.
|
||||
- Global search: PASS. All affected resources are already non-globally-searchable or unchanged in search behavior; no new global-search surface is added.
|
||||
- Run observability: PASS. Existing artifact-producing operations already use `OperationRun`; this slice only improves how run detail communicates artifact truth.
|
||||
- Ops-UX 3-surface feedback: PASS. No new feedback surface is introduced; run detail remains the canonical live/terminal interpretation surface.
|
||||
- Ops-UX lifecycle ownership: PASS. No direct `status` or `outcome` transitions are introduced outside `OperationRunService`.
|
||||
- Ops-UX summary counts: PASS. Existing numeric summary counts remain canonical; artifact-truth interpretation may consume them but will not replace them with free-text metrics.
|
||||
- Data minimization: PASS. The design prefers derived truth envelopes over new raw payload persistence.
|
||||
- BADGE-001: PASS. Shared semantics must flow through centralized badge domains or presenter helpers, not page-local label/color mappings.
|
||||
- UI-NAMING-001 and OPSURF-001: PASS. Operator-facing surfaces lead with trustworthy artifact answers and keep renderer/support limitations in secondary diagnostics.
|
||||
- Filament Action Surface Contract: PASS. The first slice changes status messaging, helper copy, empty states, and action-enable explanations on existing resources/pages without expanding mutation surface area.
|
||||
- Filament UX-001: PASS. Existing list/detail patterns remain intact; truth messaging is layered into current Infolists, tables, and canonical pages.
|
||||
- UI-STD-001 list surface checklist: PASS WITH REQUIRED REVIEW. Because existing baseline, evidence, tenant review, review-pack, and canonical summary list surfaces are modified, implementation and validation must reference `docs/product/standards/list-surface-review-checklist.md`.
|
||||
|
||||
**Post-Phase-1 Re-check: PASS**
|
||||
- The proposed design keeps the slice presentation-first, respects tenant/workspace ownership, avoids new remote work, reuses existing canonical action surfaces, and introduces no constitution violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/158-artifact-truth-semantics/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── artifact-truth.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ ├── Monitoring/
|
||||
│ │ ├── Operations/
|
||||
│ │ └── Reviews/
|
||||
│ └── Resources/
|
||||
│ ├── BaselineSnapshotResource.php
|
||||
│ ├── EvidenceSnapshotResource.php
|
||||
│ ├── ReviewPackResource.php
|
||||
│ ├── TenantReviewResource.php
|
||||
│ └── OperationRunResource.php
|
||||
├── Models/
|
||||
│ ├── EvidenceSnapshot.php
|
||||
│ ├── ReviewPack.php
|
||||
│ ├── TenantReview.php
|
||||
│ └── OperationRun.php
|
||||
├── Services/
|
||||
│ ├── Baselines/
|
||||
│ ├── Evidence/
|
||||
│ └── TenantReviews/
|
||||
└── Support/
|
||||
├── Badges/
|
||||
├── ReasonTranslation/
|
||||
├── Ui/
|
||||
└── Operations/
|
||||
|
||||
resources/
|
||||
└── views/
|
||||
└── filament/
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Evidence/
|
||||
│ ├── TenantReview/
|
||||
│ ├── ReviewPack/
|
||||
│ ├── Monitoring/
|
||||
│ └── ManagedTenants/
|
||||
└── Unit/
|
||||
├── Evidence/
|
||||
├── TenantReview/
|
||||
├── ReviewPack/
|
||||
└── Badges/
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the existing Laravel monolith structure and deliver the slice by modifying existing resources/pages, shared badge or presenter support, and focused tests. No new base folders or new runtime subsystems are required. Any new helper should live under existing `app/Support` or domain service namespaces rather than introducing a new module root.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| None | N/A | N/A |
|
||||
|
||||
## Filament v5 Agent Output Contract
|
||||
|
||||
1. **Livewire v4.0+ compliance**: Yes. The plan stays within Filament v5 + Livewire v4 components and helpers already present in the repo.
|
||||
2. **Provider registration**: No new panel is introduced. Existing panel providers remain registered in `bootstrap/providers.php`.
|
||||
3. **Global search**: Affected resources remain non-globally-searchable in this slice, so the Edit/View-page rule is unchanged. No new globally searchable resource is added.
|
||||
4. **Destructive actions**: Existing destructive actions remain the only destructive actions in scope: evidence snapshot expire, review archive, review-pack expire, and any existing destructive baseline actions. They continue to use `->action(...)`, confirmation, and server-side authorization.
|
||||
5. **Asset strategy**: No new custom assets are planned. The slice uses existing Filament rendering, badges, and Blade views. Deployment still relies on the existing Filament asset pipeline, including `vendor/bin/sail artisan filament:assets` where registered assets are deployed.
|
||||
6. **Testing plan**: Cover unit semantics for badge or truth mapping, Livewire or feature tests for modified baseline/evidence/review/review-pack Filament resources and canonical pages, canonical RBAC regression tests for evidence/review summaries, operation-run detail regression tests for artifact-targeted truth messaging, empty-state distinctions, and the 12-case curated manual validation set captured in `quickstart.md`.
|
||||
156
specs/158-artifact-truth-semantics/quickstart.md
Normal file
156
specs/158-artifact-truth-semantics/quickstart.md
Normal file
@ -0,0 +1,156 @@
|
||||
# Quickstart: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
||||
|
||||
## Goal
|
||||
|
||||
Deliver the first truthful-artifact slice without widening scope beyond existing governance artifacts and artifact-targeted run detail.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Shared Truth Envelope
|
||||
|
||||
**Goal:** Centralize how governance artifacts express truth, degradation, readiness, and next steps.
|
||||
|
||||
1. Add or extend shared presenter support under existing `app/Support` or domain service namespaces.
|
||||
2. Normalize which dimensions are primary vs diagnostic for:
|
||||
- baseline snapshot fidelity and gaps
|
||||
- evidence completeness and freshness
|
||||
- tenant review completeness and publication blockers
|
||||
- review-pack lifecycle vs provenance
|
||||
- artifact-targeted run outcome vs artifact result
|
||||
3. Reuse `BadgeCatalog`, `BadgeRenderer`, `OperatorOutcomeTaxonomy`, and `ReasonPresenter` instead of page-local label logic.
|
||||
4. Add targeted unit tests for any new or remapped badge or presenter semantics.
|
||||
|
||||
### Phase 2: Artifact Family Integration
|
||||
|
||||
**Goal:** Apply the truth envelope to existing resource lists and detail views.
|
||||
|
||||
1. Update baseline snapshot list/detail messaging so fidelity/support signals stay diagnostic while false-green capture outcomes are clearly degraded.
|
||||
2. Update evidence snapshot list/detail and evidence overview rows so completeness, freshness, and next-step semantics are explicit.
|
||||
3. Update tenant review list/detail and review register so review existence, completeness, and publication readiness are separated.
|
||||
4. Update review-pack list/detail so file existence is separated from trustworthy stakeholder-output provenance.
|
||||
5. Keep current action surfaces and authorization rules unchanged unless explanation or enablement reasons must reflect truth semantics.
|
||||
|
||||
### Phase 3: Canonical Run Detail Integration
|
||||
|
||||
**Goal:** Make artifact-targeted operation runs answer whether a trustworthy artifact was produced.
|
||||
|
||||
1. Extend the operation-run enterprise detail builder for:
|
||||
- `baseline_capture`
|
||||
- `tenant.evidence.snapshot.generate`
|
||||
- `tenant.review.compose`
|
||||
- `tenant.review_pack.generate`
|
||||
2. Show a primary artifact-truth summary before raw JSON/context.
|
||||
3. Reuse related links and reason translation for next-step guidance.
|
||||
4. Preserve diagnostics as a secondary boundary.
|
||||
|
||||
### Phase 4: RBAC and Regression Coverage
|
||||
|
||||
**Goal:** Prove the slice is truthful and tenant-safe.
|
||||
|
||||
1. Add positive and negative authorization tests for canonical evidence and review pages.
|
||||
2. Add focused coverage for:
|
||||
- false-green baseline examples
|
||||
- partial and stale evidence
|
||||
- non-publishable reviews
|
||||
- review-pack provenance and historical availability
|
||||
- empty-state distinctions for not-created, degraded, and historical-only cases
|
||||
- artifact-targeted run detail messaging
|
||||
3. Run formatter and affected tests.
|
||||
4. Review the affected list surfaces against `docs/product/standards/list-surface-review-checklist.md`.
|
||||
|
||||
## Curated Manual Validation Set
|
||||
|
||||
Validate 12 curated artifact cases before sign-off so the operator can answer from one inspection step whether the artifact exists, whether it is trustworthy, and whether action is required.
|
||||
|
||||
1. Healthy baseline artifact
|
||||
2. False-green baseline with no trustworthy artifact
|
||||
3. Historical baseline trace that is intentionally non-usable
|
||||
4. Healthy evidence snapshot
|
||||
5. Partial evidence snapshot with missing dimensions
|
||||
6. Stale evidence snapshot with no further action required
|
||||
7. Draft tenant review that is internally useful but not publishable
|
||||
8. Blocked tenant review with explicit next step
|
||||
9. Publishable tenant review
|
||||
10. Historical review pack derived from a formerly publishable review
|
||||
11. Current review pack blocked by regressed source readiness
|
||||
12. Artifact-targeted run that completed but produced a degraded artifact
|
||||
|
||||
## Completed Validation Checklist
|
||||
|
||||
Validated on March 22, 2026 through the focused Spec 158 regression suite plus the canonical authorization and guard checks.
|
||||
|
||||
- [x] Healthy baseline artifact
|
||||
- [x] False-green baseline with no trustworthy artifact
|
||||
- [x] Historical baseline trace that is intentionally non-usable
|
||||
- [x] Healthy evidence snapshot
|
||||
- [x] Partial evidence snapshot with missing dimensions
|
||||
- [x] Stale evidence snapshot with no further action required
|
||||
- [x] Draft tenant review that is internally useful but not publishable
|
||||
- [x] Blocked tenant review with explicit next step
|
||||
- [x] Publishable tenant review
|
||||
- [x] Historical review pack derived from a formerly publishable review
|
||||
- [x] Current review pack blocked by regressed source readiness
|
||||
- [x] Artifact-targeted run that completed but produced a degraded artifact
|
||||
|
||||
## List Surface Review Notes
|
||||
|
||||
- [x] Baseline snapshot list/detail keeps artifact truth separate from fidelity diagnostics while preserving the existing sortable/searchable structure.
|
||||
- [x] Evidence overview remains a tenant-safe read-only summary with one drill-down action per row and explicit artifact-truth, freshness, and next-step columns.
|
||||
- [x] Evidence snapshot, tenant review, review register, and review pack surfaces keep badge-based state rendering, domain-specific empty states, and authorization boundaries intact after the new truth columns were added.
|
||||
- [x] Canonical evidence and review summaries continue to scope rows, tenant filters, and drill-down links to entitled tenants only.
|
||||
|
||||
## Likely File Inventory
|
||||
|
||||
### Modified Files
|
||||
|
||||
```text
|
||||
app/Filament/Resources/BaselineSnapshotResource.php
|
||||
app/Filament/Resources/EvidenceSnapshotResource.php
|
||||
app/Filament/Resources/TenantReviewResource.php
|
||||
app/Filament/Resources/ReviewPackResource.php
|
||||
app/Filament/Resources/OperationRunResource.php
|
||||
app/Filament/Pages/Monitoring/EvidenceOverview.php
|
||||
app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
app/Filament/Pages/Reviews/ReviewRegister.php
|
||||
app/Support/Badges/OperatorOutcomeTaxonomy.php
|
||||
app/Support/Badges/BadgeCatalog.php
|
||||
app/Support/Badges/Domains/*.php
|
||||
app/Support/ReasonTranslation/*.php
|
||||
app/Services/TenantReviews/TenantReviewReadinessGate.php
|
||||
tests/Feature/Evidence/*.php
|
||||
tests/Feature/TenantReview/*.php
|
||||
tests/Feature/ReviewPack/*.php
|
||||
tests/Feature/Monitoring/*.php
|
||||
tests/Unit/**/*.php
|
||||
```
|
||||
|
||||
### Possible New Helper Files
|
||||
|
||||
```text
|
||||
app/Support/Ui/GovernanceArtifactTruth/*.php
|
||||
app/Support/Badges/Domains/<new-or-remapped-domain>.php
|
||||
tests/Unit/Badges/GovernanceArtifactTruthTest.php
|
||||
```
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Evidence
|
||||
vendor/bin/sail artisan test --compact tests/Feature/TenantReview
|
||||
vendor/bin/sail artisan test --compact tests/Feature/ReviewPack
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Monitoring
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Verification Review Checklist
|
||||
|
||||
1. Review baseline snapshot, evidence snapshot, tenant review, review-pack, evidence overview, and review register list surfaces against `docs/product/standards/list-surface-review-checklist.md`.
|
||||
2. Confirm empty-state wording distinguishes not-created, degraded, and historical-only cases where those states can appear.
|
||||
3. Confirm canonical evidence/review list filters and counts remain tenant-safe for both allowed and disallowed tenants.
|
||||
|
||||
## Rollout Notes
|
||||
|
||||
- No migration is required unless implementation reveals a minimal summary/context enrichment need.
|
||||
- If enrichment is needed, keep it additive to existing `summary` or `context` payloads and limit it to provenance or usability distinctions that cannot be derived safely at render time.
|
||||
- No deploy-time asset change is expected beyond the existing Filament asset pipeline.
|
||||
- Historical records must remain readable even if some new truth-envelope inputs are absent.
|
||||
57
specs/158-artifact-truth-semantics/research.md
Normal file
57
specs/158-artifact-truth-semantics/research.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Research: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
||||
|
||||
## Decision 1: Implement the first slice as a shared presentation/read-model layer over existing persisted artifact fields
|
||||
|
||||
- **Decision**: Introduce a shared artifact-truth presenter or view-model layer that composes current persisted artifact state from existing enums, badge domains, `summary` payloads, and `OperationRun.context` rather than adding a new database table in the first slice.
|
||||
- **Rationale**: Baseline snapshots already expose fidelity and gap summaries, evidence snapshots already persist completeness plus missing/stale counts, tenant reviews already persist completeness and publish blockers, and review packs already persist generation state plus source review/evidence metadata. The main problem is inconsistent operator interpretation, not missing primary persistence.
|
||||
- **Alternatives considered**:
|
||||
- Add a new `artifact_truth_states` table: rejected because the first slice would duplicate existing truth and risk divergence.
|
||||
- Hard-code per-page label logic: rejected because it would regress BADGE-001 and Spec 156.
|
||||
|
||||
## Decision 2: Reuse centralized badge semantics and reason translation instead of creating a parallel truth taxonomy
|
||||
|
||||
- **Decision**: Build the truth envelope on top of `BadgeCatalog`, `BadgeRenderer`, `OperatorOutcomeTaxonomy`, and `ReasonPresenter`, extending or remapping badge domains only where existing domains still collapse artifact meanings.
|
||||
- **Rationale**: Evidence completeness and tenant review completeness already consume the taxonomy, baseline fidelity already uses a centralized badge domain, and run detail already translates blocked reasons through `ReasonPresenter`. This creates visible adoption of Specs 156 and 157 instead of inventing a competing presentation model.
|
||||
- **Alternatives considered**:
|
||||
- Create dedicated per-artifact truth enums for every surface: rejected because it would fragment semantics and duplicate the taxonomy.
|
||||
- Rely only on free-form helper text: rejected because operator consistency would remain untestable.
|
||||
|
||||
## Decision 3: Keep canonical workspace summaries entitlement-safe by filtering tenants before deriving labels, counts, and filter options
|
||||
|
||||
- **Decision**: Preserve the current query-first tenant filtering pattern on canonical workspace pages and derive truth labels, readiness counts, and filter options only from the already-authorized record set.
|
||||
- **Rationale**: `EvidenceOverview` builds rows only from tenants for which the user can `evidence.view`, and `ReviewRegister` limits both the query and tenant filter options to entitled tenants via `TenantReviewRegisterService` and `CanonicalAdminTenantFilterState`. This is the correct security boundary for Spec 158's non-leakage requirements.
|
||||
- **Alternatives considered**:
|
||||
- Precompute workspace-wide aggregate truth counts and hide unauthorized rows later: rejected because counts and filter options would become leakage-prone.
|
||||
- Allow canonical pages to read all tenant rows and filter in Blade/Livewire: rejected because that would weaken 404/403 semantics and be harder to regression-test.
|
||||
|
||||
## Decision 4: Attach artifact-truth messaging to tenantless run detail via the existing enterprise-detail builder and related-link model
|
||||
|
||||
- **Decision**: Extend `OperationRunResource::enterpriseDetailPage()` and the tenantless run viewer to add an artifact-truth section for baseline capture, evidence generation, tenant review composition, and review-pack generation, reusing `OperationRunLinks` and `ReasonPresenter`.
|
||||
- **Rationale**: The canonical run detail page already leads with operator-first state, exposes related links, and has operation-type-specific sections for baseline compare and baseline capture evidence. Adding artifact-produced/usable/degraded interpretation there stays aligned with the existing Ops-UX reference implementation.
|
||||
- **Alternatives considered**:
|
||||
- Add a separate artifact-truth run detail page: rejected because it would violate the single canonical run-detail surface.
|
||||
- Expose only raw `context` or failure JSON: rejected because it does not satisfy FR-158-012.
|
||||
|
||||
## Decision 5: Use targeted summary enrichment only where current persisted fields cannot explain truthful artifact provenance
|
||||
|
||||
- **Decision**: Preserve existing models as the primary source, but allow small, backward-compatible enrichment of existing `summary` or `context` payloads when current fields cannot truthfully answer whether an artifact is usable, current, or publishable.
|
||||
- **Rationale**: Some families already have enough persisted state, while others, especially review packs and baseline-capture runs, may need a stable provenance or degraded-artifact explanation beyond the lifecycle enum alone. Enriching current JSON payloads is lower-risk than adding new tables or widening operation types.
|
||||
- **Alternatives considered**:
|
||||
- Force the entire slice to use current fields only: rejected because some false-green cases would stay ambiguous.
|
||||
- Redesign underlying artifact schemas first: rejected because it would turn a bounded truth-semantics slice into a domain rewrite.
|
||||
|
||||
## Decision 6: Review-pack truth should derive from pack lifecycle plus source review and evidence provenance, not from pack status alone
|
||||
|
||||
- **Decision**: Model review-pack truth as a composition of pack lifecycle (`queued`, `generating`, `ready`, `failed`, `expired`), source review readiness or publication state, anchored evidence completeness, and freshness/provenance metadata already persisted in `summary`.
|
||||
- **Rationale**: `ReviewPackStatusBadge` currently treats `ready` as if it were enough, but the spec explicitly requires operators to know whether the pack is a trustworthy export of a publishable review or only a historical derivative.
|
||||
- **Alternatives considered**:
|
||||
- Keep `ReviewPackStatus` as the sole operator state: rejected because it cannot distinguish “pack file exists” from “pack is a trustworthy stakeholder output.”
|
||||
- Add a second lifecycle enum to `ReviewPack`: rejected for the first slice because provenance can be derived from existing linked models and summary payloads.
|
||||
|
||||
## Decision 7: Migration guidance is semantic migration, not data migration, for the first slice
|
||||
|
||||
- **Decision**: Treat FR-158-017 as a semantic migration of labels, helper copy, badge usage, and empty-state wording rather than a schema or record backfill migration.
|
||||
- **Rationale**: The current ambiguity stems from overloaded labels such as `missing`, `partial`, `ready`, and `draft`. Existing records remain valid if the new truth layer interprets them more precisely and, where needed, supplements them with cause-specific explanation.
|
||||
- **Alternatives considered**:
|
||||
- Backfill historical records with new truth fields: rejected because current records can be interpreted through derived envelopes.
|
||||
- Leave old wording in place for backward compatibility: rejected because the feature's core value is truthful operator meaning.
|
||||
202
specs/158-artifact-truth-semantics/spec.md
Normal file
202
specs/158-artifact-truth-semantics/spec.md
Normal file
@ -0,0 +1,202 @@
|
||||
# Feature Specification: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
||||
|
||||
**Feature Branch**: `158-artifact-truth-semantics`
|
||||
**Created**: 2026-03-22
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Governance Artifact Truthful Outcomes & Fidelity Semantics"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace + tenant + canonical-view
|
||||
- **Primary Routes**:
|
||||
- Workspace-scoped baseline snapshot list and detail surfaces
|
||||
- Tenant-scoped evidence snapshot list and detail surfaces
|
||||
- Workspace-scoped evidence overview surface
|
||||
- Tenant-scoped tenant-review list and detail surfaces
|
||||
- Workspace-scoped review register surface
|
||||
- Tenant-scoped review-pack list and detail surfaces where publication or export readiness is shown
|
||||
- Existing run-detail surfaces only when the run explains the truth state of a baseline, evidence, review, or review-pack artifact
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned records affected: baseline snapshots, baseline snapshot items, workspace-scoped review register summaries, and canonical evidence overview summaries.
|
||||
- Tenant-owned records affected: evidence snapshots, evidence snapshot items, tenant reviews, tenant review sections, review packs, and tenant-scoped artifact readiness summaries.
|
||||
- Workspace-owned `OperationRun` records remain the canonical observability layer for baseline capture, evidence generation, tenant review composition, and review-pack generation, but this feature only changes how those runs explain artifact truth.
|
||||
- This feature does not change ownership boundaries; it standardizes operator-facing truth semantics across existing artifact families.
|
||||
- **RBAC**:
|
||||
- Workspace membership remains mandatory for every affected surface.
|
||||
- Workspace baseline surfaces continue to require the existing workspace baseline capabilities for list/detail visibility and management actions.
|
||||
- Evidence surfaces continue to require `evidence.view` and `evidence.manage`.
|
||||
- Tenant review surfaces continue to require `tenant_review.view` and `tenant_review.manage`.
|
||||
- Review-pack surfaces continue to require `review_pack.view` and `review_pack.manage`.
|
||||
- Non-members or wrong-scope users remain deny-as-not-found; in-scope members missing required capability remain forbidden.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Canonical evidence and review register surfaces open prefiltered to the current tenant when entered from tenant context. Workspace-owned baseline snapshot surfaces remain workspace-scoped by default, but any tenant-linked drill-in or summary chip must stay limited to the current tenant context rather than broadening back to all tenants implicitly.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Badge labels, summary rows, readiness totals, filter values, and empty-state wording on canonical evidence and review surfaces must be derived only from authorized tenant records. Shared truth labels such as `Partial`, `Stale`, `Publishable`, `Blocked`, or `No data` must not allow operators to infer unauthorized tenant state.
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline snapshot list and detail | Workspace governance operator | List/detail | Is this baseline artifact trustworthy enough to compare or review? | Snapshot existence, fidelity or completeness state, freshness, whether comparison use is safe, next action when degraded | Renderer limitations, raw provenance fields, low-level capture counters, internal reason fragments | artifact existence, execution outcome, fidelity or completeness, support maturity, operator actionability | TenantPilot only / simulation only | View snapshot, open related compare or run detail | Existing destructive actions unchanged |
|
||||
| Evidence snapshot list/detail and evidence overview | Tenant or workspace governance operator | List/detail/overview | What evidence exists, how complete is it, and is it still current? | Snapshot status, completeness, stale or missing dimensions, whether evidence is reviewable, next action | Raw dimension payloads, internal metric keys, technical source references | artifact existence, completeness, freshness, readiness, operator actionability | TenantPilot only | View snapshot, create or refresh snapshot where allowed | Existing expiration actions unchanged |
|
||||
| Tenant review list/detail and review register | Tenant or workspace review owner | List/detail/register | Is this review merely present, or is it actually ready to publish or export? | Review status, completeness, readiness blockers, publishability, anchored evidence basis summary, next action | Raw section payloads, fingerprint data, internal readiness calculations | artifact existence, completeness, publication readiness, freshness, operator actionability | TenantPilot only | View review, create next review, refresh, publish when eligible | Existing archive action remains destructive |
|
||||
| Review-pack list/detail | Tenant review operator | List/detail | Is this pack a trustworthy stakeholder output or only a draft or blocked derivative? | Pack existence, generation outcome, readiness source, blocking cause, export suitability, next action | Raw generation payloads, internal export options, technical error fragments | artifact existence, execution outcome, publication readiness, freshness, operator actionability | TenantPilot only | View pack, export pack, open source review | Existing archive or destructive actions unchanged |
|
||||
| Artifact-targeted run detail | Governance operator | Detail | Did the run merely finish, or did it produce a trustworthy artifact? | Primary cause, whether the intended artifact was produced, whether the artifact is usable, next action | Raw context JSON, low-level reason codes, internal counters beyond the operator summary | execution outcome, artifact existence, fidelity or completeness, operator actionability | TenantPilot only / simulation only depending on run family | View related artifact, retry where already allowed | Existing dangerous rerun or mutation actions unchanged |
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Distinguish real artifact truth from cosmetic success (Priority: P1)
|
||||
|
||||
As a governance operator, I want baseline, evidence, review, and review-pack surfaces to tell me whether a usable artifact truly exists, so that I do not mistake `completed` or `present` for `trustworthy and ready`.
|
||||
|
||||
**Why this priority**: This is the direct product-trust problem. False-green or ambiguous artifact states damage review credibility more than internal platform inconsistency that operators never see.
|
||||
|
||||
**Independent Test**: Can be fully tested by preparing examples where an artifact exists but is degraded, stale, blocked by prerequisites, metadata-only, or not publishable, then verifying that the primary surface distinguishes those cases from truly usable artifacts in one inspection step.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a baseline capture run completed without producing a credible baseline artifact, **When** the operator opens the related run detail or snapshot surface, **Then** the product states that no trustworthy baseline was produced and explains the next action.
|
||||
2. **Given** an evidence snapshot exists but some required dimensions are stale or missing, **When** the operator views the snapshot or overview, **Then** the surface shows the snapshot as partial or stale rather than as fully ready.
|
||||
3. **Given** a tenant review exists but is not ready to publish or export, **When** the operator views the review or review register, **Then** the product distinguishes `review exists` from `review is publishable` and names the blocking reason.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Understand fidelity and readiness without decoding diagnostics (Priority: P1)
|
||||
|
||||
As an operator, I want artifact surfaces to separate completeness, freshness, support limitations, and publication readiness, so that I can tell what is wrong and whether action is required without reading raw payloads or internal codes.
|
||||
|
||||
**Why this priority**: The product already contains the truth in many places, but it reaches operators as overloaded words such as `missing`, `stale`, `unsupported`, or `partial` without saying which dimension they belong to.
|
||||
|
||||
**Independent Test**: Can be fully tested by reviewing curated baseline, evidence, review, and review-pack examples containing mixed conditions and confirming that each degraded condition is shown on the correct semantic dimension with the correct next-action guidance.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an artifact is usable but stale, **When** the operator views it, **Then** freshness is shown as the primary issue and the artifact is not mislabeled as missing or broken.
|
||||
2. **Given** an artifact is metadata-only, reference-only, or affected by renderer maturity limits, **When** the operator views it, **Then** those support facts are shown as diagnostics and do not replace the primary truth state.
|
||||
3. **Given** an artifact is non-green but no operator action is required, **When** the operator views it, **Then** the surface explicitly says that no action is needed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Reuse one truth model across the governance chain (Priority: P2)
|
||||
|
||||
As a product owner, I want baseline, evidence, review, and review-pack surfaces to share one truthful outcome and fidelity model, so that the governance chain reads as one coherent product rather than a set of locally invented status systems.
|
||||
|
||||
**Why this priority**: This turns the outcome taxonomy and reason translation foundations into visible product value. Without this adoption slice, the foundations remain theoretical.
|
||||
|
||||
**Independent Test**: Can be fully tested by reviewing the bounded adoption set and confirming that equivalent states such as `partial`, `stale`, `blocked by prerequisite`, `ready`, and `not publishable` keep the same meaning across artifact families.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** two artifact families show comparable degraded states, **When** the operator moves between them, **Then** the same word keeps the same meaning and severity.
|
||||
2. **Given** one artifact is present but not publishable and another is absent entirely, **When** the operator compares them, **Then** the surfaces distinguish existence from readiness rather than collapsing both into a generic warning.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A baseline snapshot exists and a related run is marked complete, but the snapshot was built from zero in-scope subjects or an unusable upstream source; the surface must say that the artifact is not trustworthy.
|
||||
- An evidence snapshot is present but only contains metadata or reference placeholders for one or more dimensions; the surface must not present those dimensions as fully collected evidence.
|
||||
- A tenant review is draft because required sections are incomplete, but the review itself is still useful for internal preparation; the surface must distinguish internal usability from stakeholder readiness.
|
||||
- A review pack exists from a prior publishable review, but the latest review has regressed and is no longer publishable; historical pack availability must not mask current readiness regression.
|
||||
- A surface must show more than one degraded dimension at once, such as `artifact exists`, `freshness stale`, and `publication blocked`; the dimensions must remain separate instead of collapsing into one badge.
|
||||
- Canonical evidence or review surfaces are opened by an operator entitled to only a subset of tenants; state filters and counts must not expose foreign-tenant degraded artifacts.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph call path and no new artifact-producing operation family. It standardizes the operator-facing truth contract for existing baseline capture, evidence generation, tenant review composition, and review-pack generation flows. Existing safety gates, audit logging, and tenant isolation remain mandatory. If an adopted surface relies on DB-only lifecycle changes such as publish or archive, those actions continue to require audit history and existing confirmation rules.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature reuses existing `OperationRun` families for baseline capture, evidence generation, tenant review composition, and review-pack generation. It does not change the Ops-UX three-surface contract or create a parallel progress surface. `OperationRun.status` and `OperationRun.outcome` remain service-owned. The feature changes how artifact-targeted runs communicate whether the intended artifact was produced, whether it is usable, and what the operator should do next. Any summary-count wording shown to operators must stay compatible with numeric-only summary keys while avoiding false-green interpretations.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature affects workspace-admin baseline views, tenant-admin evidence and review surfaces, and workspace-scoped canonical evidence and review surfaces. Cross-plane access remains deny-as-not-found. Non-members or wrong-scope users remain `404`; in-scope users missing capability remain `403`. Artifact truth labels, readiness badges, filter values, and next-step hints must not reveal unauthorized tenant state. Existing server-side policies remain the source of truth for all view and mutation permissions.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature does not touch authentication handshakes.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This feature is a direct BADGE-001 adoption slice. Baseline fidelity, evidence completeness, review readiness, publication readiness, and artifact-targeted run truth states must consume centralized semantics rather than page-local mappings. Tests must cover new or remapped values introduced by this feature.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Target objects are baseline snapshots, evidence snapshots, tenant reviews, review packs, and artifact-targeted runs. Operator vocabulary must preserve one meaning per term across list badges, detail headers, helper copy, notifications, and readiness messages. Implementation-first wording such as `render fallback`, `metadata projection`, `section composer`, or `fingerprint mismatch` remains diagnostics-only.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** This feature materially refines existing operator-facing governance surfaces. Default-visible content must stay operator-first and answer: does the artifact exist, is it trustworthy, is it current, is it publishable, and what should I do next? Raw payloads, internal provenance, low-level counters, and machine-oriented reasons remain explicit secondary diagnostics.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature changes existing Filament resources and pages but does not add a new mutation family. The Action Surface Contract remains satisfied when affected pages keep their existing inspect affordances, preserve capability-gated mutations, and apply the new truth model only to status, helper copy, readiness messaging, and related action enablement reasons.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Affected list and detail screens keep their existing layouts, but status badges, readiness chips, summary panels, and empty states must reflect the truthful artifact model. Empty states must distinguish `artifact not created yet`, `artifact exists but is not ready`, and `no action needed` scenarios instead of treating all three as one generic absence state.
|
||||
|
||||
**Constitution alignment (UI-STD-001 list surfaces):** Because this feature modifies existing list surfaces for baseline snapshots, evidence snapshots, tenant reviews, review packs, and canonical review/evidence summaries, implementation and verification MUST reference `docs/product/standards/list-surface-review-checklist.md` so inspection affordances, filters, empty states, and row semantics remain compliant while truth messaging changes.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-158-001**: The system MUST define one governance-artifact truth model shared by baseline snapshots, evidence snapshots, tenant reviews, review packs, and artifact-targeted run summaries.
|
||||
- **FR-158-002**: The shared model MUST separate at least execution outcome, artifact existence, fidelity or completeness, freshness, support maturity, publication readiness, and operator actionability into distinct dimensions.
|
||||
- **FR-158-003**: An adopted surface MUST NOT treat `artifact exists` as equivalent to `artifact is trustworthy` or `artifact is ready`.
|
||||
- **FR-158-004**: Baseline-related surfaces MUST distinguish a technically completed run from a run that produced a credible baseline artifact.
|
||||
- **FR-158-005**: Baseline-related surfaces MUST treat zero-subject, unusable-upstream, blocked-upstream, or empty-snapshot outcomes as non-success artifact states unless the artifact is explicitly marked as non-usable historical trace.
|
||||
- **FR-158-006**: Evidence surfaces MUST distinguish complete, partial, stale, missing-input, metadata-only, and reference-only conditions without collapsing them into one generic warning.
|
||||
- **FR-158-007**: Review surfaces MUST distinguish review existence, review completeness, and review publication readiness as separate states.
|
||||
- **FR-158-008**: Review-pack surfaces MUST distinguish pack existence, pack generation outcome, and whether the source review was actually publishable at the time the pack was generated.
|
||||
- **FR-158-009**: Product-support or renderer-maturity facts MUST remain diagnostics-only and MUST NOT be shown as the primary warning or error state for an adopted artifact surface.
|
||||
- **FR-158-010**: Every adopted non-green artifact state MUST include a cause-specific explanation and either a next action, a destination, or an explicit `No action needed` message.
|
||||
- **FR-158-011**: Adopted surfaces MUST use the shared operator vocabulary established by Spec 156 and MUST consume humanized reasons from Spec 157 where cause wording is required.
|
||||
- **FR-158-012**: Artifact-targeted run detail MUST state whether the intended artifact was produced, whether it is usable, and why it is degraded when the run outcome alone would otherwise read as ambiguous.
|
||||
- **FR-158-013**: Canonical evidence and review overview surfaces MUST apply the same truth vocabulary as tenant-scoped detail surfaces while remaining entitlement-safe.
|
||||
- **FR-158-014**: The first implementation slice MUST cover baseline snapshot list/detail, evidence snapshot list/detail, evidence overview, tenant review list/detail, review register, review-pack list/detail, and artifact-targeted run detail summaries.
|
||||
- **FR-158-015**: The first implementation slice MUST explicitly exclude provider dispatch gating, restore lifecycle cleanup, and broad inventory or onboarding semantics beyond any existing artifact truth they already surface.
|
||||
- **FR-158-016**: Adopted surfaces MUST preserve historical artifact intelligibility, so older evidence snapshots, reviews, and review packs remain understandable even when newer artifacts are more complete or more current.
|
||||
- **FR-158-017**: The feature MUST define migration guidance for replacing overloaded labels such as `missing`, `stale`, `partial`, `unsupported`, `ready`, and `draft` with dimension-specific artifact truth messaging.
|
||||
- **FR-158-018**: The feature MUST include regression coverage for false-green baseline outcomes, partial or stale evidence, non-publishable reviews, review-pack readiness provenance, and non-member-safe canonical summaries.
|
||||
- **FR-158-019**: The feature MUST include at least one positive and one negative authorization test proving that truth labels, readiness counts, and filter values on canonical artifact surfaces do not leak unauthorized tenant state.
|
||||
- **FR-158-020**: The feature MUST ensure that adopted empty states distinguish `not created yet`, `created but degraded`, and `historical artifact available but not current` where those distinctions matter to operator decisions.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline snapshot list/detail | Existing workspace baseline snapshot resource | Existing actions unchanged | Existing clickable inspection remains primary | None added by this feature | None added by this feature | Existing CTA unchanged | Existing actions unchanged | N/A | Existing audit model unchanged | This feature changes fidelity, truth, and next-action messaging only |
|
||||
| Evidence snapshot list/detail | Existing tenant evidence snapshot resource | `Create snapshot` remains existing entry point | Existing clickable inspection remains primary | Existing row actions unchanged | None added by this feature | `Create first snapshot` remains | Existing actions unchanged | N/A | Existing audit model unchanged | Status, empty-state wording, and readiness explanation change |
|
||||
| Evidence overview | Existing workspace evidence overview page | `Clear filters` | Existing drill-down link remains primary | None | None | `Clear filters` when filtered | N/A | N/A | No new mutation | Canonical summary rows adopt truthful completeness and freshness semantics |
|
||||
| Tenant review list/detail and review register | Existing tenant review resource and workspace review register | Existing `Create review` and `Clear filters` entry points remain | Existing clickable inspection remains primary | Existing row actions unchanged | None added by this feature | Existing single CTA pattern remains | Existing `Refresh review`, `Publish review`, `Export executive pack`, `Archive review` remain | N/A | Existing audit model unchanged | This feature clarifies existence vs readiness vs publication truth |
|
||||
| Review-pack list/detail | Existing review-pack resource | Existing actions unchanged | Existing clickable inspection remains primary | Existing row actions unchanged | None added by this feature | Existing CTA unchanged | Existing actions unchanged | N/A | Existing audit model unchanged | Pack truth and provenance messaging change without adding new actions |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Governance Artifact Truth State**: The operator-facing envelope that explains whether an artifact exists, whether it is usable, whether it is current, and what to do next.
|
||||
- **Artifact Fidelity or Completeness State**: The dimension that explains how complete, trustworthy, or degraded the artifact content is without conflating that with execution outcome or readiness.
|
||||
- **Publication Readiness State**: The dimension that explains whether a review or review-derived pack is suitable for stakeholder publication or export.
|
||||
- **Artifact Support Maturity Signal**: Diagnostic-only information about renderer or interpretation capability that must not replace the primary artifact truth state.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-158-001**: In the first implementation slice, 100% of adopted artifact surfaces show artifact existence, fidelity or completeness, and readiness as separate meanings whenever more than one of those dimensions is relevant.
|
||||
- **SC-158-002**: In focused regression coverage, 100% of false-green baseline examples are rendered as non-success artifact states unless explicitly marked as non-usable historical traces.
|
||||
- **SC-158-003**: In focused regression coverage, 100% of adopted evidence and review examples with stale, partial, or blocked conditions include a next action or an explicit `No action needed` marker.
|
||||
- **SC-158-004**: In a manual review set of 12 curated artifact cases across baseline, evidence, review, and review-pack surfaces, operators can correctly answer whether the artifact exists, whether it is trustworthy, and whether action is required in at least 11 of 12 cases from one inspection step.
|
||||
- **SC-158-005**: In focused authorization regression coverage, 100% of canonical evidence and review summaries suppress unauthorized tenant labels, counts, and truth-state filter values.
|
||||
- **SC-158-006**: In the first implementation slice, no adopted artifact surface uses a renderer limitation or support-tier fact as the primary warning or error signal.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Spec 156 provides the shared outcome vocabulary, and Spec 157 provides the humanized cause wording that this feature consumes.
|
||||
- Existing baseline, evidence, review, and review-pack artifact models are stable enough that this slice can focus on truthful presentation rather than reworking domain ownership.
|
||||
- The first rollout is intentionally bounded to governance artifacts and their immediate run summaries, not to all product status systems.
|
||||
- Historical artifacts remain valuable even when newer artifacts are more complete, so the truth model must explain staleness or partiality without making historical records unintelligible.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Spec 118 - Baseline Drift Engine
|
||||
- Spec 153 - Evidence Domain Foundation
|
||||
- Spec 155 - Tenant Review Layer
|
||||
- Spec 156 - Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||
- Spec 157 - Operator Reason Code Translation and Humanization Contract
|
||||
- Existing baseline snapshot, evidence snapshot, tenant review, review-pack, and operation-run surfaces in the current admin panel
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Extending provider-backed action preflight or dispatch gating
|
||||
- Performing a broad restore lifecycle semantics cleanup outside artifact truth that already appears on adopted governance surfaces
|
||||
- Redefining every platform-wide `OperationRun` outcome
|
||||
- Redesigning the visual system or component library
|
||||
- Creating new artifact families or new long-running operation families
|
||||
|
||||
## Final Direction
|
||||
|
||||
This spec makes the outcome taxonomy and reason translation foundations visible in TenantPilot's most trust-sensitive product surfaces: baselines, evidence, reviews, and review packs. It focuses on the governance chain that customers actually evaluate in demos, audits, and day-to-day review work. The goal is not merely cleaner wording, but a stricter product guarantee that operators can tell whether an artifact exists, whether it is trustworthy, whether it is current, whether it is publishable, and what to do next.
|
||||
225
specs/158-artifact-truth-semantics/tasks.md
Normal file
225
specs/158-artifact-truth-semantics/tasks.md
Normal file
@ -0,0 +1,225 @@
|
||||
# Tasks: Governance Artifact Truthful Outcomes & Fidelity Semantics
|
||||
|
||||
**Input**: Design documents from `/specs/158-artifact-truth-semantics/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/artifact-truth.openapi.yaml, quickstart.md
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature because it changes runtime behavior and operator-visible semantics across existing Laravel/Filament surfaces.
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Establish setup scaffolding, curated validation cases, and failing test shells for artifact-truth work.
|
||||
|
||||
- [X] T001 Create curated artifact-truth fixture builders and case datasets in `tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php` and `tests/Feature/Monitoring/ArtifactTruthManualCasesDataset.php`
|
||||
- [X] T002 [P] Create failing test shells for the shared truth layer and adopted surfaces in `tests/Unit/Badges/GovernanceArtifactTruthTest.php`, `tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`, `tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php`, `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/TenantReview/TenantReviewUiContractTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||
- [X] T003 [P] Capture the 12-case manual validation matrix and first-slice verification targets in `specs/158-artifact-truth-semantics/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Build the shared truth model and cross-cutting helpers that every story depends on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [X] T004 Implement the `ArtifactTruthEnvelope`, `ArtifactTruthDimension`, and `ArtifactTruthCause` read-model DTOs in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthEnvelope.php`, `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthDimension.php`, and `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthCause.php`
|
||||
- [X] T005 Implement family-specific truth resolution and next-action composition in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`
|
||||
- [X] T006 [P] Extend shared taxonomy and badge registration for artifact-truth axes in `app/Support/Badges/OperatorOutcomeTaxonomy.php`, `app/Support/Badges/BadgeCatalog.php`, and `app/Support/Badges/BadgeDomain.php`
|
||||
- [X] T007 [P] Wire centralized reason translation into artifact-truth presentation in `app/Support/ReasonTranslation/ReasonPresenter.php` and `app/Support/ReasonTranslation/ReasonTranslator.php`
|
||||
- [X] T008 [P] Add canonical artifact/run navigation helpers and a conditional summary/context enrichment seam for the truth layer in `app/Support/OperationRunLinks.php`, `app/Support/OperationCatalog.php`, `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, and `app/Models/OperationRun.php`
|
||||
- [X] T009 [P] Add foundational unit coverage for truth-envelope mapping and badge remaps in `tests/Unit/Badges/GovernanceArtifactTruthTest.php`, `tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php`, and `tests/Unit/TenantReview/TenantReviewBadgeTest.php`
|
||||
|
||||
**Checkpoint**: Shared truth foundation is ready; user story work can now proceed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Distinguish Real Artifact Truth From Cosmetic Success (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make baseline, evidence, review, and review-pack surfaces clearly distinguish artifact existence from trustworthy usability.
|
||||
|
||||
**Independent Test**: Prepare degraded and healthy examples for each artifact family and verify that one inspection step answers whether a trustworthy artifact exists.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T010 [P] [US1] Add false-green baseline list/detail and artifact-targeted run-detail regressions in `tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php`, `tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php`, `tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`, and `tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
|
||||
- [X] T011 [P] [US1] Add truthful artifact-existence feature coverage for evidence, reviews, and review packs in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/TenantReview/TenantReviewLifecycleTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T012 [US1] Apply baseline artifact-existence and trust messaging to list/detail surfaces in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ListBaselineSnapshots.php`, and `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`
|
||||
- [X] T013 [US1] Apply evidence artifact-existence and trust messaging to tenant evidence list/detail surfaces in `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/EvidenceSnapshotResource/Pages/ListEvidenceSnapshots.php`, and `app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||
- [X] T014 [US1] Apply review existence-versus-publishability messaging to tenant review list/detail surfaces in `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/TenantReviewResource/Pages/ListTenantReviews.php`, and `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||
- [X] T015 [US1] Apply review-pack existence-versus-trustworthy-output messaging to pack list/detail surfaces in `app/Filament/Resources/ReviewPackResource.php`, `app/Filament/Resources/ReviewPackResource/Pages/ListReviewPacks.php`, and `app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`
|
||||
- [X] T016 [US1] Add artifact-produced and artifact-usable summaries to canonical run detail in `app/Filament/Resources/OperationRunResource.php` and `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when each adopted surface clearly distinguishes “artifact exists” from “artifact is trustworthy and usable.”
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Understand Fidelity And Readiness Without Decoding Diagnostics (Priority: P1)
|
||||
|
||||
**Goal**: Separate completeness, freshness, support limitations, and publication readiness so operators understand the degraded dimension and required action.
|
||||
|
||||
**Independent Test**: Review mixed-condition examples and confirm that primary vs diagnostic state, blocker guidance, and “No action needed” semantics are rendered correctly.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T017 [P] [US2] Add mixed-dimension and diagnostic-boundary tests in `tests/Unit/Badges/GovernanceArtifactTruthTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||
- [X] T018 [P] [US2] Add blocker-guidance, evidence empty-state, historical-artifact, and explicit no-action-needed regressions in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `tests/Feature/ReviewPack/ReviewPackResourceTest.php`, and `tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T019 [US2] Separate primary versus diagnostic baseline fidelity semantics in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Support/Badges/Domains/BaselineSnapshotFidelityBadge.php`, and `app/Services/Baselines/SnapshotRendering/FidelityState.php`
|
||||
- [X] T020 [US2] Separate evidence completeness, freshness, metadata-only, and reference-only semantics in `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Pages/Monitoring/EvidenceOverview.php`, and `app/Models/EvidenceSnapshot.php`
|
||||
- [X] T021 [US2] Separate review completeness, publication readiness, and blocker guidance in `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Models/TenantReview.php`, and `app/Services/TenantReviews/TenantReviewReadinessGate.php`
|
||||
- [X] T022 [US2] Separate review-pack lifecycle from provenance and readiness semantics in `app/Filament/Resources/ReviewPackResource.php`, `app/Models/ReviewPack.php`, and `app/Support/Badges/Domains/ReviewPackStatusBadge.php`
|
||||
- [X] T023 [US2] Add explicit next-step and “No action needed” handling to the shared truth layer and run detail in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `app/Support/ReasonTranslation/ReasonPresenter.php`, and `app/Filament/Resources/OperationRunResource.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when degraded states are dimension-specific, diagnostics stay secondary, and operator follow-up guidance is explicit.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Reuse One Truth Model Across The Governance Chain (Priority: P2)
|
||||
|
||||
**Goal**: Keep equivalent artifact states semantically consistent across baseline, evidence, review, review-pack, and canonical workspace summaries.
|
||||
|
||||
**Independent Test**: Compare equivalent artifact conditions across the adopted surfaces and verify that labels, severity, and count/filter behavior remain consistent and tenant-safe.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T024 [P] [US3] Add cross-surface vocabulary consistency coverage in `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterTest.php`, `tests/Feature/ReviewPack/TenantReviewDerivedReviewPackTest.php`, and `tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`
|
||||
- [X] T025 [P] [US3] Add explicit positive and negative canonical authorization tests for truth labels, counts, and filter values in `tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php`, and `tests/Feature/TenantReview/TenantReviewRegisterPrefilterTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T026 [US3] Normalize shared artifact vocabulary and semantic labels across badge domains in `app/Support/Badges/OperatorOutcomeTaxonomy.php`, `app/Support/Badges/Domains/EvidenceCompletenessBadge.php`, `app/Support/Badges/Domains/TenantReviewCompletenessStateBadge.php`, `app/Support/Badges/Domains/TenantReviewStatusBadge.php`, and `app/Support/Badges/Domains/ReviewPackStatusBadge.php`
|
||||
- [X] T027 [US3] Apply the shared truth vocabulary to canonical evidence and review summaries without leaking unauthorized tenant state in `app/Filament/Pages/Monitoring/EvidenceOverview.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Services/TenantReviews/TenantReviewRegisterService.php`, and `app/Support/Filament/CanonicalAdminTenantFilterState.php`
|
||||
- [X] T028 [US3] Align related run and artifact navigation with the shared truth model in `app/Support/OperationRunLinks.php`, `app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`, `app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, and `app/Filament/Resources/ReviewPackResource.php`
|
||||
- [X] T029 [US3] Apply semantic migration away from overloaded labels in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, and `app/Filament/Resources/ReviewPackResource.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when equivalent artifact states read consistently across all adopted governance surfaces and canonical summaries remain entitlement-safe.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final regression coverage, guard validation, and formatting.
|
||||
|
||||
- [X] T030 [P] Run and stabilize focused artifact-truth regression suites in `tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php`, `tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/TenantReview/TenantReviewRegisterTest.php`, `tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `tests/Feature/ReviewPack/ReviewPackResourceTest.php`, and `tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php`
|
||||
- [X] T031 [P] Run and stabilize guard and authorization regressions in `tests/Feature/Guards/ActionSurfaceContractTest.php`, `tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, and `tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php`
|
||||
- [X] T032 Run formatting, complete the 12-case quickstart validation checklist, and review affected list surfaces against `docs/product/standards/list-surface-review-checklist.md` for `specs/158-artifact-truth-semantics/quickstart.md` and the modified PHP files under `app/` and `tests/`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1: Setup**: No dependencies; start immediately.
|
||||
- **Phase 2: Foundational**: Depends on Phase 1; blocks all user stories.
|
||||
- **Phase 3: User Story 1**: Depends on Phase 2.
|
||||
- **Phase 4: User Story 2**: Depends on Phase 2 and is best executed after User Story 1 because it refines the same surfaces and helpers.
|
||||
- **Phase 5: User Story 3**: Depends on Phase 2 and is best executed after User Stories 1 and 2 because it normalizes the completed semantics across surfaces.
|
||||
- **Phase 6: Polish**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Primary MVP slice; no user-story dependency after foundational work.
|
||||
- **US2 (P1)**: Logically depends on the shared truth layer and overlaps the same surfaces as US1, so sequence after US1 to reduce merge and semantic conflicts.
|
||||
- **US3 (P2)**: Depends on the shared truth layer and the adopted semantics from US1 and US2 so it can normalize them across canonical and tenant surfaces.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests should be added first and observed failing before implementation.
|
||||
- Shared presenter or badge changes come before surface adoption.
|
||||
- Resource/page changes come before canonical run-detail polish.
|
||||
- Canonical RBAC regression coverage must pass before the story is considered complete.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T002`, `T003` can run in parallel during setup after the fixture dataset exists.
|
||||
- `T006`, `T007`, `T008`, `T009` can run in parallel during foundational work once the support classes exist.
|
||||
- Test tasks marked `[P]` within each story can run in parallel.
|
||||
- `T012` through `T015` can be split across developers after `T005` through `T008` are complete, but `T016` should follow once family-specific truth semantics are available.
|
||||
- `T030` and `T031` can run in parallel during final validation.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Parallel test authoring
|
||||
T010 tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php
|
||||
T011 tests/Feature/Evidence/EvidenceSnapshotResourceTest.php + tests/Feature/TenantReview/TenantReviewLifecycleTest.php + tests/Feature/ReviewPack/ReviewPackResourceTest.php
|
||||
|
||||
# Parallel surface adoption after shared truth foundation is ready
|
||||
T012 app/Filament/Resources/BaselineSnapshotResource.php
|
||||
T013 app/Filament/Resources/EvidenceSnapshotResource.php
|
||||
T014 app/Filament/Resources/TenantReviewResource.php
|
||||
T015 app/Filament/Resources/ReviewPackResource.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Parallel diagnostic/readiness coverage
|
||||
T017 tests/Unit/Badges/GovernanceArtifactTruthTest.php + tests/Feature/Evidence/EvidenceOverviewPageTest.php + tests/Feature/ReviewPack/ReviewPackResourceTest.php
|
||||
T018 tests/Feature/Evidence/EvidenceSnapshotResourceTest.php + tests/Feature/Evidence/EvidenceOverviewPageTest.php + tests/Feature/TenantReview/TenantReviewUiContractTest.php + tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php
|
||||
|
||||
# Parallel semantic refinement by domain
|
||||
T019 app/Support/Badges/Domains/BaselineSnapshotFidelityBadge.php
|
||||
T020 app/Models/EvidenceSnapshot.php + app/Filament/Pages/Monitoring/EvidenceOverview.php
|
||||
T021 app/Models/TenantReview.php + app/Services/TenantReviews/TenantReviewReadinessGate.php
|
||||
T022 app/Models/ReviewPack.php + app/Support/Badges/Domains/ReviewPackStatusBadge.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Parallel canonical safety and vocabulary checks
|
||||
T024 tests/Feature/Evidence/EvidenceOverviewPageTest.php + tests/Feature/TenantReview/TenantReviewRegisterTest.php
|
||||
T025 tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php + tests/Feature/TenantReview/TenantReviewRegisterRbacTest.php
|
||||
|
||||
# Parallel cross-surface normalization
|
||||
T026 app/Support/Badges/OperatorOutcomeTaxonomy.php
|
||||
T027 app/Filament/Pages/Monitoring/EvidenceOverview.php + app/Filament/Pages/Reviews/ReviewRegister.php
|
||||
T028 app/Support/OperationRunLinks.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational shared truth support.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. **STOP and VALIDATE**: Run the User Story 1 regression tasks and confirm that artifact existence vs trust semantics are clear on all four artifact families plus run detail.
|
||||
5. Demo the bounded trust slice before refining diagnostics and cross-surface normalization.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → shared truth layer ready.
|
||||
2. Add US1 → artifact trust becomes explicit across core surfaces.
|
||||
3. Add US2 → fidelity, freshness, readiness, and diagnostics become dimensionally correct.
|
||||
4. Add US3 → vocabulary and canonical tenant-safe summaries become consistent across the governance chain.
|
||||
5. Finish with guard coverage, focused tests, and formatting.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One developer owns Phase 2 shared truth presenter and badge foundation.
|
||||
2. After foundation is ready, split surface adoption by family:
|
||||
- Developer A: baseline + run detail
|
||||
- Developer B: evidence + evidence overview
|
||||
- Developer C: tenant review + review register + review packs
|
||||
3. Recombine in US3 for vocabulary normalization and canonical safety validation.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks touch separate files or can be executed independently.
|
||||
- Story labels map tasks directly to spec user stories for traceability.
|
||||
- This task list intentionally avoids provider dispatch gating, restore cleanup, and broad operation-status redesign.
|
||||
- Existing destructive actions remain in scope only for truthful messaging, confirmation preservation, and authorization regression coverage.
|
||||
149
tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php
Normal file
149
tests/Feature/Concerns/BuildsGovernanceArtifactTruthFixtures.php
Normal file
@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Concerns;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
|
||||
trait BuildsGovernanceArtifactTruthFixtures
|
||||
{
|
||||
protected function makeArtifactTruthEvidenceSnapshot(
|
||||
Tenant $tenant,
|
||||
array $snapshotOverrides = [],
|
||||
array $summaryOverrides = [],
|
||||
): EvidenceSnapshot {
|
||||
$defaults = [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => array_replace_recursive([
|
||||
'dimension_count' => 5,
|
||||
'finding_count' => 2,
|
||||
'report_count' => 2,
|
||||
'operation_count' => 2,
|
||||
'missing_dimensions' => 0,
|
||||
'stale_dimensions' => 0,
|
||||
], $summaryOverrides),
|
||||
'fingerprint' => hash('sha256', 'evidence-'.$tenant->getKey().'-'.microtime()),
|
||||
'generated_at' => now(),
|
||||
];
|
||||
|
||||
return EvidenceSnapshot::query()->create(array_replace($defaults, $snapshotOverrides));
|
||||
}
|
||||
|
||||
protected function makeArtifactTruthReview(
|
||||
Tenant $tenant,
|
||||
User $user,
|
||||
?EvidenceSnapshot $snapshot = null,
|
||||
array $reviewOverrides = [],
|
||||
array $summaryOverrides = [],
|
||||
): TenantReview {
|
||||
$snapshot ??= $this->makeArtifactTruthEvidenceSnapshot($tenant);
|
||||
|
||||
$defaults = [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'status' => TenantReviewStatus::Ready->value,
|
||||
'completeness_state' => TenantReviewCompletenessState::Complete->value,
|
||||
'summary' => array_replace_recursive([
|
||||
'publish_blockers' => [],
|
||||
'section_state_counts' => [
|
||||
'complete' => 6,
|
||||
'partial' => 0,
|
||||
'missing' => 0,
|
||||
'stale' => 0,
|
||||
],
|
||||
'finding_count' => 2,
|
||||
'report_count' => 2,
|
||||
'operation_count' => 2,
|
||||
'section_count' => 6,
|
||||
], $summaryOverrides),
|
||||
'fingerprint' => hash('sha256', 'review-'.$tenant->getKey().'-'.microtime()),
|
||||
'generated_at' => now(),
|
||||
];
|
||||
|
||||
return TenantReview::query()->create(array_replace($defaults, $reviewOverrides));
|
||||
}
|
||||
|
||||
protected function makeArtifactTruthReviewPack(
|
||||
Tenant $tenant,
|
||||
User $user,
|
||||
?EvidenceSnapshot $snapshot = null,
|
||||
?TenantReview $review = null,
|
||||
array $packOverrides = [],
|
||||
array $summaryOverrides = [],
|
||||
): ReviewPack {
|
||||
$snapshot ??= $this->makeArtifactTruthEvidenceSnapshot($tenant);
|
||||
$review ??= $this->makeArtifactTruthReview($tenant, $user, $snapshot);
|
||||
|
||||
$defaults = [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'summary' => array_replace_recursive([
|
||||
'review_status' => $review->status,
|
||||
'review_completeness_state' => $review->completeness_state,
|
||||
'finding_count' => 2,
|
||||
'report_count' => 2,
|
||||
'operation_count' => 2,
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
],
|
||||
], $summaryOverrides),
|
||||
'fingerprint' => hash('sha256', 'pack-'.$tenant->getKey().'-'.microtime()),
|
||||
'file_disk' => 'exports',
|
||||
'file_path' => 'review-packs/'.microtime(true).'.zip',
|
||||
'sha256' => hash('sha256', 'pack-file-'.$tenant->getKey().'-'.microtime()),
|
||||
'file_size' => 1024,
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays(30),
|
||||
];
|
||||
|
||||
return ReviewPack::query()->create(array_replace($defaults, $packOverrides));
|
||||
}
|
||||
|
||||
protected function makeArtifactTruthRun(
|
||||
Tenant $tenant,
|
||||
string $type,
|
||||
array $context = [],
|
||||
array $attributes = [],
|
||||
): OperationRun {
|
||||
$defaults = [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => $type,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'initiator_name' => 'Artifact Truth Test',
|
||||
'context' => $context,
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
'started_at' => now()->subMinute(),
|
||||
'completed_at' => now(),
|
||||
];
|
||||
|
||||
return OperationRun::factory()->create(array_replace($defaults, $attributes));
|
||||
}
|
||||
}
|
||||
@ -45,6 +45,7 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.evidence.overview'))
|
||||
->assertOk()
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee($tenantA->name)
|
||||
->assertSee($tenantB->name)
|
||||
->assertDontSee($foreignWorkspaceTenant->name);
|
||||
@ -68,8 +69,10 @@
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$snapshots = [];
|
||||
|
||||
foreach ([[$tenantA, EvidenceCompletenessState::Complete->value], [$tenantB, EvidenceCompletenessState::Partial->value]] as [$tenant, $state]) {
|
||||
EvidenceSnapshot::query()->create([
|
||||
$snapshots[(int) $tenant->getKey()] = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
@ -85,6 +88,6 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.evidence.overview', ['tenant_id' => (int) $tenantB->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantB))
|
||||
->assertDontSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantA));
|
||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantB->getKey()]], tenant: $tenantB), false)
|
||||
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshots[(int) $tenantA->getKey()]], tenant: $tenantA), false);
|
||||
});
|
||||
|
||||
@ -147,6 +147,31 @@ function seedEvidenceDomain(Tenant $tenant): void
|
||||
->assertActionVisible('expire_snapshot');
|
||||
});
|
||||
|
||||
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Partial->value,
|
||||
'summary' => [
|
||||
'dimension_count' => 5,
|
||||
'missing_dimensions' => 2,
|
||||
'stale_dimensions' => 0,
|
||||
],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee('Partial')
|
||||
->assertSee('Refresh evidence before using this snapshot');
|
||||
});
|
||||
|
||||
it('renders readable evidence dimension summaries and keeps raw json available', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
@ -48,7 +48,8 @@
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Coverage summary', 'Captured policy types', 'Technical detail'])
|
||||
->assertSeeInOrder(['Artifact truth', 'Coverage summary', 'Captured policy types', 'Technical detail'])
|
||||
->assertSee('Reference only')
|
||||
->assertSee('Inventory metadata')
|
||||
->assertSee('Metadata-only evidence was captured for this item.')
|
||||
->assertSee('Only inventory metadata was available.');
|
||||
|
||||
@ -82,7 +82,7 @@
|
||||
|
||||
Gate::define(Capabilities::EVIDENCE_VIEW, fn (User $actor, Tenant $tenant): bool => (int) $tenant->getKey() === (int) $tenantA->getKey());
|
||||
|
||||
EvidenceSnapshot::query()->create([
|
||||
$allowedSnapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
@ -91,7 +91,7 @@
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
EvidenceSnapshot::query()->create([
|
||||
$deniedSnapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenantDenied->getKey(),
|
||||
'workspace_id' => (int) $tenantDenied->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
@ -104,6 +104,6 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.evidence.overview'))
|
||||
->assertOk()
|
||||
->assertSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantA))
|
||||
->assertDontSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantDenied));
|
||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA))
|
||||
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied));
|
||||
});
|
||||
|
||||
20
tests/Feature/Monitoring/ArtifactTruthManualCasesDataset.php
Normal file
20
tests/Feature/Monitoring/ArtifactTruthManualCasesDataset.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
dataset('artifactTruthManualCases', [
|
||||
'healthy baseline artifact' => [BadgeDomain::GovernanceArtifactContent, 'trusted'],
|
||||
'false-green baseline artifact' => [BadgeDomain::GovernanceArtifactExistence, 'created_but_not_usable'],
|
||||
'historical baseline trace' => [BadgeDomain::GovernanceArtifactExistence, 'historical_only'],
|
||||
'healthy evidence snapshot' => [BadgeDomain::GovernanceArtifactContent, 'trusted'],
|
||||
'partial evidence snapshot' => [BadgeDomain::GovernanceArtifactContent, 'partial'],
|
||||
'stale evidence snapshot' => [BadgeDomain::GovernanceArtifactFreshness, 'stale'],
|
||||
'internal tenant review' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'internal_only'],
|
||||
'blocked tenant review' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'blocked'],
|
||||
'publishable tenant review' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'publishable'],
|
||||
'historical review pack' => [BadgeDomain::GovernanceArtifactExistence, 'historical_only'],
|
||||
'blocked review pack' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'blocked'],
|
||||
'artifact requires action' => [BadgeDomain::GovernanceArtifactActionability, 'required'],
|
||||
]);
|
||||
74
tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php
Normal file
74
tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||
|
||||
it('shows artifact truth for artifact-targeted runs when the produced evidence snapshot is degraded', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = $this->makeArtifactTruthRun($tenant, 'tenant.evidence.snapshot.generate');
|
||||
|
||||
$this->makeArtifactTruthEvidenceSnapshot(
|
||||
tenant: $tenant,
|
||||
snapshotOverrides: [
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Partial->value,
|
||||
],
|
||||
summaryOverrides: [
|
||||
'missing_dimensions' => 2,
|
||||
],
|
||||
);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee('Partial')
|
||||
->assertSee('Refresh evidence before using this snapshot');
|
||||
});
|
||||
|
||||
it('shows missing-artifact guidance when a blocked artifact run never produced a record', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = $this->makeArtifactTruthRun(
|
||||
tenant: $tenant,
|
||||
type: 'tenant.review.compose',
|
||||
context: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
],
|
||||
attributes: [
|
||||
'outcome' => 'blocked',
|
||||
'failure_summary' => [
|
||||
['reason_code' => 'review_missing_sections', 'message' => 'The review basis is incomplete.'],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee('Artifact not usable')
|
||||
->assertSee('Inspect the blocked run details before retrying');
|
||||
});
|
||||
@ -323,10 +323,49 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee('Publishable')
|
||||
->assertSee('#'.$snapshot->getKey())
|
||||
->assertSee('resolved');
|
||||
});
|
||||
|
||||
it('shows blocked publication truth when the source review is no longer publishable', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$snapshot = seedReviewPackEvidence($tenant);
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
|
||||
$review->update([
|
||||
'status' => 'draft',
|
||||
'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [
|
||||
'publish_blockers' => ['Review the missing approval note before publication.'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'summary' => [
|
||||
'review_status' => 'draft',
|
||||
'review_completeness_state' => 'complete',
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Open the source review before sharing this pack');
|
||||
});
|
||||
|
||||
it('shows download header action on view page for a ready pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
@ -59,6 +59,9 @@
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee('Publishable')
|
||||
->assertSee('No action needed')
|
||||
->assertSee('#'.$review->getKey())
|
||||
->assertSee('Review status');
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Services\TenantReviews\TenantReviewReadinessGate;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
|
||||
it('blocks publication when required review sections are missing from the anchored evidence basis', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -21,6 +22,13 @@
|
||||
expect(app(TenantReviewReadinessGate::class)->canPublish($review))->toBeFalse()
|
||||
->and($review->publishBlockers())->not->toBeEmpty();
|
||||
|
||||
$truth = app(ArtifactTruthPresenter::class)->forTenantReview($review);
|
||||
|
||||
expect($truth->artifactExistence)->toBe('created')
|
||||
->and($truth->publicationReadiness)->toBe('blocked')
|
||||
->and($truth->primaryLabel)->toBe('Blocked')
|
||||
->and($truth->nextStepText())->toBe('Resolve the review blockers before publication');
|
||||
|
||||
expect(fn () => app(TenantReviewLifecycleService::class)->publish($review, $user))
|
||||
->toThrow(\InvalidArgumentException::class);
|
||||
});
|
||||
@ -36,9 +44,17 @@
|
||||
->and($published->published_by_user_id)->toBe((int) $user->getKey())
|
||||
->and($publishedAt)->not->toBeNull();
|
||||
|
||||
$publishedTruth = app(ArtifactTruthPresenter::class)->forTenantReview($published);
|
||||
|
||||
$archived = app(TenantReviewLifecycleService::class)->archive($published, $user);
|
||||
$archivedTruth = app(ArtifactTruthPresenter::class)->forTenantReview($archived);
|
||||
|
||||
expect($archived->status)->toBe(TenantReviewStatus::Archived->value)
|
||||
->and($archived->archived_at)->not->toBeNull()
|
||||
->and($archived->published_at?->toIso8601String())->toBe($publishedAt);
|
||||
->and($archived->published_at?->toIso8601String())->toBe($publishedAt)
|
||||
->and($publishedTruth->publicationReadiness)->toBe('publishable')
|
||||
->and($publishedTruth->nextStepText())->toBe('No action needed')
|
||||
->and($archivedTruth->artifactExistence)->toBe('historical_only')
|
||||
->and($archivedTruth->publicationReadiness)->toBe('internal_only')
|
||||
->and($archivedTruth->nextStepText())->toBe('No action needed');
|
||||
});
|
||||
|
||||
@ -18,7 +18,18 @@
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
||||
|
||||
$reviewA = composeTenantReviewForTest($tenantA, $user);
|
||||
$reviewB = composeTenantReviewForTest($tenantB, $user);
|
||||
$reviewB = composeTenantReviewForTest(
|
||||
$tenantB,
|
||||
$user,
|
||||
seedTenantReviewEvidence(
|
||||
tenant: $tenantB,
|
||||
permissionPayload: [
|
||||
'required_count' => 11,
|
||||
'granted_count' => 7,
|
||||
],
|
||||
operationRunCount: 0,
|
||||
),
|
||||
);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
@ -31,7 +42,10 @@
|
||||
->test(ReviewRegister::class)
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
||||
->assertCanSeeTableRecords([$reviewB])
|
||||
->assertCanNotSeeTableRecords([$reviewA]);
|
||||
->assertCanNotSeeTableRecords([$reviewA])
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Resolve the review blockers before publication')
|
||||
->assertDontSee('Publishable');
|
||||
});
|
||||
|
||||
it('prefilters the review register from a tenant query parameter and accepts external tenant identifiers', function (): void {
|
||||
@ -44,7 +58,18 @@
|
||||
]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
||||
|
||||
$reviewA = composeTenantReviewForTest($tenantA, $user);
|
||||
$reviewA = composeTenantReviewForTest(
|
||||
$tenantA,
|
||||
$user,
|
||||
seedTenantReviewEvidence(
|
||||
tenant: $tenantA,
|
||||
permissionPayload: [
|
||||
'required_count' => 11,
|
||||
'granted_count' => 7,
|
||||
],
|
||||
operationRunCount: 0,
|
||||
),
|
||||
);
|
||||
$reviewB = composeTenantReviewForTest($tenantB, $user);
|
||||
|
||||
$this->actingAs($user);
|
||||
@ -55,7 +80,10 @@
|
||||
->test(ReviewRegister::class)
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
||||
->assertCanSeeTableRecords([$reviewA])
|
||||
->assertCanNotSeeTableRecords([$reviewB]);
|
||||
->assertCanNotSeeTableRecords([$reviewB])
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Resolve the review blockers before publication')
|
||||
->assertDontSee('Publishable');
|
||||
});
|
||||
|
||||
it('scopes canonical tenant filter options to entitled tenants only', function (): void {
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('returns 404 for users outside the active workspace on the canonical review register', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
@ -45,3 +46,40 @@
|
||||
->get(ReviewRegister::getUrl(panel: 'admin'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('shows artifact-truth rows only for entitled tenants on the canonical review register', function (): void {
|
||||
$tenantAllowed = Tenant::factory()->create(['name' => 'Allowed Tenant']);
|
||||
[$user, $tenantAllowed] = createUserWithTenant(tenant: $tenantAllowed, role: 'readonly');
|
||||
|
||||
$allowedReview = composeTenantReviewForTest(
|
||||
$tenantAllowed,
|
||||
$user,
|
||||
seedTenantReviewEvidence(
|
||||
tenant: $tenantAllowed,
|
||||
permissionPayload: [
|
||||
'required_count' => 11,
|
||||
'granted_count' => 7,
|
||||
],
|
||||
operationRunCount: 0,
|
||||
),
|
||||
);
|
||||
|
||||
$tenantDenied = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantAllowed->workspace_id,
|
||||
'name' => 'Denied Tenant',
|
||||
]);
|
||||
[$otherOwner, $tenantDenied] = createUserWithTenant(tenant: $tenantDenied, role: 'owner');
|
||||
$deniedReview = composeTenantReviewForTest($tenantDenied, $otherOwner);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantAllowed->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->assertCanSeeTableRecords([$allowedReview])
|
||||
->assertCanNotSeeTableRecords([$deniedReview])
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Resolve the review blockers before publication')
|
||||
->assertDontSee('Denied Tenant');
|
||||
});
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->assertSee('Artifact truth')
|
||||
->assertCanSeeTableRecords([$reviewA, $reviewB])
|
||||
->assertCanNotSeeTableRecords([$reviewC])
|
||||
->filterTable('tenant_id', (string) $tenantB->getKey())
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ListTenantReviews;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -78,3 +79,31 @@
|
||||
->mountAction('archive_review')
|
||||
->assertActionMounted('archive_review');
|
||||
});
|
||||
|
||||
it('shows publication truth and next-step guidance when a review is not yet publishable', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$snapshot = seedTenantReviewEvidence($tenant);
|
||||
|
||||
$review = TenantReview::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $owner->getKey(),
|
||||
'status' => 'draft',
|
||||
'completeness_state' => 'complete',
|
||||
'summary' => [
|
||||
'publish_blockers' => ['Review the approval note before publication.'],
|
||||
'section_state_counts' => ['complete' => 6, 'partial' => 0, 'missing' => 0, 'stale' => 0],
|
||||
],
|
||||
'fingerprint' => hash('sha256', 'tenant-review-ui-contract'),
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($owner)
|
||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee('Blocked')
|
||||
->assertSee('Resolve the review blockers before publication');
|
||||
});
|
||||
|
||||
72
tests/Unit/Badges/GovernanceArtifactTruthTest.php
Normal file
72
tests/Unit/Badges/GovernanceArtifactTruthTest.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||
|
||||
it('maps each curated governance-artifact truth badge to a known label', function (BadgeDomain $domain, string $state): void {
|
||||
$spec = BadgeCatalog::spec($domain, $state);
|
||||
|
||||
expect($spec->label)->not->toBe('Unknown')
|
||||
->and($spec->icon)->not->toBeNull();
|
||||
})->with([
|
||||
'healthy baseline artifact' => [BadgeDomain::GovernanceArtifactContent, 'trusted'],
|
||||
'false-green baseline artifact' => [BadgeDomain::GovernanceArtifactExistence, 'created_but_not_usable'],
|
||||
'historical baseline trace' => [BadgeDomain::GovernanceArtifactExistence, 'historical_only'],
|
||||
'healthy evidence snapshot' => [BadgeDomain::GovernanceArtifactContent, 'trusted'],
|
||||
'partial evidence snapshot' => [BadgeDomain::GovernanceArtifactContent, 'partial'],
|
||||
'stale evidence snapshot' => [BadgeDomain::GovernanceArtifactFreshness, 'stale'],
|
||||
'internal tenant review' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'internal_only'],
|
||||
'blocked tenant review' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'blocked'],
|
||||
'publishable tenant review' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'publishable'],
|
||||
'historical review pack' => [BadgeDomain::GovernanceArtifactExistence, 'historical_only'],
|
||||
'blocked review pack' => [BadgeDomain::GovernanceArtifactPublicationReadiness, 'blocked'],
|
||||
'artifact requires action' => [BadgeDomain::GovernanceArtifactActionability, 'required'],
|
||||
]);
|
||||
|
||||
it('separates review existence from publication readiness', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant);
|
||||
$review = $this->makeArtifactTruthReview(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
snapshot: $snapshot,
|
||||
reviewOverrides: [
|
||||
'status' => 'draft',
|
||||
'completeness_state' => 'complete',
|
||||
],
|
||||
summaryOverrides: [
|
||||
'publish_blockers' => ['Review the missing approval note before publication.'],
|
||||
],
|
||||
);
|
||||
|
||||
$truth = app(ArtifactTruthPresenter::class)->forTenantReview($review);
|
||||
|
||||
expect($truth->artifactExistence)->toBe('created')
|
||||
->and($truth->publicationReadiness)->toBe('blocked')
|
||||
->and($truth->primaryLabel)->toBe('Blocked')
|
||||
->and($truth->nextStepText())->toContain('Resolve');
|
||||
});
|
||||
|
||||
it('marks ready review packs as publishable only when their source review stays publishable', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant);
|
||||
$review = $this->makeArtifactTruthReview($tenant, $user, $snapshot);
|
||||
$pack = $this->makeArtifactTruthReviewPack($tenant, $user, $snapshot, $review);
|
||||
|
||||
$truth = app(ArtifactTruthPresenter::class)->forReviewPack($pack);
|
||||
|
||||
expect($truth->artifactExistence)->toBe('created')
|
||||
->and($truth->publicationReadiness)->toBe('publishable')
|
||||
->and($truth->primaryLabel)->toBe('Publishable')
|
||||
->and($truth->nextStepText())->toBe('No action needed');
|
||||
});
|
||||
59
tests/Unit/Baselines/BaselineSnapshotItemNormalizerTest.php
Normal file
59
tests/Unit/Baselines/BaselineSnapshotItemNormalizerTest.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
||||
|
||||
it('deduplicates snapshot items by subject reference and keeps the stronger evidence', function (): void {
|
||||
$normalizer = new BaselineSnapshotItemNormalizer;
|
||||
|
||||
$result = $normalizer->deduplicate([
|
||||
[
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'subject-1',
|
||||
'subject_key' => 'standard',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => 'hash-meta',
|
||||
'meta_jsonb' => [
|
||||
'evidence' => [
|
||||
'fidelity' => 'meta',
|
||||
'observed_at' => '2026-03-22T23:18:29+00:00',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'subject-1',
|
||||
'subject_key' => 'standard',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => 'hash-content',
|
||||
'meta_jsonb' => [
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'observed_at' => '2026-03-22T23:18:30+00:00',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => 'subject-2',
|
||||
'subject_key' => 'unique',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'baseline_hash' => 'hash-unique',
|
||||
'meta_jsonb' => [
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'observed_at' => '2026-03-22T23:18:31+00:00',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($result['duplicates'])->toBe(1)
|
||||
->and($result['items'])->toHaveCount(2)
|
||||
->and(collect($result['items'])->firstWhere('subject_external_id', 'subject-1'))
|
||||
->toMatchArray([
|
||||
'baseline_hash' => 'hash-content',
|
||||
'subject_key' => 'standard',
|
||||
]);
|
||||
});
|
||||
@ -30,3 +30,9 @@
|
||||
->and(BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, 'stale')->label)->toBe('Refresh recommended')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, 'stale')->color)->toBe('warning');
|
||||
});
|
||||
|
||||
it('exposes shared governance-artifact truth badges for evidence semantics', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactContent, 'partial')->label)->toBe('Partial')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, 'stale')->label)->toBe('Stale')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactActionability, 'required')->label)->toBe('Action required');
|
||||
});
|
||||
|
||||
@ -20,3 +20,9 @@
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'missing')->color)->toBe('info')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'stale')->label)->toBe('Refresh review inputs');
|
||||
});
|
||||
|
||||
it('maps publication-readiness truth badges for tenant reviews', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, 'internal_only')->label)->toBe('Internal only')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, 'blocked')->label)->toBe('Blocked')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::GovernanceArtifactPublicationReadiness, 'publishable')->label)->toBe('Publishable');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user