Compare commits

..

2 Commits

Author SHA1 Message Date
Ahmed Darrazi
419a289dd8 feat: bridge finding exception approval queue 2026-03-28 11:07:19 +01:00
Ahmed Darrazi
f73d3623fc feat: harden finding governance health surfaces 2026-03-27 16:41:39 +01:00
36 changed files with 78 additions and 3631 deletions

View File

@ -112,8 +112,6 @@ ## Active Technologies
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets (166-finding-governance-health)
- PostgreSQL using existing `findings`, `finding_exceptions`, related decision tables, and existing DB-backed summary sources; no schema changes required (166-finding-governance-health)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams (167-derived-state-memoization)
- PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only (167-derived-state-memoization)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -133,8 +131,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 167-derived-state-memoization: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams
- 166-finding-governance-health: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets
- 165-baseline-summary-trust: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
- 164-run-detail-hardening: Added PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -14,7 +14,6 @@
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;
@ -91,7 +90,7 @@ public function mount(): void
$snapshots = $query->get()->unique('tenant_id')->values();
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
$truth = $this->snapshotTruth($snapshot);
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
return [
@ -134,13 +133,4 @@ protected function getHeaderActions(): array
->url(route('admin.evidence.overview')),
];
}
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
{
$presenter = app(ArtifactTruthPresenter::class);
return $fresh
? $presenter->forEvidenceSnapshotFresh($snapshot)
: $presenter->forEvidenceSnapshot($snapshot);
}
}

View File

@ -14,7 +14,6 @@
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
@ -25,6 +24,7 @@
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@ -107,7 +107,7 @@ protected function getHeaderActions(): array
return $actions;
}
$related = $this->relatedLinks();
$related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
$relatedActions = [];
@ -178,7 +178,7 @@ public function blockedExecutionBanner(): ?array
$operatorExplanation->dominantCauseExplanation,
]))
: ($reasonEnvelope?->toBodyLines(false) ?? [
$this->surfaceFailureDetail() ?? 'The queued run was refused before side effects could begin.',
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
]);
return [
@ -197,13 +197,13 @@ public function lifecycleBanner(): ?array
return null;
}
$attention = $this->lifecycleAttentionSummary();
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
if ($attention === null) {
return null;
}
$detail = $this->surfaceFailureDetail() ?? 'Lifecycle truth needs operator review.';
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
return match ($this->run->freshnessState()->value) {
'likely_stale' => [
@ -457,44 +457,6 @@ private function governanceOperatorExplanation(): ?OperatorExplanationPattern
return null;
}
return OperationUxPresenter::governanceOperatorExplanation($this->run);
}
/**
* @return array<string, string>
*/
private function relatedLinks(bool $fresh = false): array
{
if (! isset($this->run)) {
return [];
}
$resolver = app(RelatedNavigationResolver::class);
return $fresh
? $resolver->operationLinksFresh($this->run, $this->relatedLinksTenant())
: $resolver->operationLinks($this->run, $this->relatedLinksTenant());
}
private function lifecycleAttentionSummary(bool $fresh = false): ?string
{
if (! isset($this->run)) {
return null;
}
return $fresh
? OperationUxPresenter::lifecycleAttentionSummaryFresh($this->run)
: OperationUxPresenter::lifecycleAttentionSummary($this->run);
}
private function surfaceFailureDetail(bool $fresh = false): ?string
{
if (! isset($this->run)) {
return null;
}
return $fresh
? OperationUxPresenter::surfaceFailureDetailFresh($this->run)
: OperationUxPresenter::surfaceFailureDetail($this->run);
return app(ArtifactTruthPresenter::class)->forOperationRun($this->run)?->operatorExplanation;
}
}

View File

@ -22,7 +22,6 @@
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;
@ -119,11 +118,11 @@ public function table(Table $table): Table
TextColumn::make('artifact_truth')
->label('Artifact truth')
->badge()
->getStateUsing(fn (TenantReview $record): string => $this->reviewTruth($record)->primaryLabel)
->color(fn (TenantReview $record): string => $this->reviewTruth($record)->primaryBadgeSpec()->color)
->icon(fn (TenantReview $record): ?string => $this->reviewTruth($record)->primaryBadgeSpec()->icon)
->iconColor(fn (TenantReview $record): ?string => $this->reviewTruth($record)->primaryBadgeSpec()->iconColor)
->description(fn (TenantReview $record): ?string => $this->reviewTruth($record)->operatorExplanation?->headline ?? $this->reviewTruth($record)->primaryExplanation)
->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)->operatorExplanation?->headline ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
->wrap(),
TextColumn::make('completeness_state')
->label('Completeness')
@ -139,23 +138,23 @@ public function table(Table $table): Table
->badge()
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->label)
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->color)
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->icon)
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
BadgeDomain::GovernanceArtifactPublicationReadiness,
$this->reviewTruth($record)->publicationReadiness ?? 'internal_only',
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
)->iconColor),
TextColumn::make('artifact_next_step')
->label('Next step')
->getStateUsing(fn (TenantReview $record): string => $this->reviewTruth($record)->operatorExplanation?->nextActionText ?? $this->reviewTruth($record)->nextStepText())
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
->wrap(),
])
->filters([
@ -326,13 +325,4 @@ private function workspace(): ?Workspace
? Workspace::query()->whereKey((int) $workspaceId)->first()
: null;
}
private function reviewTruth(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
{
$presenter = app(ArtifactTruthPresenter::class);
return $fresh
? $presenter->forTenantReviewFresh($record)
: $presenter->forTenantReview($record);
}
}

View File

@ -385,13 +385,9 @@ private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges
);
}
private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
{
$presenter = app(ArtifactTruthPresenter::class);
return $fresh
? $presenter->forBaselineSnapshotFresh($snapshot)
: $presenter->forBaselineSnapshot($snapshot);
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
}
private static function currentTruthLabel(BaselineSnapshot $snapshot): string

View File

@ -274,7 +274,6 @@ public static function table(Table $table): Table
}
app(EvidenceSnapshotService::class)->expire($record, $user);
static::truthEnvelope($record->refresh(), fresh: true);
Notification::make()->success()->title('Snapshot expired')->send();
}),
@ -613,13 +612,9 @@ private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
return $label === 'Unknown' ? null : $label;
}
private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = false): ArtifactTruthEnvelope
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
{
$presenter = app(ArtifactTruthPresenter::class);
return $fresh
? $presenter->forEvidenceSnapshotFresh($record)
: $presenter->forEvidenceSnapshot($record);
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
}
private static function stringifySummaryValue(mixed $value): string
@ -651,7 +646,6 @@ public static function executeGeneration(array $data): void
user: $user,
allowStale: (bool) ($data['allow_stale'] ?? false),
);
static::truthEnvelope($snapshot->refresh(), fresh: true);
if (! $snapshot->wasRecentlyCreated) {
Notification::make()

View File

@ -64,6 +64,11 @@ class FindingResource extends Resource
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
/**
* @var array<string, RelatedContextEntry|null>
*/
private static array $primaryRelatedEntryCache = [];
protected static ?string $model = Finding::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -1246,13 +1251,18 @@ private static function primaryRelatedAction(): Actions\Action
->color('gray');
}
private static function primaryRelatedEntry(Finding $record, bool $fresh = false): ?RelatedContextEntry
private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry
{
$resolver = app(RelatedNavigationResolver::class);
$cacheKey = is_numeric($record->getKey())
? (string) $record->getKey()
: spl_object_hash($record);
return $fresh
? $resolver->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_FINDING, $record)
: $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
if (array_key_exists($cacheKey, static::$primaryRelatedEntryCache)) {
return static::$primaryRelatedEntryCache[$cacheKey];
}
return static::$primaryRelatedEntryCache[$cacheKey] = app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
}
private static function findingRunNavigationContext(Finding $record): CanonicalNavigationContext

View File

@ -135,7 +135,7 @@ public static function table(Table $table): Table
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
->description(fn (OperationRun $record): ?string => static::lifecycleAttentionSummary($record)),
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
Tables\Columns\TextColumn::make('type')
->label('Operation')
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
@ -162,7 +162,7 @@ public static function table(Table $table): Table
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
->description(fn (OperationRun $record): ?string => static::surfaceGuidance($record)),
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
])
->filters([
Tables\Filters\SelectFilter::make('tenant_id')
@ -264,7 +264,9 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$referencedTenantLifecycle = $record->tenant instanceof Tenant
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
: null;
$artifactTruth = static::artifactTruthEnvelope($record);
$artifactTruth = $record->supportsOperatorExplanation()
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
: null;
$operatorExplanation = $artifactTruth?->operatorExplanation;
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$supportingGroups = static::supportingGroups(
@ -342,7 +344,8 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
kind: 'related_context',
title: 'Related context',
view: 'filament.infolists.entries.related-context',
viewData: ['entries' => static::relatedContextEntries($record)],
viewData: ['entries' => app(RelatedNavigationResolver::class)
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
emptyState: $factory->emptyState('No related context is available for this record.'),
),
$factory->viewSection(
@ -515,7 +518,7 @@ private static function supportingGroups(
array $primaryNextStep,
): array {
$groups = [];
$hasElevatedLifecycleState = static::lifecycleAttentionSummary($record) !== null;
$hasElevatedLifecycleState = OperationUxPresenter::lifecycleAttentionSummary($record) !== null;
$guidanceItems = array_values(array_filter([
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
@ -647,11 +650,11 @@ private static function resolvePrimaryNextStep(
$opsUxSource = match (true) {
(string) $record->outcome === OperationRunOutcome::Blocked->value => 'blocked_reason',
static::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
OperationUxPresenter::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
default => 'ops_ux',
};
static::pushNextStepCandidate($candidates, static::surfaceGuidance($record), $opsUxSource);
static::pushNextStepCandidate($candidates, OperationUxPresenter::surfaceGuidance($record), $opsUxSource);
if ($candidates === []) {
return [
@ -1186,57 +1189,6 @@ public static function getPages(): array
return [];
}
private static function artifactTruthEnvelope(OperationRun $record, bool $fresh = false): ?ArtifactTruthEnvelope
{
if (! $record->supportsOperatorExplanation()) {
return null;
}
$presenter = app(ArtifactTruthPresenter::class);
return $fresh
? $presenter->forOperationRunFresh($record)
: $presenter->forOperationRun($record);
}
private static function lifecycleAttentionSummary(OperationRun $record, bool $fresh = false): ?string
{
return $fresh
? OperationUxPresenter::lifecycleAttentionSummaryFresh($record)
: OperationUxPresenter::lifecycleAttentionSummary($record);
}
private static function surfaceGuidance(OperationRun $record, bool $fresh = false): ?string
{
return $fresh
? OperationUxPresenter::surfaceGuidanceFresh($record)
: OperationUxPresenter::surfaceGuidance($record);
}
/**
* @return list<array{
* key: string,
* label: string,
* value: string,
* secondaryValue: ?string,
* targetUrl: ?string,
* targetKind: string,
* availability: string,
* unavailableReason: ?string,
* contextBadge: ?string,
* priority: int,
* actionLabel: string
* }>
*/
private static function relatedContextEntries(OperationRun $record, bool $fresh = false): array
{
$resolver = app(RelatedNavigationResolver::class);
return $fresh
? $resolver->detailEntriesFresh(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)
: $resolver->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record);
}
private static function targetScopeDisplay(OperationRun $record): ?string
{
$context = is_array($record->context) ? $record->context : [];

View File

@ -4,7 +4,6 @@
use App\Filament\Resources\PolicyVersionResource;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedContextEntry;
use App\Support\Navigation\RelatedNavigationResolver;
use Filament\Actions\Action;
use Filament\Resources\Pages\ViewRecord;
@ -27,9 +26,12 @@ protected function getHeaderActions(): array
{
return [
Action::make('primary_related')
->label(fn (): string => $this->primaryRelatedEntry()?->actionLabel ?? 'Open related record')
->url(fn (): ?string => $this->primaryRelatedEntry()?->targetUrl)
->hidden(fn (): bool => ! ($this->primaryRelatedEntry()?->isAvailable() ?? false))
->label(fn (): string => app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->actionLabel ?? 'Open related record')
->url(fn (): ?string => app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->targetUrl)
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())?->isAvailable() ?? false))
->color('gray'),
];
}
@ -40,13 +42,4 @@ public function getFooter(): ?View
'record' => $this->getRecord(),
]);
}
private function primaryRelatedEntry(bool $fresh = false): ?RelatedContextEntry
{
$resolver = app(RelatedNavigationResolver::class);
return $fresh
? $resolver->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())
: $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord());
}
}

View File

@ -337,7 +337,6 @@ public static function table(Table $table): Table
}
$record->update(['status' => ReviewPackStatus::Expired->value]);
static::truthEnvelope($record->refresh(), fresh: true);
Notification::make()
->success()
@ -398,13 +397,9 @@ public static function getPages(): array
];
}
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
{
$presenter = app(ArtifactTruthPresenter::class);
return $fresh
? $presenter->forReviewPackFresh($record)
: $presenter->forReviewPack($record);
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
}
/**
@ -452,8 +447,6 @@ public static function executeGeneration(array $data): void
return;
}
static::truthEnvelope($reviewPack->refresh(), fresh: true);
if (! $reviewPack->wasRecentlyCreated) {
Notification::make()
->success()

View File

@ -419,8 +419,6 @@ public static function executeCreateReview(array $data): void
return;
}
static::truthEnvelope($review->refresh(), fresh: true);
if (! $review->wasRecentlyCreated) {
Notification::make()
->success()
@ -490,9 +488,6 @@ public static function executeExport(TenantReview $review): void
return;
}
static::truthEnvelope($review->refresh(), fresh: true);
app(ArtifactTruthPresenter::class)->forReviewPackFresh($pack->refresh());
if (! $pack->wasRecentlyCreated) {
Notification::make()
->success()
@ -611,12 +606,8 @@ private static function sectionPresentation(TenantReviewSection $section): array
];
}
private static function truthEnvelope(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
{
$presenter = app(ArtifactTruthPresenter::class);
return $fresh
? $presenter->forTenantReviewFresh($record)
: $presenter->forTenantReview($record);
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
}
}

View File

@ -62,7 +62,6 @@
use App\Support\References\Resolvers\PolicyReferenceResolver;
use App\Support\References\Resolvers\PolicyVersionReferenceResolver;
use App\Support\References\Resolvers\PrincipalReferenceResolver;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use Filament\Events\TenantSet;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
@ -119,7 +118,6 @@ public function register(): void
$this->app->singleton(ReferenceTypeLabelCatalog::class);
$this->app->singleton(ReferenceStatePresenter::class);
$this->app->singleton(ResolvedReferencePresenter::class);
$this->app->scoped(RequestScopedDerivedStateStore::class);
$this->app->singleton(FallbackReferenceResolver::class);
$this->app->singleton(PolicyReferenceResolver::class);
$this->app->singleton(PolicyVersionReferenceResolver::class);

View File

@ -35,9 +35,6 @@
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceResolverRegistry;
use App\Support\References\RelatedContextReferenceAdapter;
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\DerivedStateKey;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
@ -51,7 +48,6 @@ public function __construct(
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
private readonly ReferenceResolverRegistry $referenceResolverRegistry,
private readonly RelatedContextReferenceAdapter $relatedContextReferenceAdapter,
private readonly RequestScopedDerivedStateStore $derivedStateStore,
) {}
/**
@ -73,41 +69,18 @@ public function detailEntries(string $sourceType, Model $record): array
{
return array_map(
static fn (RelatedContextEntry $entry): array => $entry->toArray(),
$this->detailEntryObjects($sourceType, $record),
);
}
/**
* @return list<array{
* key: string,
* label: string,
* value: string,
* secondaryValue: ?string,
* targetUrl: ?string,
* targetKind: string,
* availability: string,
* unavailableReason: ?string,
* contextBadge: ?string,
* priority: int,
* actionLabel: string
* }>
*/
public function detailEntriesFresh(string $sourceType, Model $record): array
{
return array_map(
static fn (RelatedContextEntry $entry): array => $entry->toArray(),
$this->detailEntryObjects($sourceType, $record, fresh: true),
$this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION, $record),
);
}
public function primaryListAction(string $sourceType, Model $record): ?RelatedContextEntry
{
return $this->resolvePrimaryListAction($sourceType, $record);
}
$entries = array_values(array_filter(
$this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_LIST_ROW, $record),
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
));
public function primaryListActionFresh(string $sourceType, Model $record): ?RelatedContextEntry
{
return $this->resolvePrimaryListAction($sourceType, $record, fresh: true);
return $entries[0] ?? null;
}
/**
@ -116,7 +89,7 @@ public function primaryListActionFresh(string $sourceType, Model $record): ?Rela
public function operationLinks(OperationRun $run, ?Tenant $tenant): array
{
$entries = array_filter(
$this->detailEntryObjects(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run),
$this->resolveEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION, $run),
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
);
@ -127,51 +100,20 @@ public function operationLinks(OperationRun $run, ?Tenant $tenant): array
}
if ($tenant instanceof Tenant) {
$links = ['Operations' => OperationRunLinks::index($tenant)] + $links;
$links = ['Open operations' => OperationRunLinks::index($tenant)] + $links;
} else {
$links = ['Operations' => OperationRunLinks::index()] + $links;
$links = ['Open operations' => OperationRunLinks::index()] + $links;
}
return $links;
}
/**
* @return array<string, string>
*/
public function operationLinksFresh(OperationRun $run, ?Tenant $tenant): array
{
$entries = array_filter(
$this->detailEntryObjects(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run, fresh: true),
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
);
$links = [];
foreach ($entries as $entry) {
$links[$entry->actionLabel] = (string) $entry->targetUrl;
}
if ($tenant instanceof Tenant) {
return ['Operations' => OperationRunLinks::index($tenant)] + $links;
}
return ['Operations' => OperationRunLinks::index()] + $links;
}
/**
* @return list<RelatedContextEntry>
*/
public function headerEntries(string $sourceType, Model $record): array
{
return $this->headerEntryObjects($sourceType, $record);
}
/**
* @return list<RelatedContextEntry>
*/
public function headerEntriesFresh(string $sourceType, Model $record): array
{
return $this->headerEntryObjects($sourceType, $record, fresh: true);
return $this->resolveEntries($sourceType, CrossResourceNavigationMatrix::SURFACE_DETAIL_HEADER, $record);
}
/**
@ -305,91 +247,6 @@ private function resolveEntries(string $sourceType, string $surface, Model $reco
return $entries;
}
/**
* @return list<RelatedContextEntry>
*/
private function detailEntryObjects(string $sourceType, Model $record, bool $fresh = false): array
{
return $this->memoizedEntries(
family: DerivedStateFamily::RelatedNavigationDetail,
sourceType: $sourceType,
record: $record,
surface: CrossResourceNavigationMatrix::SURFACE_DETAIL_SECTION,
fresh: $fresh,
);
}
/**
* @return list<RelatedContextEntry>
*/
private function headerEntryObjects(string $sourceType, Model $record, bool $fresh = false): array
{
return $this->memoizedEntries(
family: DerivedStateFamily::RelatedNavigationHeader,
sourceType: $sourceType,
record: $record,
surface: CrossResourceNavigationMatrix::SURFACE_DETAIL_HEADER,
fresh: $fresh,
);
}
private function resolvePrimaryListAction(string $sourceType, Model $record, bool $fresh = false): ?RelatedContextEntry
{
$entries = array_values(array_filter(
$this->memoizedEntries(
family: DerivedStateFamily::RelatedNavigationPrimary,
sourceType: $sourceType,
record: $record,
surface: CrossResourceNavigationMatrix::SURFACE_LIST_ROW,
fresh: $fresh,
),
static fn (RelatedContextEntry $entry): bool => $entry->isAvailable(),
));
return $entries[0] ?? null;
}
/**
* @return list<RelatedContextEntry>
*/
private function memoizedEntries(
DerivedStateFamily $family,
string $sourceType,
Model $record,
string $surface,
bool $fresh = false,
): array {
$key = DerivedStateKey::fromModel(
family: $family,
record: $record,
variant: $sourceType,
context: [
'source_type' => $sourceType,
'surface' => $surface,
'active_tenant_id' => $this->activeTenantId(),
'route_name' => request()?->route()?->getName(),
'user_id' => auth()->id(),
],
);
/** @var list<RelatedContextEntry> $entries */
$entries = $fresh
? $this->derivedStateStore->resolveFresh(
$key,
fn (): array => $this->resolveEntries($sourceType, $surface, $record),
$family->defaultFreshnessPolicy(),
$family->allowsNegativeResultCache(),
)
: $this->derivedStateStore->resolve(
$key,
fn (): array => $this->resolveEntries($sourceType, $surface, $record),
$family->defaultFreshnessPolicy(),
$family->allowsNegativeResultCache(),
);
return $entries;
}
private function resolveRule(NavigationMatrixRule $rule, Model $record): ?RelatedContextEntry
{
return match ($rule->sourceType) {

View File

@ -9,11 +9,7 @@
use App\Support\OperationCatalog;
use App\Support\Operations\OperationRunFreshnessState;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\RedactionIntegrity;
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\DerivedStateKey;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Notifications\Notification as FilamentNotification;
@ -101,25 +97,6 @@ public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $
}
public static function surfaceGuidance(OperationRun $run): ?string
{
return self::memoizeGuidance(
run: $run,
variant: 'surface_guidance',
resolver: fn (): ?string => self::buildSurfaceGuidance($run),
);
}
public static function surfaceGuidanceFresh(OperationRun $run): ?string
{
return self::memoizeGuidance(
run: $run,
variant: 'surface_guidance',
resolver: fn (): ?string => self::buildSurfaceGuidance($run),
fresh: true,
);
}
private static function buildSurfaceGuidance(OperationRun $run): ?string
{
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
$reasonEnvelope = self::reasonEnvelope($run);
@ -171,25 +148,6 @@ private static function buildSurfaceGuidance(OperationRun $run): ?string
}
public static function surfaceFailureDetail(OperationRun $run): ?string
{
return self::memoizeExplanation(
run: $run,
variant: 'surface_failure_detail',
resolver: fn (): ?string => self::buildSurfaceFailureDetail($run),
);
}
public static function surfaceFailureDetailFresh(OperationRun $run): ?string
{
return self::memoizeExplanation(
run: $run,
variant: 'surface_failure_detail',
resolver: fn (): ?string => self::buildSurfaceFailureDetail($run),
fresh: true,
);
}
private static function buildSurfaceFailureDetail(OperationRun $run): ?string
{
$operatorExplanation = self::governanceOperatorExplanation($run);
@ -223,25 +181,6 @@ public static function freshnessState(OperationRun $run): OperationRunFreshnessS
}
public static function lifecycleAttentionSummary(OperationRun $run): ?string
{
return self::memoizeExplanation(
run: $run,
variant: 'lifecycle_attention_summary',
resolver: fn (): ?string => self::buildLifecycleAttentionSummary($run),
);
}
public static function lifecycleAttentionSummaryFresh(OperationRun $run): ?string
{
return self::memoizeExplanation(
run: $run,
variant: 'lifecycle_attention_summary',
resolver: fn (): ?string => self::buildLifecycleAttentionSummary($run),
fresh: true,
);
}
private static function buildLifecycleAttentionSummary(OperationRun $run): ?string
{
return match (self::freshnessState($run)) {
OperationRunFreshnessState::LikelyStale => 'Likely stale',
@ -250,16 +189,6 @@ private static function buildLifecycleAttentionSummary(OperationRun $run): ?stri
};
}
public static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
{
return self::resolveGovernanceOperatorExplanation($run);
}
public static function governanceOperatorExplanationFresh(OperationRun $run): ?OperatorExplanationPattern
{
return self::resolveGovernanceOperatorExplanation($run, fresh: true);
}
/**
* @return array{titleSuffix: string, body: string, status: string}
*/
@ -353,18 +282,14 @@ private static function sanitizeFailureMessage(string $failureMessage): ?string
return $failureMessage !== '' ? $failureMessage : null;
}
private static function reasonEnvelope(OperationRun $run): ?ReasonResolutionEnvelope
private static function reasonEnvelope(OperationRun $run): ?\App\Support\ReasonTranslation\ReasonResolutionEnvelope
{
return self::memoizeExplanation(
run: $run,
variant: 'reason_envelope_notification',
resolver: fn (): ?ReasonResolutionEnvelope => app(ReasonPresenter::class)->forOperationRun($run, 'notification'),
);
return app(ReasonPresenter::class)->forOperationRun($run, 'notification');
}
private static function operatorExplanationGuidance(OperationRun $run): ?string
{
$operatorExplanation = self::resolveGovernanceOperatorExplanation($run);
$operatorExplanation = self::governanceOperatorExplanation($run);
if (! is_string($operatorExplanation?->nextActionText) || trim($operatorExplanation->nextActionText) === '') {
return null;
@ -381,73 +306,12 @@ private static function operatorExplanationGuidance(OperationRun $run): ?string
: 'Next step: '.$text.'.';
}
private static function resolveGovernanceOperatorExplanation(OperationRun $run, bool $fresh = false): ?OperatorExplanationPattern
private static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
{
if (! $run->supportsOperatorExplanation()) {
return null;
}
return self::memoizeExplanation(
run: $run,
variant: 'governance_operator_explanation',
resolver: fn (): ?OperatorExplanationPattern => $fresh
? app(ArtifactTruthPresenter::class)->forOperationRunFresh($run)?->operatorExplanation
: app(ArtifactTruthPresenter::class)->forOperationRun($run)?->operatorExplanation,
fresh: $fresh,
);
}
private static function memoizeGuidance(
OperationRun $run,
string $variant,
callable $resolver,
bool $fresh = false,
): ?string {
$key = DerivedStateKey::fromModel(DerivedStateFamily::OperationUxGuidance, $run, $variant);
/** @var ?string $value */
$value = $fresh
? self::derivedStateStore()->resolveFresh(
$key,
$resolver,
DerivedStateFamily::OperationUxGuidance->defaultFreshnessPolicy(),
DerivedStateFamily::OperationUxGuidance->allowsNegativeResultCache(),
)
: self::derivedStateStore()->resolve(
$key,
$resolver,
DerivedStateFamily::OperationUxGuidance->defaultFreshnessPolicy(),
DerivedStateFamily::OperationUxGuidance->allowsNegativeResultCache(),
);
return $value;
}
private static function memoizeExplanation(
OperationRun $run,
string $variant,
callable $resolver,
bool $fresh = false,
): mixed {
$key = DerivedStateKey::fromModel(DerivedStateFamily::OperationUxExplanation, $run, $variant);
return $fresh
? self::derivedStateStore()->resolveFresh(
$key,
$resolver,
DerivedStateFamily::OperationUxExplanation->defaultFreshnessPolicy(),
DerivedStateFamily::OperationUxExplanation->allowsNegativeResultCache(),
)
: self::derivedStateStore()->resolve(
$key,
$resolver,
DerivedStateFamily::OperationUxExplanation->defaultFreshnessPolicy(),
DerivedStateFamily::OperationUxExplanation->allowsNegativeResultCache(),
);
}
private static function derivedStateStore(): RequestScopedDerivedStateStore
{
return app(RequestScopedDerivedStateStore::class);
return app(ArtifactTruthPresenter::class)->forOperationRun($run)?->operatorExplanation;
}
}

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\DerivedState;
enum DerivedStateFamily: string
{
case ArtifactTruth = 'artifact_truth';
case OperationUxGuidance = 'operation_ux_guidance';
case OperationUxExplanation = 'operation_ux_explanation';
case RelatedNavigationPrimary = 'related_navigation_primary';
case RelatedNavigationDetail = 'related_navigation_detail';
case RelatedNavigationHeader = 'related_navigation_header';
public function allowsNegativeResultCache(): bool
{
return true;
}
public function defaultFreshnessPolicy(): string
{
return RequestScopedDerivedStateStore::FRESHNESS_INVALIDATE_AFTER_MUTATION;
}
}

View File

@ -1,190 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\DerivedState;
use Illuminate\Database\Eloquent\Model;
use JsonException;
final class DerivedStateKey
{
public function __construct(
public readonly DerivedStateFamily $family,
public readonly string $recordClass,
public readonly string $recordKey,
public readonly string $variant,
public readonly ?int $workspaceId = null,
public readonly ?int $tenantId = null,
public readonly ?string $contextHash = null,
) {
if (trim($this->recordClass) === '') {
throw new \InvalidArgumentException('Derived state keys require a non-empty record class.');
}
if (trim($this->recordKey) === '') {
throw new \InvalidArgumentException('Derived state keys require a non-empty record key.');
}
if (trim($this->variant) === '') {
throw new \InvalidArgumentException('Derived state keys require a non-empty variant.');
}
}
/**
* @param array<string, mixed>|string|null $context
*/
public static function fromModel(
DerivedStateFamily $family,
Model $record,
string $variant,
array|string|null $context = null,
?int $workspaceId = null,
?int $tenantId = null,
): self {
return new self(
family: $family,
recordClass: $record::class,
recordKey: (string) $record->getKey(),
variant: $variant,
workspaceId: $workspaceId ?? self::normalizeScopeId($record->getAttribute('workspace_id')),
tenantId: $tenantId ?? self::normalizeScopeId($record->getAttribute('tenant_id')),
contextHash: self::hashContext($context),
);
}
/**
* @return array{
* family: string,
* record_class: string,
* record_key: string,
* variant: string,
* workspace_id: ?int,
* tenant_id: ?int,
* context_hash: ?string
* }
*/
public function toArray(): array
{
return [
'family' => $this->family->value,
'record_class' => $this->recordClass,
'record_key' => $this->recordKey,
'variant' => $this->variant,
'workspace_id' => $this->workspaceId,
'tenant_id' => $this->tenantId,
'context_hash' => $this->contextHash,
];
}
public function fingerprint(): string
{
try {
/** @var string $json */
$json = json_encode($this->toArray(), JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new \RuntimeException('Unable to encode derived state key fingerprint.', previous: $exception);
}
return $json;
}
public function matches(
DerivedStateFamily $family,
?string $recordClass = null,
string|int|null $recordKey = null,
?string $variant = null,
?int $workspaceId = null,
?int $tenantId = null,
): bool {
if ($this->family !== $family) {
return false;
}
if ($recordClass !== null && $this->recordClass !== $recordClass) {
return false;
}
if ($recordKey !== null && $this->recordKey !== (string) $recordKey) {
return false;
}
if ($variant !== null && $this->variant !== $variant) {
return false;
}
if ($workspaceId !== null && $this->workspaceId !== $workspaceId) {
return false;
}
if ($tenantId !== null && $this->tenantId !== $tenantId) {
return false;
}
return true;
}
/**
* @param array<string, mixed>|string|null $context
*/
public static function hashContext(array|string|null $context): ?string
{
if ($context === null) {
return null;
}
if (is_string($context)) {
$context = trim($context);
return $context === '' ? null : sha1($context);
}
if ($context === []) {
return null;
}
$normalized = self::normalizeContext($context);
try {
/** @var string $json */
$json = json_encode($normalized, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new \RuntimeException('Unable to encode derived state context.', previous: $exception);
}
return sha1($json);
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private static function normalizeContext(array $context): array
{
ksort($context);
foreach ($context as $key => $value) {
if (is_array($value)) {
/** @var mixed $normalized */
$normalized = array_is_list($value)
? array_map(static fn (mixed $item): mixed => is_array($item) ? self::normalizeContext($item) : $item, $value)
: self::normalizeContext($value);
$context[$key] = $normalized;
}
}
return $context;
}
private static function normalizeScopeId(mixed $value): ?int
{
if (! is_numeric($value)) {
return null;
}
$normalized = (int) $value;
return $normalized > 0 ? $normalized : null;
}
}

View File

@ -1,186 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ui\DerivedState;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
final class RequestScopedDerivedStateStore
{
public const string FRESHNESS_REQUEST_STABLE = 'request_stable';
public const string FRESHNESS_INVALIDATE_AFTER_MUTATION = 'invalidate_after_mutation';
public const string FRESHNESS_NO_REUSE = 'no_reuse';
private string $requestScopeId;
/**
* @var array<string, array{
* key: DerivedStateKey,
* value: mixed,
* negative_result: bool,
* freshness_policy: string,
* resolved_at: int
* }>
*/
private array $entries = [];
/**
* @var list<string>
*/
private array $invalidations = [];
private int $resolutionSequence = 0;
public function __construct(?string $requestScopeId = null)
{
$this->requestScopeId = $requestScopeId ?? (string) Str::uuid();
}
public function requestScopeId(): string
{
return $this->requestScopeId;
}
public function resolve(
DerivedStateKey $key,
callable $resolver,
?string $freshnessPolicy = null,
?bool $allowNegativeResultCache = null,
): mixed {
$freshnessPolicy ??= $key->family->defaultFreshnessPolicy();
if ($freshnessPolicy === self::FRESHNESS_NO_REUSE) {
return $resolver();
}
$fingerprint = $key->fingerprint();
if (array_key_exists($fingerprint, $this->entries)) {
return $this->entries[$fingerprint]['value'];
}
$value = $resolver();
$negativeResult = $this->isNegativeResult($value);
$allowNegativeResultCache ??= $key->family->allowsNegativeResultCache();
if ($negativeResult && ! $allowNegativeResultCache) {
return $value;
}
$this->entries[$fingerprint] = [
'key' => $key,
'value' => $value,
'negative_result' => $negativeResult,
'freshness_policy' => $freshnessPolicy,
'resolved_at' => ++$this->resolutionSequence,
];
return $value;
}
public function resolveFresh(
DerivedStateKey $key,
callable $resolver,
?string $freshnessPolicy = null,
?bool $allowNegativeResultCache = null,
): mixed {
$this->invalidateKey($key);
return $this->resolve($key, $resolver, $freshnessPolicy, $allowNegativeResultCache);
}
public function invalidateKey(DerivedStateKey $key): int
{
$fingerprint = $key->fingerprint();
if (! array_key_exists($fingerprint, $this->entries)) {
return 0;
}
unset($this->entries[$fingerprint]);
$this->invalidations[] = $fingerprint;
return 1;
}
public function invalidateFamily(
DerivedStateFamily $family,
?string $recordClass = null,
string|int|null $recordKey = null,
?string $variant = null,
?int $workspaceId = null,
?int $tenantId = null,
): int {
$invalidated = 0;
foreach ($this->entries as $fingerprint => $record) {
if (! $record['key']->matches($family, $recordClass, $recordKey, $variant, $workspaceId, $tenantId)) {
continue;
}
unset($this->entries[$fingerprint]);
$this->invalidations[] = $fingerprint;
$invalidated++;
}
return $invalidated;
}
public function invalidateModel(DerivedStateFamily $family, Model $record, ?string $variant = null): int
{
return $this->invalidateFamily(
family: $family,
recordClass: $record::class,
recordKey: $record->getKey(),
variant: $variant,
);
}
public function entryCount(): int
{
return count($this->entries);
}
public function countStored(
DerivedStateFamily $family,
?string $recordClass = null,
string|int|null $recordKey = null,
?string $variant = null,
): int {
return count(array_filter(
$this->entries,
static fn (array $record): bool => $record['key']->matches($family, $recordClass, $recordKey, $variant),
));
}
/**
* @return array{
* key: DerivedStateKey,
* value: mixed,
* negative_result: bool,
* freshness_policy: string,
* resolved_at: int
* }|null
*/
public function resolutionRecord(DerivedStateKey $key): ?array
{
return $this->entries[$key->fingerprint()] ?? null;
}
/**
* @return list<string>
*/
public function invalidations(): array
{
return $this->invalidations;
}
private function isNegativeResult(mixed $value): bool
{
return $value === null || $value === [];
}
}

View File

@ -27,12 +27,8 @@
use App\Support\ReviewPackStatus;
use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus;
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\DerivedStateKey;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
final class ArtifactTruthPresenter
@ -41,7 +37,6 @@ public function __construct(
private readonly ReasonPresenter $reasonPresenter,
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
private readonly RequestScopedDerivedStateStore $derivedStateStore,
) {}
public function for(mixed $record): ?ArtifactTruthEnvelope
@ -56,38 +51,7 @@ public function for(mixed $record): ?ArtifactTruthEnvelope
};
}
public function forFresh(mixed $record): ?ArtifactTruthEnvelope
{
return match (true) {
$record instanceof BaselineSnapshot => $this->forBaselineSnapshotFresh($record),
$record instanceof EvidenceSnapshot => $this->forEvidenceSnapshotFresh($record),
$record instanceof TenantReview => $this->forTenantReviewFresh($record),
$record instanceof ReviewPack => $this->forReviewPackFresh($record),
$record instanceof OperationRun => $this->forOperationRunFresh($record),
default => null,
};
}
public function forBaselineSnapshot(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
{
return $this->resolveEnvelope(
record: $snapshot,
variant: 'baseline_snapshot',
resolver: fn (): ArtifactTruthEnvelope => $this->buildBaselineSnapshotEnvelope($snapshot),
);
}
public function forBaselineSnapshotFresh(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
{
return $this->resolveEnvelope(
record: $snapshot,
variant: 'baseline_snapshot',
resolver: fn (): ArtifactTruthEnvelope => $this->buildBaselineSnapshotEnvelope($snapshot),
fresh: true,
);
}
private function buildBaselineSnapshotEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
{
$snapshot->loadMissing('baselineProfile');
@ -221,25 +185,6 @@ private function buildBaselineSnapshotEnvelope(BaselineSnapshot $snapshot): Arti
}
public function forEvidenceSnapshot(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
{
return $this->resolveEnvelope(
record: $snapshot,
variant: 'evidence_snapshot',
resolver: fn (): ArtifactTruthEnvelope => $this->buildEvidenceSnapshotEnvelope($snapshot),
);
}
public function forEvidenceSnapshotFresh(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
{
return $this->resolveEnvelope(
record: $snapshot,
variant: 'evidence_snapshot',
resolver: fn (): ArtifactTruthEnvelope => $this->buildEvidenceSnapshotEnvelope($snapshot),
fresh: true,
);
}
private function buildEvidenceSnapshotEnvelope(EvidenceSnapshot $snapshot): ArtifactTruthEnvelope
{
$snapshot->loadMissing('tenant');
@ -382,25 +327,6 @@ private function buildEvidenceSnapshotEnvelope(EvidenceSnapshot $snapshot): Arti
}
public function forTenantReview(TenantReview $review): ArtifactTruthEnvelope
{
return $this->resolveEnvelope(
record: $review,
variant: 'tenant_review',
resolver: fn (): ArtifactTruthEnvelope => $this->buildTenantReviewEnvelope($review),
);
}
public function forTenantReviewFresh(TenantReview $review): ArtifactTruthEnvelope
{
return $this->resolveEnvelope(
record: $review,
variant: 'tenant_review',
resolver: fn (): ArtifactTruthEnvelope => $this->buildTenantReviewEnvelope($review),
fresh: true,
);
}
private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthEnvelope
{
$review->loadMissing(['tenant', 'currentExportReviewPack']);
@ -548,25 +474,6 @@ private function buildTenantReviewEnvelope(TenantReview $review): ArtifactTruthE
}
public function forReviewPack(ReviewPack $pack): ArtifactTruthEnvelope
{
return $this->resolveEnvelope(
record: $pack,
variant: 'review_pack',
resolver: fn (): ArtifactTruthEnvelope => $this->buildReviewPackEnvelope($pack),
);
}
public function forReviewPackFresh(ReviewPack $pack): ArtifactTruthEnvelope
{
return $this->resolveEnvelope(
record: $pack,
variant: 'review_pack',
resolver: fn (): ArtifactTruthEnvelope => $this->buildReviewPackEnvelope($pack),
fresh: true,
);
}
private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelope
{
$pack->loadMissing(['tenant', 'tenantReview']);
@ -705,25 +612,6 @@ private function buildReviewPackEnvelope(ReviewPack $pack): ArtifactTruthEnvelop
}
public function forOperationRun(OperationRun $run): ArtifactTruthEnvelope
{
return $this->resolveEnvelope(
record: $run,
variant: 'operation_run',
resolver: fn (): ArtifactTruthEnvelope => $this->buildOperationRunEnvelope($run),
);
}
public function forOperationRunFresh(OperationRun $run): ArtifactTruthEnvelope
{
return $this->resolveEnvelope(
record: $run,
variant: 'operation_run',
resolver: fn (): ArtifactTruthEnvelope => $this->buildOperationRunEnvelope($run),
fresh: true,
);
}
private function buildOperationRunEnvelope(OperationRun $run): ArtifactTruthEnvelope
{
$artifact = $this->resolveArtifactForRun($run);
$reason = $this->reasonPresenter->forOperationRun($run, 'run_detail');
@ -832,32 +720,6 @@ private function resolveArtifactForRun(OperationRun $run): BaselineSnapshot|Evid
};
}
private function resolveEnvelope(
Model $record,
string $variant,
callable $resolver,
bool $fresh = false,
): ArtifactTruthEnvelope {
$key = DerivedStateKey::fromModel(DerivedStateFamily::ArtifactTruth, $record, $variant);
/** @var ArtifactTruthEnvelope $envelope */
$envelope = $fresh
? $this->derivedStateStore->resolveFresh(
$key,
$resolver,
DerivedStateFamily::ArtifactTruth->defaultFreshnessPolicy(),
DerivedStateFamily::ArtifactTruth->allowsNegativeResultCache(),
)
: $this->derivedStateStore->resolve(
$key,
$resolver,
DerivedStateFamily::ArtifactTruth->defaultFreshnessPolicy(),
DerivedStateFamily::ArtifactTruth->allowsNegativeResultCache(),
);
return $envelope;
}
private function contentExplanation(string $contentState): string
{
return match ($contentState) {

View File

@ -5,7 +5,7 @@ # Spec Candidates
>
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
**Last reviewed**: 2026-03-28 (added request-scoped performance foundation candidates for derived state, governance aggregates, and workspace access context)
**Last reviewed**: 2026-03-24 (added Baseline Compare Scope Guardrails & Ambiguity Guidance candidate)
---
@ -44,89 +44,6 @@ ## Qualified
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
### Request-Scoped Derived State and Resolver Memoization
- **Type**: foundation
- **Source**: cross-cutting Filament render-path performance analysis 2026-03-28 — repeated derived-state resolution across `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver`
- **Problem**: TenantPilot's presenter / resolver layer is architecturally correct but render-path chatty. The same derived truth, guidance, or related-navigation state is recomputed multiple times per record, per surface, and per request: list row badges, descriptions, tooltips, visibility checks, detail entries, and widgets ask for the same deterministic answer through separate closures. This is broader than one local N+1 query; it is a repeated-cost shape that now spans baseline, evidence, review, operation, and navigation surfaces.
- **Why it matters**: If each page is locally optimized in isolation, the underlying cost pattern remains and spreads into tenant reviews, review packs, evidence surfaces, baseline snapshots, and future portfolio/MSP views. The product needs a shared contract for deterministic request-local reuse, not another generation of ad hoc static caches and page-specific memoization helpers.
- **Proposed direction**:
- Introduce a canonical request-scoped derived-state store for deterministic presenter / resolver outputs
- Define explicit keying rules around presenter family, record type, record identity, variant / surface mode, and any relevant view-policy context
- Route `ArtifactTruthPresenter::for*()` paths, `OperationUxPresenter` surface guidance paths, and `RelatedNavigationResolver` primary/detail-entry paths through the shared store
- Separate raw domain state, derived surface state, and navigation/context state so memoization boundaries are explicit instead of accidental
- Provide a row-safe consumption pattern for Filament tables so label / tooltip / description / visible / url closures share the same derived state instead of recomputing it independently
- Define an explicit freshness rule for mutating action flows so request-local reuse never masks state that must be recomputed within the same action request
- **Scope boundaries**:
- **In scope**: request-scoped memoization contract, derived-state store infrastructure, keying contract, presenter/resolver adoption for at least Artifact Truth, Operation UX, and Related Navigation, and Filament list/detail/widget guardrails for row-safe consumption
- **Out of scope**: Redis or cross-request caching, aggressive query redesign, semantic changes to truth/guidance/navigation rules, widget redesign, and one-off page caches that bypass the shared contract
- **Acceptance points**:
- The same artifact truth for the same scope is fully derived at most once per request
- The same operation guidance for the same run/surface scope is fully derived at most once per request
- The same related primary/detail navigation entry for the same record is fully resolved at most once per request
- At least three currently affected surface families adopt the same shared contract
- Existing truth / guidance / navigation semantics stay unchanged from the operator's perspective
- Regression tests prove request-local reuse and mutation-path freshness
- **Risks / open questions**:
- Incorrect cache keys could cause invalid reuse across surfaces or variants
- Static ad hoc caches would hide the problem rather than solve it
- Mutation flows need an explicit invalidation or fresh-recompute rule where state can legitimately change mid-request
- **Suggested order**: first. This is the shared performance foundation the two follow-up candidates build on.
- **Priority**: high
### Tenant Governance Aggregate Contract
- **Type**: foundation
- **Source**: tenant governance surface overlap analysis 2026-03-28 — shared summary state duplicated across Baseline Compare Landing, Needs Attention, Coverage Banner, Compare Now, and related tenant-governance cards
- **Problem**: TenantPilot currently recomposes overlapping tenant-governance summaries on multiple surfaces in parallel instead of treating them as one tenant-scoped aggregate. Baseline compare freshness, compare outcome, open finding counts, overdue findings, expiring governance, lapsed governance, and related drift/coverage signals are recalculated or re-queried per widget/page, leaving ownership fragmented and making each new governance card more expensive than it needs to be.
- **Why it matters**: Release 1 and 2 continue expanding governance, review, evidence, and dashboard surfaces. If these tenant-level summaries keep growing surface-by-surface, new widgets will multiply redundant count queries, presentation-specific helper code, and semantic drift around what exactly qualifies as an attention count or governance posture.
- **Proposed direction**:
- Promote the current baseline/governance summary path into an explicit tenant-scoped aggregate contract, either by hardening `BaselineCompareStats` into that role or by introducing a clearly named `TenantGovernanceAggregate`
- Define one shared tenant summary that covers compare freshness, compare outcome, confidence/coverage/suppression where relevant, open findings summary, overdue findings, expiring/lapsed governance counts, and compare-related drift posture
- Make dashboard widgets and attention cards consume the aggregate and keep only presentation mapping local to the surface
- Prohibit parallel count queries for states that are already part of the shared aggregate contract
- Ensure repeated reads of the same tenant aggregate on one page are request-scoped and reusable instead of recomputed per widget
- **Scope boundaries**:
- **In scope**: tenant-scoped governance aggregate contract, shared ownership of attention counts, dashboard/landing/banner/card consumption for the same aggregate family, and request-local reuse across those surfaces
- **Out of scope**: cross-tenant portfolio aggregation, cross-request persistence caching, new drift semantics, new findings workflow semantics, full dashboard redesign, and broader evidence/review aggregation beyond the current tenant-governance summary family
- **Acceptance points**:
- At least three tenant-governance widgets/pages consume the same aggregate contract
- No widget re-queries overdue, lapsed, or expiring counts that are already part of the shared aggregate
- Dashboard, landing, and banner surfaces present semantically consistent values for the same tenant state
- Request-local reuse for the tenant aggregate is demonstrably testable
- No visible business-semantics regression is introduced while consolidating the summary source
- **Risks / open questions**:
- An overly broad aggregate could become a single oversized payload instead of a crisp contract
- If attention semantics are not clearly separated from presentation mapping, widgets will continue to smuggle business logic back into the UI layer
- If `BaselineCompareStats` stays helper-shaped rather than becoming an explicit contract, ownership ambiguity will persist even after partial consolidation
- **Suggested order**: second, ideally immediately after or alongside the request-scoped derived-state foundation.
- **Priority**: high
### Workspace Access Context and Navigation Cost Hardening
- **Type**: hardening
- **Source**: admin/workspace access-path analysis 2026-03-28 — repeated current-workspace, membership, navigation-visibility, and policy-adjacent access resolution across admin requests
- **Problem**: TenantPilot already has request-local caching in some capability resolvers, but the wider workspace/admin access path still pays a repeated request tax. Current workspace resolution, workspace membership lookups, navigation visibility checks, page access checks, and policy-adjacent access helpers can rebuild overlapping context multiple times before the actual screen content has even rendered. The issue is not one slow page; it is a hidden cost shape spread across many admin requests.
- **Why it matters**: As admin, monitoring, review, evidence, and future portfolio/workspace surfaces grow, this hidden context tax will compound across almost every workspace-scoped request. Left unbounded, it also increases the risk of access logic drifting into scattered local helpers instead of one explicit request-level contract.
- **Proposed direction**:
- Introduce an explicit request-scoped workspace access context that carries the current workspace ID/model, the membership decision, and any capability-access snapshot needed for repeated checks
- Harden `currentWorkspace()` or equivalent paths so the active workspace model is request-stable instead of repeatedly reloaded
- Make navigation visibility, resource visibility, page access helpers, and similar admin-panel checks consume the shared access context rather than rebuilding workspace/membership state locally
- Reuse the same context in policy-side or policy-adjacent workspace access decisions where repeated lookup is currently common
- Keep cross-panel workspace-aware transitions aligned with the same context contract rather than introducing special-case handoff helpers
- **Scope boundaries**:
- **In scope**: request-scoped workspace access context, current-workspace reuse, membership reuse, navigation/page-access context reuse, and migration of at least a subset of admin-sensitive helpers to the shared path
- **Out of scope**: RBAC redesign, capability-semantic changes, navigation IA restructuring, tenant-panel RBAC rewrite, or product-model changes to workspace-first behavior
- **Acceptance points**:
- The current workspace is not separately loaded multiple times within the same request path
- Repeated workspace-scoped access checks reuse the same membership/access context instead of rebuilding it
- At least two admin-sensitive request paths are migrated to the shared access context
- Navigation visibility uses request-wide reusable workspace context rather than repeated local lookups
- Access semantics remain unchanged while the request-path cost is hardened
- **Risks / open questions**:
- A wrong or overly broad shared context could create subtle access bugs
- The boundary between session-persisted workspace choice and request-scoped access context must stay explicit
- Over-centralization could hide legitimate special cases if exceptions are not consciously modeled
- **Suggested order**: third, after the derived-state and tenant-aggregate foundation work has clarified the shared request-scoped patterns.
- **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

View File

@ -1,36 +0,0 @@
# Specification Quality Checklist: Request-Scoped Derived State and Resolver Memoization
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-28
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- First-pass validation completed on 2026-03-28.
- No clarification markers remain.
- Spec is ready for `/speckit.plan`.

View File

@ -1,72 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/request-scoped-derived-state-key.schema.json",
"title": "RequestScopedDerivedStateKey",
"description": "Deterministic key used to identify one reusable derived-state result inside a single request. This schema describes the internal runtime key shape using snake_case field names; the logical OpenAPI contract documents an equivalent camelCase transport form that must normalize back to this structure.",
"type": "object",
"additionalProperties": false,
"required": [
"family",
"record_class",
"record_key",
"variant"
],
"properties": {
"family": {
"type": "string",
"enum": [
"artifact_truth",
"operation_ux_guidance",
"operation_ux_explanation",
"related_navigation_primary",
"related_navigation_detail",
"related_navigation_header"
]
},
"record_class": {
"type": "string",
"minLength": 1,
"examples": [
"App\\Models\\TenantReview"
]
},
"record_key": {
"type": "string",
"minLength": 1,
"examples": [
"42"
]
},
"variant": {
"type": "string",
"minLength": 1,
"examples": [
"list_row",
"detail_page",
"header_action"
]
},
"workspace_id": {
"type": [
"integer",
"null"
],
"minimum": 1
},
"tenant_id": {
"type": [
"integer",
"null"
],
"minimum": 1
},
"context_hash": {
"type": [
"string",
"null"
],
"minLength": 1,
"description": "Stable hash of additional scope-sensitive or capability-sensitive inputs required to distinguish the result."
}
}
}

View File

@ -1,571 +0,0 @@
openapi: 3.1.0
info:
title: Request-Scoped Derived State Logical Contract
version: 0.1.0
summary: Logical contract for resolving, reusing, and invalidating deterministic derived state within one request.
description: |
This contract is logical rather than transport-prescriptive. It documents the
expected behavior of the internal request-scoped derived-state store used by
existing presenter and resolver families. It does not add new external APIs
and does not imply cross-request caching.
Resolution and invalidation payloads use camelCase transport keys in this
contract, while the runtime data model and JSON schema keep their internal
snake_case field names. Implementations must normalize between the two
shapes rather than treating them as separate contracts.
Every future presenter or resolver family that wants to use the shared store
must document its family key, scope-sensitive inputs, access pattern, and
freshness policy through the consumer-validation contract before adoption.
servers:
- url: https://tenantpilot.local
x-derived-state-consumers:
- surface: reviews.register.table
family: artifact_truth
variant: tenant_review
accessPattern: row_safe
scopeInputs:
- record_class
- record_key
- workspace_id
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Pages/Reviews/ReviewRegister.php
requiredMarkers:
- 'private function reviewTruth(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope'
- '$this->reviewTruth($record)'
maxOccurrences:
- needle: '->forTenantReview('
max: 1
- needle: '->forTenantReviewFresh('
max: 1
- surface: monitoring.evidence_overview.table
family: artifact_truth
variant: evidence_snapshot
accessPattern: row_safe
scopeInputs:
- record_class
- record_key
- workspace_id
- tenant_id
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Pages/Monitoring/EvidenceOverview.php
requiredMarkers:
- 'private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope'
- '$this->snapshotTruth($snapshot)'
maxOccurrences:
- needle: '->forEvidenceSnapshot('
max: 1
- needle: '->forEvidenceSnapshotFresh('
max: 1
- surface: tenant.evidence_snapshots.table
family: artifact_truth
variant: evidence_snapshot
accessPattern: row_safe
scopeInputs:
- record_class
- record_key
- workspace_id
- tenant_id
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Resources/EvidenceSnapshotResource.php
requiredMarkers:
- 'private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = false): ArtifactTruthEnvelope'
- 'static::truthEnvelope($record)'
- 'fresh: true'
maxOccurrences:
- needle: '->forEvidenceSnapshot('
max: 1
- needle: '->forEvidenceSnapshotFresh('
max: 1
- surface: tenant.tenant_reviews.table
family: artifact_truth
variant: tenant_review
accessPattern: row_safe
scopeInputs:
- record_class
- record_key
- workspace_id
- tenant_id
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Resources/TenantReviewResource.php
requiredMarkers:
- 'private static function truthEnvelope(TenantReview $record, bool $fresh = false): ArtifactTruthEnvelope'
- 'static::truthEnvelope($record)'
- 'static::truthEnvelope($review->refresh(), fresh: true);'
maxOccurrences:
- needle: '->forTenantReview('
max: 1
- needle: '->forTenantReviewFresh('
max: 1
- surface: tenant.review_packs.table
family: artifact_truth
variant: review_pack
accessPattern: row_safe
scopeInputs:
- record_class
- record_key
- workspace_id
- tenant_id
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Resources/ReviewPackResource.php
requiredMarkers:
- 'private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope'
- 'static::truthEnvelope($record)'
- 'static::truthEnvelope($reviewPack->refresh(), fresh: true);'
maxOccurrences:
- needle: '->forReviewPack('
max: 1
- needle: '->forReviewPackFresh('
max: 1
- surface: admin.baseline_snapshots.truth
family: artifact_truth
variant: baseline_snapshot
accessPattern: row_safe
scopeInputs:
- record_class
- record_key
- workspace_id
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Resources/BaselineSnapshotResource.php
requiredMarkers:
- 'private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope'
- 'self::truthEnvelope($record)'
maxOccurrences:
- needle: '->forBaselineSnapshot('
max: 1
- needle: '->forBaselineSnapshotFresh('
max: 1
- surface: admin.baseline_snapshots.primary_navigation
family: related_navigation_primary
variant: baseline_snapshot
accessPattern: row_safe
scopeInputs:
- record_class
- record_key
- workspace_id
- user_id
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Resources/BaselineSnapshotResource.php
requiredMarkers:
- 'private static function primaryRelatedEntry(BaselineSnapshot $record): ?RelatedContextEntry'
- 'static::primaryRelatedEntry($record)'
maxOccurrences:
- needle: '->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $record)'
max: 1
- surface: tenant.findings.primary_navigation
family: related_navigation_primary
variant: finding
accessPattern: row_safe
scopeInputs:
- record_class
- record_key
- workspace_id
- tenant_id
- active_tenant_id
- user_id
- route_name
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Resources/FindingResource.php
requiredMarkers:
- 'private static function primaryRelatedEntry(Finding $record, bool $fresh = false): ?RelatedContextEntry'
- 'static::primaryRelatedEntry($record)'
maxOccurrences:
- needle: '->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record)'
max: 1
- needle: '->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_FINDING, $record)'
max: 1
- needle: 'primaryRelatedEntryCache'
max: 0
- surface: tenant.policy_versions.header_navigation
family: related_navigation_primary
variant: policy_version
accessPattern: page_safe
scopeInputs:
- record_class
- record_key
- workspace_id
- tenant_id
- active_tenant_id
- user_id
- route_name
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php
requiredMarkers:
- 'private function primaryRelatedEntry(bool $fresh = false): ?RelatedContextEntry'
- '$this->primaryRelatedEntry()'
maxOccurrences:
- needle: '->primaryListAction(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())'
max: 1
- needle: '->primaryListActionFresh(CrossResourceNavigationMatrix::SOURCE_POLICY_VERSION, $this->getRecord())'
max: 1
- surface: admin.operations.table_guidance
family: operation_ux_guidance
variant: surface_guidance
accessPattern: row_safe
scopeInputs:
- record_class
- record_key
- workspace_id
- tenant_id
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Resources/OperationRunResource.php
requiredMarkers:
- 'private static function surfaceGuidance(OperationRun $record, bool $fresh = false): ?string'
- 'private static function lifecycleAttentionSummary(OperationRun $record, bool $fresh = false): ?string'
- 'static::surfaceGuidance($record)'
maxOccurrences:
- needle: 'OperationUxPresenter::surfaceGuidance('
max: 1
- needle: 'OperationUxPresenter::surfaceGuidanceFresh('
max: 1
- needle: 'OperationUxPresenter::lifecycleAttentionSummary('
max: 1
- needle: 'OperationUxPresenter::lifecycleAttentionSummaryFresh('
max: 1
- surface: admin.operations.detail_related_context
family: related_navigation_detail
variant: operation_run
accessPattern: page_safe
scopeInputs:
- record_class
- record_key
- workspace_id
- tenant_id
- active_tenant_id
- user_id
- route_name
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Resources/OperationRunResource.php
requiredMarkers:
- 'private static function relatedContextEntries(OperationRun $record, bool $fresh = false): array'
- 'CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN'
maxOccurrences:
- needle: '->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)'
max: 1
- needle: '->detailEntriesFresh(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)'
max: 1
- surface: admin.operations.viewer_explanation
family: operation_ux_explanation
variant: governance_operator_explanation
accessPattern: page_safe
scopeInputs:
- record_class
- record_key
- workspace_id
- tenant_id
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
requiredMarkers:
- 'private function governanceOperatorExplanation(): ?OperatorExplanationPattern'
- 'OperationUxPresenter::governanceOperatorExplanation($this->run);'
maxOccurrences:
- needle: 'OperationUxPresenter::governanceOperatorExplanation('
max: 1
- needle: 'ArtifactTruthPresenter::class)->forOperationRun('
max: 0
- surface: admin.operations.viewer_related_links
family: related_navigation_detail
variant: operation_run
accessPattern: page_safe
scopeInputs:
- record_class
- record_key
- workspace_id
- tenant_id
- active_tenant_id
- user_id
- route_name
freshnessPolicy: invalidate_after_mutation
guardScope:
- app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
requiredMarkers:
- 'private function relatedLinks(bool $fresh = false): array'
- '$resolver->operationLinks($this->run, $this->relatedLinksTenant())'
maxOccurrences:
- needle: '->operationLinks($this->run, $this->relatedLinksTenant())'
max: 1
- needle: '->operationLinksFresh($this->run, $this->relatedLinksTenant())'
max: 1
paths:
/contracts/derived-state/resolve:
post:
summary: Resolve or reuse one deterministic derived-state result within the current request
operationId: resolveDerivedState
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DerivedStateResolutionRequest'
responses:
'200':
description: Derived-state value resolved, reused, or intentionally bypassed
content:
application/json:
schema:
$ref: '#/components/schemas/DerivedStateResolutionResponse'
/contracts/derived-state/invalidate:
post:
summary: Invalidate one or more request-local derived-state entries after a covered mutation
operationId: invalidateDerivedState
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DerivedStateInvalidationRequest'
responses:
'200':
description: Matching request-local entries invalidated
content:
application/json:
schema:
$ref: '#/components/schemas/DerivedStateInvalidationResponse'
/contracts/derived-state/validate-consumer:
post:
summary: Validate one UI consumer against the supported family, keying, and freshness rules
description: |
Use this logical validation step before onboarding a new presenter or
resolver family or before replacing an existing local cache pattern.
The consumer must declare its access pattern, scope inputs, and
freshness policy so unsupported reuse never becomes implicit.
The automated Pest guard for derived-state adoption should report
violations from this validation step with file and snippet context.
operationId: validateDerivedStateConsumer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DerivedStateConsumerValidationRequest'
responses:
'200':
description: Consumer validation result
content:
application/json:
schema:
$ref: '#/components/schemas/DerivedStateConsumerValidationResponse'
components:
schemas:
DerivedStateResolutionRequest:
type: object
additionalProperties: false
required:
- family
- recordClass
- recordKey
- variant
properties:
family:
type: string
enum:
- artifact_truth
- operation_ux_guidance
- operation_ux_explanation
- related_navigation_primary
- related_navigation_detail
- related_navigation_header
recordClass:
type: string
example: App\\Models\\TenantReview
recordKey:
type: string
example: '42'
variant:
type: string
example: list_row
workspaceId:
type:
- integer
- 'null'
tenantId:
type:
- integer
- 'null'
contextHash:
type:
- string
- 'null'
allowNegativeResultCache:
type: boolean
default: true
freshnessPolicy:
type: string
enum:
- request_stable
- invalidate_after_mutation
- no_reuse
default: request_stable
DerivedStateResolutionResponse:
type: object
additionalProperties: false
required:
- cacheStatus
- family
- variant
- negativeResult
- freshnessPolicy
properties:
cacheStatus:
type: string
enum:
- miss_resolved
- hit_reused
- bypassed
family:
type: string
variant:
type: string
negativeResult:
type: boolean
freshnessPolicy:
type: string
enum:
- request_stable
- invalidate_after_mutation
- no_reuse
scopeFingerprint:
type:
- string
- 'null'
notes:
type:
- string
- 'null'
DerivedStateInvalidationRequest:
type: object
additionalProperties: false
properties:
family:
type:
- string
- 'null'
recordClass:
type:
- string
- 'null'
recordKey:
type:
- string
- 'null'
variant:
type:
- string
- 'null'
workspaceId:
type:
- integer
- 'null'
tenantId:
type:
- integer
- 'null'
reason:
type: string
required:
- reason
DerivedStateInvalidationResponse:
type: object
additionalProperties: false
required:
- invalidatedCount
properties:
invalidatedCount:
type: integer
minimum: 0
DerivedStateConsumerValidationRequest:
type: object
description: |
Adoption request for one UI consumer. New consumers should not use the
shared store until family support, scope-sensitive inputs, access
pattern, and freshness behavior are all explicit.
additionalProperties: false
required:
- surface
- family
- variant
- accessPattern
- scopeInputs
- freshnessPolicy
- guardScope
properties:
surface:
type: string
example: reviews.register.table
family:
type: string
description: Supported derived-state family name, or a proposed family under review for adoption.
variant:
type: string
description: Stable variant identifier for the consumer path, such as `list_row` or `detail_page`.
accessPattern:
type: string
enum:
- row_safe
- page_safe
- direct_once
scopeInputs:
type: array
description: Scope or capability inputs that affect the result for this consumer.
items:
type: string
guardScope:
type: array
description: Source paths or helper seams the automated guard scans when validating this consumer.
items:
type: string
mutationSensitive:
type: boolean
description: Advisory hint for the guard when post-mutation state changes require explicit freshness handling; does not replace `freshnessPolicy`.
default: false
capabilitySensitive:
type: boolean
description: Advisory hint for the guard when capability context changes the result; does not replace `scopeInputs`.
default: false
freshnessPolicy:
type: string
enum:
- request_stable
- invalidate_after_mutation
- no_reuse
default: request_stable
DerivedStateConsumerValidationResponse:
type: object
additionalProperties: false
required:
- valid
- violations
properties:
valid:
type: boolean
violations:
type: array
items:
type: object
additionalProperties: false
required:
- code
- message
properties:
code:
type: string
enum:
- missing_scope_context
- unsupported_family
- mutation_freshness_gap
- ad_hoc_local_cache
- unstable_variant_key
- missing_guard_scope
- missing_freshness_policy
message:
type: string

View File

@ -1,134 +0,0 @@
# Data Model: Request-Scoped Derived State and Resolver Memoization
This feature does not introduce persistent storage. It defines runtime-only entities that govern how deterministic presenter and resolver results are reused inside one HTTP or Livewire request.
## Entities
### RequestScopedDerivedStateStore
- **Purpose**: Holds resolved derived-state values for the lifetime of one request so repeated consumers can reuse deterministic results.
- **Lifecycle**: Created at request start through the Laravel container and discarded at request end.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `request_scope_id` | string | yes | Internal identifier for the active request-local store instance |
| `entries` | map<string, DerivedStateResolutionRecord> | yes | Resolved entries indexed by deterministic derived-state key |
| `invalidations` | list<string> | no | Keys or family scopes explicitly invalidated during the request |
#### Validation Rules
- The store must never be serialized or persisted.
- The store must never survive beyond the current request lifecycle.
- Each key in `entries` must be unique within the request.
### DerivedStateKey
- **Purpose**: Defines what counts as “the same derivation” for request-local reuse.
- **Contract naming note**: The runtime model uses internal snake_case field names. The logical OpenAPI contract uses camelCase transport names for request and response payloads and must normalize back to this runtime key shape.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `family` | enum | yes | Covered family such as `artifact_truth`, `operation_ux_guidance`, `operation_ux_explanation`, `related_navigation_primary`, `related_navigation_detail`, or `related_navigation_header` |
| `record_class` | string | yes | Concrete model class or logical source type used by the family |
| `record_key` | string | yes | Stable string form of the source record identity |
| `variant` | string | yes | Surface mode or derivation variant such as `list_row`, `detail_page`, `expanded`, or `header_action` |
| `workspace_id` | int nullable | no | Workspace scope when relevant to the derivation |
| `tenant_id` | int nullable | no | Tenant scope when relevant to the derivation |
| `context_hash` | string nullable | no | Stable hash of any additional scope-sensitive inputs such as capability-sensitive visibility or consumer options |
#### Validation Rules
- `family` must be one of the explicitly supported family identifiers.
- `record_class` and `record_key` must be non-empty.
- `variant` must be non-empty and stable for the consumer path.
- `workspace_id`, `tenant_id`, and `context_hash` must be included whenever omitting them could change the result.
### DerivedStateResolutionRecord
- **Purpose**: Represents one resolved request-local entry stored under a `DerivedStateKey`.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | DerivedStateKey | yes | Deterministic key for the stored result |
| `value` | mixed | yes | The resolved presenter or resolver result |
| `negative_result` | bool | yes | Whether the stored value represents a stable negative result such as `null`, no entry, or no next action |
| `freshness_policy` | enum(`request_stable`,`invalidate_after_mutation`,`no_reuse`) | yes | Freshness behavior for the stored result |
| `resolved_at` | string | yes | Internal timestamp or sequence marker for testable store behavior |
#### Validation Rules
- `negative_result = true` is allowed only when the result is deterministic for the current scope.
- `freshness_policy = no_reuse` means the record must not be stored or reused.
- `freshness_policy = invalidate_after_mutation` requires an explicit invalidation path on covered mutation flows.
### DerivedStateFamilyContract
- **Purpose**: Documents the supported family-level rules for key composition, negative-result reuse, and freshness.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `family` | enum | yes | Covered family identifier |
| `source_method` | string | yes | Existing presenter or resolver entry point used to resolve the family |
| `allows_negative_result_cache` | bool | yes | Whether deterministic negative results may be reused within the request |
| `default_freshness_policy` | enum | yes | Default freshness behavior for the family |
| `required_scope_inputs` | list<string> | yes | Key fields that must be present when the family depends on scope or capability context |
### DerivedStateConsumerDeclaration
- **Purpose**: Declares how one UI consumer is allowed to adopt the shared request-scoped contract and provides the metadata used by the automated guardrail.
- **Contract naming note**: This declaration uses the same camelCase field names as the logical consumer-validation contract because the automated guard and onboarding path treat that contract as the published declaration surface.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `surface` | string | yes | Stable surface identifier such as `reviews.register.table` or `operations.run.detail` |
| `family` | enum | yes | Covered family identifier used by the consumer |
| `variant` | string | yes | Stable variant identifier such as `list_row`, `detail_page`, or `header_action` used by the consumer |
| `accessPattern` | enum(`row_safe`,`page_safe`,`direct_once`) | yes | Supported consumer access pattern |
| `scopeInputs` | list<string> | yes | Scope or capability inputs that must be declared for the consumer |
| `freshnessPolicy` | enum(`request_stable`,`invalidate_after_mutation`,`no_reuse`) | yes | Freshness behavior required for this consumer |
| `guardScope` | list<string> | yes | Source paths or helper seams the automated guard uses when validating adoption |
| `mutationSensitive` | bool | no | Advisory flag used when the consumer's visible result changes after in-request mutation and requires explicit freshness handling |
| `capabilitySensitive` | bool | no | Advisory flag used when capability context changes the result and the guard should require explicit scope inputs |
#### Validation Rules
- `family` must exist in a supported `DerivedStateFamilyContract`.
- `variant` must be explicit and stable for the guarded consumer path.
- `accessPattern` must be one of the supported consumer patterns.
- `scopeInputs` must be explicit when capability, workspace, tenant, or route context can affect the result.
- `guardScope` must be narrow enough to produce actionable failures with file and snippet output.
- `mutationSensitive` and `capabilitySensitive` are advisory guard hints and must never replace explicit `freshnessPolicy` or `scopeInputs` declaration.
## Relationships
- One `RequestScopedDerivedStateStore` contains many `DerivedStateResolutionRecord` objects.
- Each `DerivedStateResolutionRecord` is uniquely identified by one `DerivedStateKey`.
- Each `DerivedStateKey` belongs to one `DerivedStateFamilyContract`.
- Each `DerivedStateConsumerDeclaration` references one `DerivedStateFamilyContract` and is validated by the automated adoption guard before new consumers rely on the shared store.
## State Transitions
### Derived State Lifecycle
1. **Miss**: No `DerivedStateResolutionRecord` exists for the requested `DerivedStateKey`.
2. **Resolved**: The existing presenter or resolver computes the result and stores one `DerivedStateResolutionRecord` when reuse is allowed.
3. **Reused**: Additional consumers in the same request retrieve the same stored record without a new full derivation.
4. **Invalidated**: A covered mutation explicitly invalidates affected keys or family scopes when business truth changes.
5. **Recomputed**: The next access after invalidation resolves a fresh record under the same or updated key.
## Notes
- No database migrations, model tables, or persisted read models are introduced.
- The feature must not add a generic cross-request cache abstraction.
- Existing presenter envelopes and navigation entry objects remain the business-visible payloads; the new model only governs reuse of those outputs within one request.
- The automated guardrail uses `DerivedStateConsumerDeclaration` metadata to block undeclared or unsupported adoption patterns in CI.

View File

@ -1,271 +0,0 @@
# Implementation Plan: Request-Scoped Derived State and Resolver Memoization
**Branch**: `167-derived-state-memoization` | **Date**: 2026-03-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/167-derived-state-memoization/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/167-derived-state-memoization/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce one explicit request-scoped derived-state contract for deterministic presenter and resolver outputs that are currently recalculated multiple times per record and per surface during one HTTP or Livewire request. The first implementation slice will bind a per-request in-memory store inside the Laravel container, define a stable key contract, adopt the store behind `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver`, converge existing local cache behavior where appropriate, and protect freshness with focused mutation-path and scope-safety tests.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams
**Storage**: PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only
**Testing**: Pest 4 unit and feature tests, including focused Filament page/component coverage and mutation-path regression tests run through Laravel Sail
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging/production
**Project Type**: web application
**Performance Goals**: Covered deterministic derived-state families resolve at most once per request for the same family + record + variant + scope tuple; covered list/detail/widget pages keep DB-only render behavior and avoid repeated presenter/resolver fan-out in one render pass
**Constraints**: No cross-request caching, no new persistent summaries, no business-semantic changes, no RBAC drift, request-local reuse must work for both HTTP and Livewire requests, mutation freshness must be explicit, and covered surfaces must preserve current operator-visible meaning
**Scale/Scope**: One cross-cutting runtime contract, three covered derived-state families, representative adoption across review register, evidence overview, baseline snapshot, operation-run detail/list, and related-context surfaces serving dozens of rows and multi-section pages per request
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | No inventory or snapshot ownership semantics change; reuse only affects request-time derivation. |
| Read/write separation | PASS | PASS | Default path is read-only. Mutation-path freshness is a guardrail, not a new write flow. |
| Graph contract path | N/A | N/A | No Graph calls or `config/graph_contracts.php` changes. |
| Deterministic capabilities | PASS | PASS | Capability semantics remain unchanged; any capability-sensitive derivation must key scope explicitly or bypass reuse. |
| Workspace + tenant isolation | PASS | PASS | Request-local reuse is explicitly scoped and must not cross workspace, tenant, or request boundaries. |
| RBAC-UX authorization semantics | PASS | PASS | No new policies or capabilities; tests must prove no 404/403 leakage through reused derived values. |
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` family or feedback path; covered operation surfaces reuse guidance only. |
| Data minimization | PASS | PASS | No additional persistence or log payloads; cached state lives only inside the current request. |
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED ABSTRACTION | PASS WITH JUSTIFIED ABSTRACTION | One new abstraction is introduced because three existing families already share the same repeated-cost shape and page-local caches are insufficient. |
| Persisted truth / behavioral state | PASS | PASS | No new table, artifact, status, or reason family. |
| UI semantics / few layers | PASS | PASS | The design sits below existing presenters and resolvers and must not create a second interpretation layer. |
| Badge semantics (BADGE-001) | PASS | PASS | Existing badge catalogs and renderers remain the single source for visible state mappings. |
| Filament Action Surface Contract | PASS | PASS | No action inventory or inspect-affordance change; only repeated derivation behind existing surfaces changes. |
| Filament UX-001 | PASS | PASS | No layout redesign; current list/detail/widget structures stay intact. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design remains inside the existing Filament v5 + Livewire v4 stack with no legacy API introduction. |
| Provider registration location | PASS | PASS | No panel or provider change; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global-search hard rule | PASS | PASS | No global-search behavior changes are proposed. |
| Asset strategy | PASS | PASS | No new assets, no shared asset registration, and no new `filament:assets` requirement. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/167-derived-state-memoization/research.md`.
Key decisions:
- Use a dedicated per-request in-memory store bound through the Laravel container rather than static arrays or persistent caches.
- Define one stable key contract around family, record identity, variant, and scope context rather than relying on model-object identity or page-local assumptions.
- Integrate through the existing `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver` entry points instead of introducing a new presentation framework.
- Standardize row-safe and page-safe consumer seams on covered Filament surfaces so multiple closures can share one resolved family result.
- Treat mutation freshness as an explicit invalidation or fresh-access rule rather than as an accidental side effect of current code order.
- Validate behavior with focused derivation-count, scope-safety, and mutation-path tests instead of relying on ad hoc microbenchmarks.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/167-derived-state-memoization/`:
- `data-model.md`: runtime entities for the request-scoped store, key contract, supported family contract, and freshness policy
- `contracts/request-scoped-derived-state.logical.openapi.yaml`: logical service contract for resolve, invalidate, and consumer-validation behavior
- `contracts/request-scoped-derived-state-key.schema.json`: structural schema for deterministic key composition
- `quickstart.md`: focused implementation and verification workflow
Design decisions:
- The request-scoped store is a single narrow abstraction, not a generic caching platform.
- The store is bound in the Laravel container per request and not persisted to cache stores or the database.
- Existing family entry points remain the only place where covered derivations are resolved; adoption happens behind those seams.
- Existing local caches such as `FindingResource::$primaryRelatedEntryCache` become convergence points, not a parallel long-term pattern.
- Covered mutation flows must either invalidate affected keys or force a fresh derivation path after business-state changes.
- Regression protection focuses on repeated-read elimination, cross-scope safety, and post-mutation freshness rather than on implementation trivia.
## Project Structure
### Documentation (this feature)
```text
specs/167-derived-state-memoization/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── request-scoped-derived-state.logical.openapi.yaml
│ └── request-scoped-derived-state-key.schema.json
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ ├── Monitoring/
│ │ │ └── EvidenceOverview.php
│ │ ├── Operations/
│ │ │ └── TenantlessOperationRunViewer.php
│ │ └── Reviews/
│ │ └── ReviewRegister.php
│ └── Resources/
│ ├── BaselineSnapshotResource.php
│ ├── EvidenceSnapshotResource.php
│ ├── FindingResource.php
│ ├── OperationRunResource.php
│ ├── ReviewPackResource.php
│ ├── TenantReviewResource.php
│ └── PolicyVersionResource/
│ └── Pages/
│ └── ViewPolicyVersion.php
├── Providers/
│ └── AppServiceProvider.php
└── Support/
├── Navigation/
│ └── RelatedNavigationResolver.php
├── OpsUx/
│ └── OperationUxPresenter.php
└── Ui/
├── DerivedState/
│ ├── DerivedStateKey.php
│ ├── DerivedStateFamily.php
│ └── RequestScopedDerivedStateStore.php
└── GovernanceArtifactTruth/
└── ArtifactTruthPresenter.php
tests/
├── Feature/
│ ├── Filament/
│ │ ├── ReviewRegisterDerivedStateMemoizationTest.php
│ │ ├── EvidenceOverviewDerivedStateMemoizationTest.php
│ │ ├── OperationRunDerivedStateMemoizationTest.php
│ │ └── DerivedStateMutationFreshnessTest.php
│ ├── Guards/
│ │ └── DerivedStateConsumerAdoptionGuardTest.php
│ └── Navigation/
│ └── RelatedNavigationResolverMemoizationTest.php
└── Unit/
└── Support/
└── Ui/
└── DerivedState/
└── RequestScopedDerivedStateStoreTest.php
```
**Structure Decision**: Keep the existing Laravel monolith structure. Introduce the new runtime support types as a narrow support layer near the covered presenter/resolver families, bind them in `AppServiceProvider`, and adopt them through current Filament resources/pages rather than introducing new base directories or a broader platform package.
## Implementation Strategy
### Phase A — Introduce the Request-Scoped Store and Key Contract
**Goal**: Add one explicit per-request store with deterministic keys and no persistence.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Providers/AppServiceProvider.php` | Bind the new derived-state store with request-local lifecycle semantics and no cross-request persistence |
| A.2 | `app/Support/Ui/DerivedState/*` | Add the narrow store, key, and supported-family support types needed for resolve and invalidate behavior |
| A.3 | Covered unit tests | Verify hit/miss, negative-result reuse, distinct-variant separation, and explicit invalidation behavior |
### Phase B — Adopt Artifact Truth on Representative Surfaces
**Goal**: Route repeated artifact-truth resolution through the shared contract and expose row-safe/page-safe access on representative surfaces.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` | Route `forBaselineSnapshot()`, `forEvidenceSnapshot()`, `forTenantReview()`, `forReviewPack()`, and `forOperationRun()` through the request-scoped store while preserving existing envelope semantics |
| B.2 | `app/Filament/Pages/Reviews/ReviewRegister.php` | Replace repeated per-closure `forTenantReview()` calls with one row-safe access path |
| B.3 | `app/Filament/Pages/Monitoring/EvidenceOverview.php` | Reuse one artifact-truth resolution per active snapshot row in the canonical evidence overview |
| B.4 | `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/ReviewPackResource.php`, and `app/Filament/Resources/OperationRunResource.php` | Keep the first-slice baseline snapshot, evidence snapshot, tenant review, review pack, and operation-run helper consumers aligned to the shared presenter contract without changing visible meaning |
### Phase C — Adopt Operation UX and Related Navigation
**Goal**: Reuse operation guidance and related-context resolution through the same contract while converging existing hidden caches.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Support/OpsUx/OperationUxPresenter.php` | Route covered guidance/explanation reads through the request-scoped store |
| C.2 | `app/Support/Navigation/RelatedNavigationResolver.php` | Route primary, detail, and header entry resolution through the request-scoped store |
| C.3 | `app/Filament/Resources/OperationRunResource.php` and `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Reuse operation guidance and related context on run list/detail surfaces |
| C.4 | `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php` | Replace page-local related-navigation repetition and align the existing finding-specific cache behavior with the shared contract |
### Phase D — Freshness Rules and Consumer Guardrails
**Goal**: Make post-mutation freshness explicit and prevent future heavy closures from bypassing the shared contract.
| Step | File | Change |
|------|------|--------|
| D.1 | Covered mutating resource/page actions | Document and implement invalidation or forced-fresh access where visible truth, guidance, or related navigation changes within the same request |
| D.2 | Covered resources/pages | Introduce clearly named row-safe or page-safe helper seams for repeated derived-state reads |
| D.3 | `specs/167-derived-state-memoization/quickstart.md`, `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` | Document the future-family adoption path and add an automated guard that locks the supported-family, keying, access-pattern, and freshness rules so future surfaces cannot drift back to ad hoc local caches |
### Phase E — Regression Protection and Verification
**Goal**: Prove the first slice delivers one-derivation-per-request behavior without leaks or stale post-mutation state.
| Step | File | Change |
|------|------|--------|
| E.1 | `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php` | Add core store and key behavior coverage |
| E.2 | `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php` | Prove repeated artifact-truth reads on one row resolve once per request |
| E.3 | `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` | Prove canonical evidence overview reuses one artifact-truth result per row without scope leakage |
| E.4 | `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php` and `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php` | Prove operation-guidance and related-navigation reuse plus authorization safety |
| E.5 | `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php` | Prove covered post-mutation truth and navigation follow explicit fresh-derivation rules within the same request |
| E.6 | `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` | Fail CI with actionable output if a covered family introduces an ad hoc local cache or adopts the shared store without explicit declaration metadata |
| E.7 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before implementation completion |
## Key Design Decisions
### D-001 — Request-local reuse must be explicit, not hidden in page-local static arrays
The repo already shows local cache behavior in isolated places, but the repeated-cost problem spans three families and multiple surfaces. One explicit request-scoped contract is narrower and safer than multiplying hidden static caches.
### D-002 — Keys must include scope context, not just record identity
Model ID alone is insufficient because some derived values depend on route context, surface variant, or capability-sensitive visibility. The key contract must include scope-sensitive inputs or the family must bypass reuse.
### D-003 — Adoption should happen behind existing presenters and resolvers, not above them
The feature exists to reduce duplicate work beneath already-correct semantics. Replacing the current presenters or adding a new presentation meta-framework would violate the spec's bounded intent.
### D-004 — Row-safe and page-safe helper seams are the UI-level adoption point
Most repeated work comes from multiple closures on the same list row or detail section. The consumer-side seam should therefore be one named row-safe or page-safe accessor rather than another layer of inline app() calls.
### D-005 — Mutation freshness is part of the contract, not an afterthought
Request-local reuse is only safe if covered post-action flows either invalidate affected keys or force a fresh derivation path. Blanket caching to request end would create stale-state ambiguity.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Incorrect key composition leaks state across variants or scopes | High | Medium | Include scope/context fields in the key contract and add explicit cross-tenant/workspace regression tests |
| Overbroad first slice creates a large diff with mixed concerns | Medium | Medium | Keep adoption to representative list/detail/canonical surfaces in the three covered families |
| Mutation flows accidentally reuse stale pre-action values | High | Medium | Define family-specific freshness policy and add at least one mutation-path regression per covered family |
| Existing local caches continue living in parallel with the shared contract | Medium | Medium | Converge finding-specific cache behavior into the shared contract and document page-local cache exceptions as temporary only |
| New contract becomes a general cache framework over time | Medium | Low | Keep support types narrow, document non-goals, and require future families to prove deterministic fit before adoption |
## Test Strategy
- Add one narrow unit suite for the store and key contract instead of snapshotting every consuming presenter branch.
- Add focused Filament feature tests for representative list/detail/canonical surfaces where repeated closure calls exist today.
- Prove business-visible non-regression by asserting current labels, badge properties, next-action text, and related URLs remain unchanged on covered examples.
- Add scope-safety tests that exercise tenant-context and canonical workspace-context reuse without leaking unauthorized tenant state, including at least one explicit deny-as-not-found regression for non-members or wrong-scope users and one explicit forbidden regression for in-scope users lacking capability.
- Add a dedicated post-mutation freshness suite in `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php` to prove stale pre-action truth, guidance, or related navigation is not reused after business state changes in the same request.
- Keep the future-family adoption path documented in `quickstart.md` and the logical contract so new presenter or resolver families declare supported family, scope inputs, access pattern, and freshness behavior before using the shared store.
- Add a lightweight guard test in `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` that scans covered source paths and fails with file-and-snippet output when new ad hoc local caches or undeclared adoption patterns appear.
- Keep Livewire v4-compatible page tests for covered pages and use the minimum focused Sail test set needed for implementation verification.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| New request-scoped derived-state abstraction | Three existing families already exhibit the same deterministic repeated-cost pattern across shipped surfaces, and the contract must enforce scope safety plus freshness rules consistently | Page-local static caches and one-off helper memoization hide scope boundaries, duplicate logic, and cannot provide one enforceable adoption path |
## Proportionality Review
- **Current operator problem**: Covered pages recompute the same deterministic truth, guidance, and related-navigation state multiple times in one request, increasing render cost and making consistency across closures harder to defend.
- **Existing structure is insufficient because**: Existing presenters and resolvers are correct but have no explicit request-local reuse boundary, so every closure and page fragment can trigger a full derivation again.
- **Narrowest correct implementation**: One request-scoped store plus one stable key contract beneath the existing families is enough to remove repeated work without persistence, without semantic changes, and without a new UI meta-framework.
- **Ownership cost created**: The codebase gains one bounded runtime abstraction, adoption work on covered consumers, and focused tests for keying, scope safety, and freshness.
- **Alternative intentionally rejected**: Static arrays, ad hoc per-page caches, and persistent cache stores were rejected because they either obscure scope semantics or solve the wrong problem layer.
- **Release truth**: Current-release truth. The hotspot exists today on Review Register, Evidence Overview, Baseline Snapshot, Operation Run, and related navigation surfaces.

View File

@ -1,52 +0,0 @@
# Quickstart: Request-Scoped Derived State and Resolver Memoization
Implement Spec 167 by adding one explicit request-scoped derived-state store beneath the existing presenter and resolver families, then adopt it on representative list, detail, and canonical surfaces without changing operator-visible semantics.
## Implementation Steps
1. Add the narrow request-scoped derived-state support types under the existing support layer and bind the store in `app/Providers/AppServiceProvider.php` with request-local lifecycle semantics.
2. Define the deterministic key contract for family, record identity, variant, and scope-sensitive context, plus an explicit invalidation path for mutation-sensitive derivations.
3. Route `ArtifactTruthPresenter::forBaselineSnapshot()`, `forEvidenceSnapshot()`, `forTenantReview()`, `forReviewPack()`, and `forOperationRun()` through the shared store.
4. Refactor repeated consumer seams on `ReviewRegister`, `EvidenceOverview`, `BaselineSnapshotResource`, `BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`, `EvidenceSnapshotResource`, `TenantReviewResource`, `ReviewPackResource`, and `OperationRunResource` so the first-slice badge, description, next-step, and helper consumers share one per-record derivation.
5. Route covered `OperationUxPresenter` guidance/explanation and `RelatedNavigationResolver` primary/detail/header entry paths through the same store.
6. Converge the existing finding-specific related-entry cache and other repeated navigation consumers toward the shared contract instead of leaving multiple local cache patterns in place.
7. Add focused unit and feature tests for derivation counts, negative-result reuse, mutation freshness, and cross-scope safety, including `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php` as the dedicated freshness suite.
8. Add `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` so CI fails with actionable output if covered families reintroduce ad hoc local caches or adopt the shared store without explicit consumer declaration metadata.
## Future Family Adoption
1. Confirm the candidate family is deterministic for the proposed access path and that adopting the shared store does not introduce a second semantic layer.
2. Declare the full consumer metadata set under the top-level `x-derived-state-consumers` extension in `contracts/request-scoped-derived-state.logical.openapi.yaml` before adding the consumer: `surface`, `family`, `variant`, `accessPattern`, `scopeInputs`, `freshnessPolicy`, and `guardScope`. Add `requiredMarkers` and `maxOccurrences` guard metadata so the adoption guard can point to the intended helper seam and reject bypasses or resurrected local caches. Advisory hints such as `mutationSensitive` or `capabilitySensitive` may be added when they help review, but they do not replace the required declaration fields.
3. Choose one supported `accessPattern` per surface: `row_safe`, `page_safe`, or `direct_once`; do not introduce a new page-local static cache for a covered family.
4. Add or update the focused Pest coverage that proves repeated reads collapse to one derivation, scope boundaries remain intact, and any mutation-sensitive path is fresh after state changes.
5. If a family cannot satisfy deterministic keying or freshness rules, use the explicit no-reuse path instead of weakening the shared contract.
6. Run `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` after adding or widening adoption so undeclared scope inputs, freshness gaps, missing guard scope, and ad hoc local caches fail before merge.
## Verification
### Automated
```bash
vendor/bin/sail up -d
vendor/bin/sail artisan test --compact tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/DerivedStateMutationFreshnessTest.php
vendor/bin/sail artisan test --compact tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php
vendor/bin/sail bin pint --dirty --format agent
```
### Manual
1. Open the review register and verify artifact-truth label, publication state, and next-step text remain unchanged while repeated presenter calls are eliminated.
2. Open the evidence overview and verify one active snapshot row per tenant still renders the same truth and freshness messaging, then confirm one canonical authorization regression still behaves correctly: non-member or wrong-scope access remains deny-as-not-found and an in-scope user lacking capability remains forbidden.
3. Open the tenantless operation-run viewer and verify related context, guidance, and artifact-truth details remain consistent.
4. Exercise one covered mutating flow and verify any post-action truth or related navigation shown in the same request reflects the updated business state.
## Expected Outcome
- Covered presenter and resolver families resolve deterministic results once per request for the same key.
- Covered surfaces retain the same operator-visible semantics and navigation destinations.
- No persistent caches, no new semantic state families, and no cross-tenant or cross-workspace reuse leakage are introduced.

View File

@ -1,49 +0,0 @@
# Research: Request-Scoped Derived State and Resolver Memoization
## Decision 1: Use a dedicated request-scoped in-memory store bound through the Laravel container
- **Decision**: Introduce one dedicated request-scoped derived-state store with request-local lifecycle semantics instead of static arrays or persistent cache stores.
- **Rationale**: The feature needs explicit reuse within one HTTP or Livewire request and explicit isolation across requests. A request-local container binding makes that boundary visible and testable while avoiding new persistence and avoiding cross-request staleness.
- **Alternatives considered**:
- Static caches inside presenters or resources: rejected because they hide scope boundaries, duplicate behavior across families, and make invalidation inconsistent.
- `Cache::remember()` or Redis-backed caching: rejected because the spec explicitly excludes cross-request caching and because stale semantic reuse would become much harder to reason about.
## Decision 2: Key derivations by family, record identity, variant, and scope-sensitive context
- **Decision**: Define one deterministic key contract that includes the derived-state family, stable record identity, variant or surface mode, and any workspace, tenant, or visibility-sensitive context required to produce the correct result.
- **Rationale**: Existing repeated work happens because the same deterministic question is asked multiple times. Correct reuse therefore depends on a stable definition of “same question.” Model-object identity alone is insufficient because the same record can appear through different model instances or under different scopes.
- **Alternatives considered**:
- Model ID only: rejected because it cannot distinguish list vs detail variants or capability-sensitive outputs.
- `spl_object_hash()` only: rejected because it prevents convergence across separate model instances representing the same record.
## Decision 3: Integrate through the existing family entry points, not by adding a new presentation framework
- **Decision**: Route reuse through `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver` entry points instead of creating a generic presenter base class, a universal decorator, or a new UI taxonomy layer.
- **Rationale**: The business semantics already live in these families. The feature's goal is to reduce repeated deterministic work beneath them, not to redesign how operator meaning is modeled.
- **Alternatives considered**:
- New cross-domain presentation framework: rejected because it would layer new semantics on top of already-correct families and violate the spec's narrow foundation intent.
- Surface-only fixes per page: rejected because the same repeated-cost pattern already spans multiple domains and would continue to reappear elsewhere.
## Decision 4: Converge existing hidden caches into the shared contract and keep negative results reusable
- **Decision**: Standardize existing local request-like caches, such as the finding primary related-entry cache, behind the shared contract and allow deterministic negative results like “no related entry” or “no next action” to be reused within one request.
- **Rationale**: The repo already contains evidence that request-local reuse is useful, but it is unevenly applied. Converging on one contract avoids parallel caching patterns and still prevents repeated work when the correct result is the absence of a link or action.
- **Alternatives considered**:
- Leave existing local caches untouched and optimize only the worst pages: rejected because it would preserve multiple hidden patterns and make future adoption harder.
- Cache only positive results: rejected because deterministic negative results can also drive repeated work and should be equally reusable when scope-stable.
## Decision 5: Treat mutation freshness as an explicit family rule
- **Decision**: Covered mutating flows must explicitly invalidate or bypass request-local reuse after business state changes within the same request.
- **Rationale**: The spec requires “no stale within request ambiguity.” A clear family-level freshness rule is safer than assuming the existing code path order will always avoid stale derived values.
- **Alternatives considered**:
- Cache for the full request without exceptions: rejected because post-action state could become stale and misleading.
- Disable reuse on every Livewire action: rejected because many actions still have deterministic pre- and post-action read phases where request-local reuse remains valuable.
## Decision 6: Test derivation-count behavior directly instead of proxying everything through query-count assertions
- **Decision**: Validate the feature with focused unit and feature tests that prove one full derivation per request for representative covered families, plus explicit scope-safety and mutation-path tests.
- **Rationale**: The repeated-cost problem is not just SQL chatter. It is repeated presenter and resolver work across closures and page fragments. Query-count assertions alone would miss important non-query work and would not prove freshness rules.
- **Alternatives considered**:
- Measure only query counts: rejected because the problem is broader than SQL and includes repeated in-memory translation and navigation assembly.
- Rely on manual profiling only: rejected because this feature needs regression protection against future local cache drift.

View File

@ -1,219 +0,0 @@
# Feature Specification: Request-Scoped Derived State and Resolver Memoization
**Feature Branch**: `167-derived-state-memoization`
**Created**: 2026-03-28
**Status**: Draft
**Input**: User description: "Request-scoped derived state and resolver memoization foundation"
## 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 and review register surfaces
- Tenant-scoped tenant review and review-pack list and detail surfaces
- Workspace-scoped operation run list and tenantless operation-run detail surfaces
- Existing detail and list surfaces that build related-context entries through the shared navigation resolver, especially baseline snapshot, policy version, and finding inspection surfaces
- **Data Ownership**:
- Workspace-owned records affected: baseline snapshots, operation runs, workspace-scoped review register rows, and related workspace-scoped records whose operator-facing state is repeatedly derived during one request
- Tenant-owned records affected: evidence snapshots, tenant reviews, review packs, and tenant-owned records that contribute related navigation entries or operator guidance on covered surfaces
- This feature does not change ownership boundaries and does not introduce any new persisted artifact; it only changes how already-owned truth is reused inside one request
- **RBAC**:
- Existing workspace membership, tenant entitlement, and capability checks remain unchanged across all covered surfaces
- Non-members and out-of-scope users remain deny-as-not-found
- In-scope users missing required capability remain forbidden
- Request-local reuse must never cross workspace, tenant, request, or authorization boundaries
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Canonical evidence, review, and operation-run surfaces continue to open in the currently active tenant context or existing prefiltered scope when entered from tenant context. Request-local reuse must respect that existing scope instead of broadening a derivation to workspace-wide results.
- **Explicit entitlement checks preventing cross-tenant leakage**: Any memoized derived state used on canonical views must remain isolated to the current request and must include scope-sensitive inputs whenever the output depends on tenant context, workspace context, visibility rules, or capability-sensitive presentation. A derived label, next action, or related entry computed for one tenant or one entitlement context must never be reused for another.
## 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 snapshot trustworthy, and where should I go next? | Existing artifact-truth label, explanation, status badges, related context, next action | Raw payloads, low-level counters, internal reason fragments | artifact truth, freshness, readiness, operator actionability | Existing actions only | View snapshot, inspect related context, open related run | 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 what should I inspect? | Existing truth badges, completeness explanation, next action, related navigation | Raw evidence payloads, internal detail fragments | artifact truth, completeness, freshness, actionability | Existing actions only | View snapshot, open related review or run | Existing destructive actions unchanged |
| Tenant review list/detail and review register | Tenant or workspace review operator | List/detail/register | Is this review ready, and what is blocking it if not? | Existing primary label, publication-readiness state, next action, related navigation | Raw section payloads, internal readiness details | artifact truth, publication readiness, actionability | Existing actions only | View review, open related pack or run | Existing destructive actions unchanged |
| Review-pack list/detail | Tenant review operator | List/detail | Is this pack usable, and what is its source context? | Existing primary truth state, operator explanation, related navigation | Internal generation details, raw export payloads | artifact truth, readiness, actionability | Existing actions only | View pack, open source review | Existing destructive actions unchanged |
| Operation run list/detail and related-context surfaces | Governance or operations operator | List/detail | What happened, what does it mean, and where should I navigate? | Existing surface guidance, operator explanation, related entries, existing run metadata | Raw context JSON, low-level execution fields | execution outcome, artifact truth where relevant, actionability | Existing actions only | View run, retry where already allowed, open related record | Existing dangerous rerun or mutation actions unchanged |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: No. The feature reuses existing artifact truth, operation UX, and related-navigation truth.
- **New persisted entity/table/artifact?**: No. All reuse remains request-local and in-memory only.
- **New abstraction?**: Yes. A single request-scoped derived-state contract is introduced so deterministic presenter and resolver outputs can be reused explicitly within one request.
- **New enum/state/reason family?**: No. This feature must not introduce a new semantic family.
- **New cross-domain UI framework/taxonomy?**: No. The feature is a runtime reuse contract, not a new interpretation or badge framework.
- **Current operator problem**: Governance-heavy list, detail, and widget surfaces repeatedly recompute the same truth, guidance, and related-navigation state, making each additional closure or surface fragment more expensive than it should be and increasing the risk that related values diverge in timing or freshness.
- **Existing structure is insufficient because**: Current presenters and resolvers are semantically correct but are called independently from badge, description, tooltip, visibility, URL, detail-entry, and widget closures without one explicit request-local reuse boundary.
- **Narrowest correct implementation**: A request-scoped deterministic reuse contract for explicitly covered presenter and resolver families is the smallest viable fix. It addresses the repeated-cost shape without adding persistence, without introducing cross-request caching, and without redefining business semantics.
- **Ownership cost**: The feature adds key-discipline rules, adoption work on covered surfaces, freshness testing for mutation flows, and ongoing review discipline so new heavy closures do not bypass the shared contract.
- **Alternative intentionally rejected**: Page-local static caches, ad hoc helper memoization, cross-request cache stores, and broad query redesign were rejected because they either hide scope boundaries, create stale-state ambiguity, or solve a different layer of the problem.
- **Release truth**: Current-release truth. The affected hotspots already exist on shipped artifact, review, navigation, and operation surfaces.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Reuse One Derived Truth Per Record (Priority: P1)
As an operator viewing governance-heavy lists and detail surfaces, I want repeated labels, explanations, icons, colors, next actions, and related links for the same record to come from one request-local derivation, so that the page remains responsive and internally consistent.
**Why this priority**: This is the immediate product-cost problem. The same row or detail page currently asks for the same deterministic answer multiple times through separate UI closures.
**Independent Test**: Can be fully tested by instrumenting a covered surface where the same presenter or resolver output is read multiple times for one record and proving that the full derivation occurs once per request while all visible values stay unchanged.
**Acceptance Scenarios**:
1. **Given** a covered list row renders a primary label, badge color, badge icon, description, and next action from the same artifact-truth state, **When** the row is built within one request, **Then** the underlying full truth derivation occurs once and the visible outputs remain identical to the current semantics.
2. **Given** a covered detail page renders related-context entries and operator explanation from the same underlying record, **When** the page is built within one request, **Then** repeated reads reuse the same request-local derived state instead of recomputing it separately.
---
### User Story 2 - Reuse Derived State Across Surface Fragments In One Request (Priority: P1)
As an operator using pages that combine tables, summary sections, and related-context fragments, I want the same deterministic truth to be reused across those fragments, so that multi-part pages do not multiply the same derivation cost.
**Why this priority**: The repeated-cost shape is not limited to one table cell. It also appears when one request renders widgets, detail sections, and navigation context for the same records.
**Independent Test**: Can be fully tested by instrumenting a covered page where the same record contributes to more than one surface fragment during one request and proving that the derivation is reused while the page still renders the same operator-facing meaning.
**Acceptance Scenarios**:
1. **Given** a canonical or workspace surface renders a table plus a supporting detail or related-context fragment for the same record set, **When** the request completes, **Then** the shared presenter or resolver family computes each deterministic record-scope result once.
2. **Given** a covered page uses both a presenter-backed explanation and a resolver-backed related link for the same record in multiple places, **When** the page renders, **Then** each covered family reuses its own request-local result instead of repeating full derivation work per consumer.
---
### User Story 3 - Preserve Freshness In Mutation Flows (Priority: P2)
As an operator triggering an action that changes a record's visible truth, guidance, or related navigation, I want any post-action state shown within the same request to be freshly determined when needed, so that the interface never reuses stale pre-mutation derived state.
**Why this priority**: Request-local reuse is only safe if mutation flows define where reuse stops and fresh determination must resume.
**Independent Test**: Can be fully tested by executing a covered mutating action that changes operator-visible truth or related navigation in the same request and proving that the post-action state is not taken from the stale pre-action derivation.
**Acceptance Scenarios**:
1. **Given** a covered action changes the business state used by a presenter or resolver, **When** the action finishes within the same request, **Then** any post-action truth or navigation shown to the operator is freshly determined according to the explicit freshness rule.
2. **Given** a covered derivation is intentionally non-deterministic or capability-sensitive, **When** the request path cannot safely reuse a prior result, **Then** the feature bypasses memoization or uses a distinct key rather than returning a stale or cross-scope result.
### Edge Cases
- The same underlying record is referenced through more than one model instance during one request; identity-based keying must still converge when the business inputs are the same.
- Two derivation variants for the same record require different outputs, such as list-surface primary state versus detail-surface related entries; the key contract must keep those variants separate.
- A covered path returns a stable negative result such as no related entry or no next action; that absence must be reusable within the request instead of triggering repeated resolver work.
- A derivation depends on capability, visibility, or current-scope context; memoization must include those inputs or explicitly opt out.
- A mutating action changes the state used by a covered derivation inside the same request; the pre-mutation result must not be reused after the state change.
- Canonical views entered from tenant context must not reuse derived state outside the active tenant or workspace scope.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph call path, no new persistence, and no new long-running operation family. It hardens the request-time read path for existing presenter and resolver outputs. Existing business truth, existing safety gates, existing audit semantics, and existing operation observability remain unchanged. If a covered mutating action needs fresh post-action truth, the implementation must define that freshness path explicitly rather than relying on accidental stale reuse.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one new abstraction and no new persistence or state family. The abstraction is justified because repeated deterministic derivation is already a current-release cross-surface cost problem and existing local call-site discipline is insufficient. The feature follows the default bias by deriving before persisting, keeping reuse request-local, and avoiding a broader cache platform.
**Constitution alignment (OPS-UX):** This feature does not create a new `OperationRun` family and does not change the three-surface feedback contract. If operation-run list or detail surfaces adopt request-local guidance reuse, `OperationRun.status` and `OperationRun.outcome` remain service-owned and all existing notification behavior stays unchanged.
**Constitution alignment (RBAC-UX):** This feature does not change the authorization model, but it must not become an accidental authorization layer. Non-members and wrong-scope users remain `404`. In-scope users lacking capability remain `403`. Any derived state that depends on visibility or capability context must include that context in its reuse boundary or bypass reuse. At least one positive and one negative scope-safety regression test must prove that request-local reuse cannot leak unauthorized tenant or workspace state.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
**Constitution alignment (BADGE-001):** This feature must not introduce new page-local badge mappings. If covered surfaces reuse the same derived truth for multiple badge properties, they must continue to consume centralized badge semantics with unchanged visible meaning.
**Constitution alignment (UI-FIL-001):** Covered admin and operator surfaces continue to use existing Filament tables, infolists, widgets, and shared UI primitives. The feature should consolidate how existing derived values are obtained, not introduce custom replacement markup or a new local status language.
**Constitution alignment (UI-NAMING-001):** This feature does not introduce new operator-facing action vocabulary. Existing labels, helper copy, next-action text, run titles, and related-link labels must remain semantically unchanged unless an existing covered surface is already inconsistent and the change is explicitly called out by a follow-up spec.
**Constitution alignment (OPSURF-001):** This feature materially affects operator-facing surfaces but must not change what those surfaces primarily communicate. Default-visible information, diagnostic hierarchy, mutation scope messaging, and inspect affordances stay the same. The change is that repeated deterministic state is obtained once per request and reused consistently.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature must not create a second semantic layer above artifact truth, operation UX, or related navigation. It reuses existing layers and adds one request-local reuse boundary beneath them. Tests must focus on business-visible consistency, scope safety, and freshness consequences rather than treating memoization as a goal by itself.
**Constitution alignment (Filament Action Surfaces):** Covered Filament Resources and Pages keep their current action inventories and inspect affordances. The Action Surface Contract remains satisfied because this feature changes derivation reuse behind existing actions rather than introducing new mutation behavior. No new exemptions are needed.
**Constitution alignment (UX-001 — Layout & Information Architecture):** No new Filament layout pattern is introduced. Existing view-page, list-page, widget, and summary structures remain intact. Any row-safe or page-safe consumption helper introduced by implementation must preserve current table filters, sortability, inspection flow, empty states, and detail-section hierarchy.
### Functional Requirements
- **FR-167-001**: The system MUST provide one explicit request-scoped contract for deterministic derived presentation and navigation state used repeatedly within one HTTP or Livewire request.
- **FR-167-002**: The contract MUST be isolated per request and MUST NOT persist derived state across requests.
- **FR-167-003**: The contract MUST use deterministic keys that can distinguish at minimum derivation family, record type, record identity, surface variant or mode, and any scope-sensitive input that changes the result.
- **FR-167-004**: The contract MUST support an explicit opt-out or distinct-key path for derivations that are non-deterministic, mutation-sensitive, or capability-sensitive.
- **FR-167-005**: The first implementation slice MUST route the covered `ArtifactTruthPresenter::for*()` paths through the request-scoped contract for baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs.
- **FR-167-006**: The first implementation slice MUST route covered `OperationUxPresenter` surface-guidance and operator-explanation paths through the same request-scoped contract.
- **FR-167-007**: The first implementation slice MUST route covered `RelatedNavigationResolver` primary-entry, detail-entry, and header-entry paths through the same request-scoped contract where repeated resolution currently occurs.
- **FR-167-008**: The first implementation slice MUST cover at least one list surface, one detail surface, and one widget or canonical surface across the three target families.
- **FR-167-009**: Covered surfaces MUST be able to read the same deterministic derived value from multiple UI consumers in one request without triggering a second full derivation.
- **FR-167-010**: Covered list surfaces MUST define a row-safe consumption pattern so label, description, tooltip, icon, color, URL, and visibility consumers can share one derived-state result per row when they rely on the same family and scope.
- **FR-167-011**: Covered detail surfaces MUST define an equivalent page-safe or section-safe consumption pattern when the same deterministic result is read more than once during one request.
- **FR-167-012**: The contract MUST support stable reuse of negative results such as no related entry, no next action, or no operator explanation when those results are deterministic for the current scope.
- **FR-167-013**: Request-local reuse MUST NOT change operator-visible semantics, labels, readiness states, navigation destinations, or next-action text on covered surfaces.
- **FR-167-014**: Request-local reuse MUST NOT cross workspace boundaries, tenant boundaries, request boundaries, or authorization contexts.
- **FR-167-015**: If a derived result depends on capability or visibility context, that context MUST be part of the reuse boundary or the derivation MUST bypass request-local reuse.
- **FR-167-016**: Covered mutating action flows MUST define an explicit freshness rule so post-mutation truth, guidance, or navigation is recomputed when the underlying business state changes within the same request.
- **FR-167-017**: The feature MUST forbid new ad hoc page-local static caches for covered families once the shared contract exists, unless an explicit, bounded exemption is documented.
- **FR-167-018**: The feature MUST include focused regression coverage proving one full derivation per request for representative `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver` paths.
- **FR-167-019**: The feature MUST include focused regression coverage proving that request-local reuse cannot leak derived state across tenant, workspace, or entitlement boundaries.
- **FR-167-020**: The feature MUST include at least one mutation-path regression proving that covered post-action state is freshly determined when business truth changes within the same request.
- **FR-167-021**: The feature MUST provide a documented adoption path and an automated guardrail for future presenter and resolver families so new heavy derived-state surfaces do not bypass the contract by default.
- **FR-167-022**: The feature MUST explicitly exclude cross-request caching, persistent cache stores, new persisted summaries, and business-semantic changes to artifact truth, operation guidance, or related navigation.
## 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 snapshots | `app/Filament/Resources/BaselineSnapshotResource.php` and related view page | Existing actions unchanged | Existing clickable inspection remains primary | Existing row actions unchanged | Existing bulk actions unchanged | Existing empty state unchanged | Existing view actions unchanged | N/A | Existing audit model unchanged | This spec changes only how truth and related context are reused during one request |
| Evidence snapshots and evidence overview | `app/Filament/Resources/EvidenceSnapshotResource.php` and `app/Filament/Pages/Monitoring/EvidenceOverview.php` | Existing actions unchanged | Existing clickable inspection remains primary | Existing row actions unchanged | Existing bulk actions unchanged | Existing empty states unchanged | Existing view actions unchanged | N/A | Existing audit model unchanged | No action inventory change; only repeated truth derivation is consolidated |
| Tenant reviews and review register | `app/Filament/Resources/TenantReviewResource.php` and `app/Filament/Pages/Reviews/ReviewRegister.php` | Existing actions unchanged | Existing clickable inspection remains primary | Existing row actions unchanged | Existing bulk actions unchanged | Existing empty states unchanged | Existing view actions unchanged | N/A | Existing audit model unchanged | Review truth, readiness, and next-action reads should reuse one request-local result per record |
| Review packs | `app/Filament/Resources/ReviewPackResource.php` | Existing actions unchanged | Existing clickable inspection remains primary | Existing row actions unchanged | Existing bulk actions unchanged | Existing empty states unchanged | Existing view actions unchanged | N/A | Existing audit model unchanged | No new actions, semantics, or audit events are added |
| Operation runs and related-context pages | `app/Filament/Resources/OperationRunResource.php`, `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, and covered related-context pages using `RelatedNavigationResolver` | Existing actions unchanged | Existing inspection patterns unchanged | Existing retry or navigation actions unchanged | Existing bulk actions unchanged | Existing empty states unchanged | Existing detail actions unchanged | N/A | Existing audit model unchanged | The purpose is request-local reuse of guidance and related navigation, not action redesign |
### Key Entities *(include if feature involves data)*
- **Request-Scoped Derived State Contract**: The explicit per-request reuse boundary for deterministic presenter and resolver outputs.
- **Derived State Key**: The stable identifier that combines derivation family, record identity, variant, and any scope-sensitive inputs needed to return the correct result.
- **Row-Safe Surface State**: The once-derived per-record state reused by multiple list-row consumers in one request.
- **Freshness Boundary**: The point at which a mutating action must recompute truth instead of reusing a previously derived result from the same request.
- **Derived State Family**: One covered presenter or resolver family, such as artifact truth, operation UX guidance, or related navigation.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-167-001**: On the first implementation slice, 100% of covered repeated-read examples derive a deterministic record-scope result at most once per request for the covered family.
- **SC-167-002**: At least three currently affected surface families adopt the same request-scoped contract without changing the operator-visible meaning of their labels, explanations, or related destinations.
- **SC-167-003**: In focused regression coverage, 100% of covered rows and detail pages keep badge properties, descriptions, next-action text, and related-navigation outputs internally consistent when those values depend on the same derived-state family.
- **SC-167-004**: In mutation-path regression coverage, 100% of covered post-action flows show fresh post-mutation truth or navigation when the underlying business state changes inside the request.
- **SC-167-005**: In focused scope-safety regression coverage, 100% of covered canonical and tenant-context examples prevent derived-state reuse across unauthorized workspace, tenant, or entitlement boundaries.
- **SC-167-006**: The first implementation slice ships without introducing any new persisted cache, any new business-semantic state family, or any visible operator-language regression on the covered surfaces.
- **SC-167-007**: An automated derived-state adoption guard fails with actionable output when a covered consumer introduces an ad hoc local cache, bypasses the supported access patterns, or adopts the shared store without explicit scope and freshness declaration.
## Assumptions
- Existing `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver` outputs are already semantically correct for the covered first slice; the problem is repeated request-time computation, not incorrect business meaning.
- Request scope is defined by one HTTP or Livewire request and is sufficiently narrow to avoid cross-request staleness while still removing repeated deterministic work.
- Some surfaces already contain local request-like caches, and the long-term direction is to converge those local caches into one explicit contract instead of allowing multiple hidden patterns to coexist.
- The first implementation slice should reuse existing presenter and resolver families rather than replacing them with a broader semantic or caching platform.
## Dependencies
- Spec 131 - Cross-Resource Navigation
- Spec 144 - Canonical Operation Viewer Context Decoupling
- Spec 156 - Operator Outcome Taxonomy and Cross-Domain State Separation
- Spec 157 - Operator Reason Code Translation and Humanization Contract
- Spec 158 - Governance Artifact Truthful Outcomes and Fidelity Semantics
- Existing baseline snapshot, evidence snapshot, tenant review, review-pack, review register, evidence overview, and operation-run surfaces already in the admin panel
## Non-Goals
- Introducing cross-request caching, Redis, or any persistent cache store
- Redesigning the underlying database query layer or treating this spec as a general query-optimization initiative
- Changing the business semantics of artifact truth, operation guidance, or related navigation
- Replacing existing presenter or resolver families with a new taxonomy or UI framework
- Performing the tenant-governance aggregate consolidation tracked by the adjacent aggregate candidate
- Performing the workspace access-context hardening tracked by the adjacent workspace-context candidate
## Final Direction
This spec establishes one explicit request-scoped reuse contract for deterministic derived state that is already being asked for repeatedly across artifact, review, navigation, and operation surfaces. It is intentionally narrow: no new persistence, no new meaning system, and no speculative cache platform. The first slice should prove that Artifact Truth, Operation UX, and Related Navigation can share one request-local boundary, that mutating flows keep their freshness guarantees, and that future heavy surfaces have a clear adoption path instead of inventing one more local cache.

View File

@ -1,208 +0,0 @@
# Tasks: Request-Scoped Derived State and Resolver Memoization
**Input**: Design documents from `/specs/167-derived-state-memoization/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature. Use Pest coverage in `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`, `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php`, `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php`, `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`, `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`.
**Operations**: This feature touches existing `OperationRun` list/detail surfaces and `OperationUxPresenter`, but it does not create a new run type, does not change run lifecycle ownership, and does not add a new Ops-UX feedback surface.
**RBAC**: Existing workspace membership, tenant entitlement, and 404 vs 403 semantics remain unchanged. Tasks must preserve tenant-safe and workspace-safe derived-state reuse and add focused scope-safety regression coverage.
**Operator Surfaces**: Covered list, detail, and canonical surfaces must keep their current operator-visible meaning while sharing one request-local derived-state result per family where appropriate.
**Filament UI Action Surfaces**: No new actions or inspect affordances are added. Existing action inventories, confirmations, and detail/list interaction patterns must remain intact.
**Filament UI UX-001**: No screen layout redesign is introduced. Existing table, detail, and widget structures remain intact while consumer seams are refactored to reuse derived-state results.
**Badges**: Existing badge semantics must continue to flow through `BadgeCatalog` / `BadgeRenderer`; no page-local mappings are introduced as part of memoization work.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently after the shared runtime contract is in place.
## Phase 1: Setup (Shared Runtime Scaffolding)
**Purpose**: Create the narrow runtime and test scaffolding required for the request-scoped contract.
- [X] T001 [P] Create the request-scoped derived-state support files in `app/Support/Ui/DerivedState/DerivedStateFamily.php`, `app/Support/Ui/DerivedState/DerivedStateKey.php`, and `app/Support/Ui/DerivedState/RequestScopedDerivedStateStore.php`
- [X] T002 [P] Create the focused test files in `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`, `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php`, `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php`, `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`, `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`
---
## Phase 2: Foundational (Blocking Runtime Contract)
**Purpose**: Build the core request-scoped store and binding that all user stories depend on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 [P] Add unit coverage for key composition, hit/miss behavior, negative-result reuse, variant separation, and invalidation in `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`
- [X] T004 [P] Implement the derived-state family and key value objects in `app/Support/Ui/DerivedState/DerivedStateFamily.php` and `app/Support/Ui/DerivedState/DerivedStateKey.php`
- [X] T005 Implement request-local resolve, reuse, and invalidation behavior in `app/Support/Ui/DerivedState/RequestScopedDerivedStateStore.php`
- [X] T006 Register the request-scoped derived-state store binding in `app/Providers/AppServiceProvider.php`
**Checkpoint**: The request-scoped derived-state runtime contract exists and can be adopted by presenter and resolver families.
---
## Phase 3: User Story 1 - Reuse One Derived Truth Per Record (Priority: P1)
**Goal**: Reuse one artifact-truth derivation per covered record on representative list and canonical surfaces.
**Independent Test**: Review Register and Evidence Overview render the same labels, explanations, and next-step text as before while each covered artifact-truth result resolves once per request for the same record and variant.
### Tests for User Story 1
- [X] T007 [P] [US1] Add per-row artifact-truth reuse assertions for `ReviewRegister` in `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php`
- [X] T008 [P] [US1] Add canonical per-row artifact-truth reuse assertions plus one explicit entitlement-boundary regression for `EvidenceOverview` in `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`
### Implementation for User Story 1
- [X] T009 [US1] Route `ArtifactTruthPresenter::forBaselineSnapshot()`, `forEvidenceSnapshot()`, `forTenantReview()`, `forReviewPack()`, and `forOperationRun()` through the request-scoped store in `app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`
- [X] T010 [US1] Replace repeated `forTenantReview()` closure calls with a row-safe artifact-truth access path in `app/Filament/Pages/Reviews/ReviewRegister.php`
- [X] T011 [US1] Reuse a single artifact-truth resolution per active snapshot row in `app/Filament/Pages/Monitoring/EvidenceOverview.php`
- [X] T012 [US1] Keep the first-slice baseline snapshot, evidence snapshot, tenant review, review pack, and operation-run helper consumers aligned to the shared artifact-truth contract in `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/ReviewPackResource.php`, and `app/Filament/Resources/OperationRunResource.php`
- [X] T013 [US1] Run the focused artifact-truth memoization pack in `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php` and `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`
**Checkpoint**: Covered artifact-truth list and canonical surfaces now reuse one deterministic truth result per record and request.
---
## Phase 4: User Story 2 - Reuse Derived State Across Surface Fragments In One Request (Priority: P1)
**Goal**: Reuse operation guidance and related-navigation state across list/detail fragments and converge existing hidden local caches.
**Independent Test**: Operation run list/detail surfaces and representative related-navigation consumers keep the same URLs and guidance text while each covered family resolves once per request for the same scope and also safely reuses deterministic negative results.
### Tests for User Story 2
- [X] T014 [P] [US2] Add operation-guidance and operator-explanation reuse assertions for list/detail surfaces in `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php`
- [X] T015 [P] [US2] Add related-navigation reuse, negative-result caching, deny-as-not-found regressions for non-members or wrong-scope users, forbidden regressions for in-scope users lacking capability, and tenant/workspace entitlement-boundary assertions in `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`
### Implementation for User Story 2
- [X] T016 [US2] Route covered guidance and explanation reads through the request-scoped store in `app/Support/OpsUx/OperationUxPresenter.php`
- [X] T017 [US2] Route primary, detail, and header related-navigation resolution through the request-scoped store in `app/Support/Navigation/RelatedNavigationResolver.php`
- [X] T018 [US2] Reuse operation guidance and related-context state on run list/detail surfaces in `app/Filament/Resources/OperationRunResource.php` and `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- [X] T019 [US2] Converge page-local related-entry consumers with the shared contract in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php`
- [X] T020 [US2] Run the focused guidance and navigation memoization pack in `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php` and `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`
**Checkpoint**: Covered operation-guidance and related-navigation surfaces now share one request-local derived-state contract instead of separate local cache patterns.
---
## Phase 5: User Story 3 - Preserve Freshness In Mutation Flows (Priority: P2)
**Goal**: Make post-mutation freshness explicit so request-local reuse never returns stale artifact truth or navigation after business state changes.
**Independent Test**: A covered mutating action changes business truth within the same request and the subsequent visible artifact-truth or related state is freshly determined instead of reusing stale pre-action results.
### Tests for User Story 3
- [X] T021 [P] [US3] Add post-mutation freshness regression coverage for covered truth-, guidance-, or navigation-affecting generation or review actions in `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php`
- [X] T022 [P] [US3] Add no-reuse and invalidate-after-mutation assertions to `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`
### Implementation for User Story 3
- [X] T023 [US3] Add family freshness-policy and invalidation APIs in `app/Support/Ui/DerivedState/DerivedStateFamily.php` and `app/Support/Ui/DerivedState/RequestScopedDerivedStateStore.php`
- [X] T024 [US3] Invalidate or bypass stale artifact-truth entries after covered tenant review, evidence snapshot, and review pack mutations in `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, and `app/Filament/Resources/ReviewPackResource.php`
- [X] T025 [US3] Update post-action truth, guidance, and related-navigation helper access to use explicit fresh-access paths in `app/Filament/Resources/TenantReviewResource.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/ReviewPackResource.php`, `app/Filament/Resources/OperationRunResource.php`, and `app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php`
- [X] T026 [US3] Run the freshness regression pack in `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php` and `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`
**Checkpoint**: Covered mutation flows now have explicit freshness behavior and cannot accidentally reuse stale request-local derived state.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Add the automated future-family guard, remove superseded local patterns, format touched files, and run the full focused verification pack.
- [X] T027 Implement the automated derived-state adoption guard in `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` using existing guard-test scanning patterns plus declared `surface`, `family`, `variant`, `accessPattern`, `scopeInputs`, `freshnessPolicy`, and `guardScope` metadata to fail with actionable file-and-snippet output when covered families reintroduce ad hoc local caches or undeclared adoption paths
- [X] T028 Remove or collapse superseded ad hoc local derived-state caches and document future-family adoption guardrails in `app/Filament/Pages/Reviews/ReviewRegister.php`, `app/Filament/Pages/Monitoring/EvidenceOverview.php`, `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php`, `specs/167-derived-state-memoization/quickstart.md`, and `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml`
- [X] T029 Run formatting for touched implementation files with `vendor/bin/sail bin pint --dirty --format agent` using `specs/167-derived-state-memoization/quickstart.md`
- [X] T030 Run the final focused verification pack from `specs/167-derived-state-memoization/quickstart.md` against `tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php`, `tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php`, `tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php`, `tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php`, `tests/Feature/Filament/DerivedStateMutationFreshnessTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and creates the narrow runtime and test scaffolding.
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until the request-scoped store and binding exist.
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP artifact-truth reuse slice.
- **User Story 2 (Phase 4)**: Starts after Foundational and can overlap with User Story 1 after the shared store is available, but it is safest after artifact-truth adoption confirms the key contract.
- **User Story 3 (Phase 5)**: Starts after User Stories 1 and 2 have established the family adoption seams because freshness rules depend on the shared contract being in use.
- **Polish (Phase 6)**: Starts after all desired user stories are complete and ends with the automated adoption guard plus focused verification pack passing.
### User Story Dependencies
- **User Story 1 (P1)**: Depends only on the request-scoped store, key contract, and binding from Phase 2.
- **User Story 2 (P1)**: Depends on the same foundational contract and can proceed independently of US1 at the store level, but shares adoption patterns and should follow once the artifact-truth slice proves the consumer seam.
- **User Story 3 (P2)**: Depends on the adoption work from US1 and US2 because mutation freshness only matters after reuse is in place.
### Within Each User Story
- Tests should be added before or alongside implementation and must fail before the story is considered complete.
- Family entry-point changes should land before consumer refactors in the same story.
- Consumer refactors should land before the focused story-level regression run.
### Parallel Opportunities
- `T001` and `T002` can run in parallel during Setup.
- `T003` and `T004` can run in parallel during Foundational work.
- `T007` and `T008` can run in parallel for User Story 1.
- `T014` and `T015` can run in parallel for User Story 2.
- `T021` and `T022` can run in parallel for User Story 3.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel:
Task: T007 tests/Feature/Filament/ReviewRegisterDerivedStateMemoizationTest.php
Task: T008 tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php
# User Story 1 implementation split after test expectations are clear:
Task: T010 app/Filament/Pages/Reviews/ReviewRegister.php
Task: T011 app/Filament/Pages/Monitoring/EvidenceOverview.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel:
Task: T014 tests/Feature/Filament/OperationRunDerivedStateMemoizationTest.php
Task: T015 tests/Feature/Navigation/RelatedNavigationResolverMemoizationTest.php
# User Story 2 implementation split after family entry-point work lands:
Task: T018 app/Filament/Resources/OperationRunResource.php and app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
Task: T019 app/Filament/Resources/FindingResource.php and app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 freshness coverage in parallel:
Task: T021 tests/Feature/Filament/DerivedStateMutationFreshnessTest.php
Task: T022 tests/Unit/Support/Ui/DerivedState/RequestScopedDerivedStateStoreTest.php
# User Story 3 implementation split after freshness rules are fixed:
Task: T024 app/Filament/Resources/TenantReviewResource.php, app/Filament/Resources/EvidenceSnapshotResource.php, and app/Filament/Resources/ReviewPackResource.php
Task: T025 app/Filament/Resources/TenantReviewResource.php, app/Filament/Resources/EvidenceSnapshotResource.php, and app/Filament/Resources/ReviewPackResource.php
```
---
## Implementation Strategy
### MVP First
- Complete Phase 1 and Phase 2.
- Deliver User Story 1 as the MVP slice.
- Verify that representative artifact-truth surfaces now reuse one deterministic result per request without changing visible meaning.
### Incremental Delivery
- Add User Story 2 next to cover operation guidance and related-navigation reuse across multi-fragment surfaces.
- Add User Story 3 last to make mutation freshness explicit once the reuse seams are in place.
### Verification Finish
- Run the derived-state adoption guard test from `quickstart.md`.
- Run Pint on touched files.
- Run the focused verification pack from `quickstart.md`.
- If broader confidence is needed after focused verification, run the wider suite separately.

View File

@ -3,10 +3,10 @@
declare(strict_types=1);
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\RestoreRunResource;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Support\OperationRunLinks;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -33,15 +33,16 @@ public function test_shows_restore_related_links_on_canonical_detail_for_restore
],
]);
$expectedUrl = RestoreRunResource::getUrl('view', ['record' => $restoreRun], tenant: $tenant);
$expectedUrl = OperationRunLinks::related($run->loadMissing('tenant'), $tenant)['Restore Run'] ?? null;
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Open')
->assertSee('View restore run');
->assertSee('Restore Run');
$this->assertIsString($expectedUrl);
$response->assertSee((string) $expectedUrl, false);
}
@ -64,7 +65,7 @@ public function test_shows_only_generic_links_for_tenantless_runs_on_canonical_d
->assertOk()
->assertSee('Operations')
->assertSee(route('admin.operations.index'), false)
->assertDontSee('View restore run');
->assertDontSee('Restore Run');
}
public function test_does_not_show_legacy_admin_details_cta_and_keeps_canonical_view_run_label(): void

View File

@ -1,198 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots;
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\ReviewPack;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\ReviewPackStatus;
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('refreshes evidence artifact truth after expiring a snapshot in the same request', function (): void {
$tenant = \App\Models\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::Complete->value,
'summary' => [
'dimension_count' => 5,
'missing_dimensions' => 0,
'stale_dimensions' => 0,
],
'generated_at' => now(),
]);
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListEvidenceSnapshots::class)
->callTableAction('expire', $snapshot);
$snapshot->refresh();
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
expect((string) $snapshot->status)->toBe(EvidenceSnapshotStatus::Expired->value)
->and($truth->freshnessState)->toBe('stale')
->and(app(RequestScopedDerivedStateStore::class)->countStored(
DerivedStateFamily::ArtifactTruth,
EvidenceSnapshot::class,
(string) $snapshot->getKey(),
'evidence_snapshot',
))->toBe(1);
});
it('refreshes review-pack artifact truth after expiring a pack in the same request', function (): void {
$tenant = \App\Models\Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
'file_disk' => 'exports',
'file_path' => 'review-packs/freshness.zip',
]);
\Illuminate\Support\Facades\Storage::fake('exports');
\Illuminate\Support\Facades\Storage::disk('exports')->put('review-packs/freshness.zip', 'PK-fake');
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListReviewPacks::class)
->callTableAction('expire', $pack);
$pack->refresh();
$truth = app(ArtifactTruthPresenter::class)->forReviewPack($pack);
expect((string) $pack->status)->toBe(ReviewPackStatus::Expired->value)
->and($truth->freshnessState)->toBe('stale')
->and(app(RequestScopedDerivedStateStore::class)->countStored(
DerivedStateFamily::ArtifactTruth,
ReviewPack::class,
(string) $pack->getKey(),
'review_pack',
))->toBe(1);
});
it('returns fresh operation guidance after run state changes within the same request', function (): void {
$tenant = \App\Models\Tenant::factory()->create();
createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$queuedGuidance = OperationUxPresenter::surfaceGuidance($run);
$run->update([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'reason_code' => 'missing_capability',
'execution_legitimacy' => [
'reason_code' => 'missing_capability',
],
],
'failure_summary' => [[
'reason_code' => 'missing_capability',
'message' => 'Missing capability prevented execution.',
]],
]);
$staleGuidance = OperationUxPresenter::surfaceGuidance($run->fresh());
$freshGuidance = OperationUxPresenter::surfaceGuidanceFresh($run->fresh());
expect($staleGuidance)->toBe($queuedGuidance)
->and($freshGuidance)->not->toBe($staleGuidance)
->and($freshGuidance)->toBe('Review the blocked prerequisite before retrying.')
->and(app(RequestScopedDerivedStateStore::class)->countStored(
DerivedStateFamily::OperationUxGuidance,
OperationRun::class,
(string) $run->getKey(),
'surface_guidance',
))->toBe(1);
});
it('returns fresh related navigation after a finding changes its related policy version', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$versionA = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 1,
]);
$versionB = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'version_number' => 2,
]);
$finding = Finding::factory()->for($tenant)->create([
'evidence_jsonb' => [
'current' => [
'policy_version_id' => (int) $versionA->getKey(),
],
],
]);
$resolver = app(RelatedNavigationResolver::class);
$firstEntries = collect($resolver->detailEntries(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding));
$first = $firstEntries->firstWhere('key', 'current_policy_version');
$finding->update([
'evidence_jsonb' => [
'current' => [
'policy_version_id' => (int) $versionB->getKey(),
],
],
]);
$staleEntries = collect($resolver->detailEntries(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding->fresh()));
$freshEntries = collect($resolver->detailEntriesFresh(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding->fresh()));
$stale = $staleEntries->firstWhere('key', 'current_policy_version');
$fresh = $freshEntries->firstWhere('key', 'current_policy_version');
expect($first['targetUrl'] ?? null)->toBe($stale['targetUrl'] ?? null)
->and($fresh['targetUrl'] ?? null)->not->toBe($stale['targetUrl'] ?? null)
->and($fresh['targetUrl'] ?? null)->toContain((string) $versionB->getKey())
->and(app(RequestScopedDerivedStateStore::class)->countStored(
DerivedStateFamily::RelatedNavigationDetail,
Finding::class,
(string) $finding->getKey(),
CrossResourceNavigationMatrix::SOURCE_FINDING,
))->toBe(1);
});

View File

@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\EvidenceOverview;
use App\Models\EvidenceSnapshot;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('reuses one artifact-truth resolution per active snapshot row on the evidence overview', function (): void {
$tenant = \App\Models\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(),
]);
setAdminPanelContext();
Filament::setTenant(null, true);
$this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(EvidenceOverview::class)
->assertSee($tenant->name)
->assertSee('Artifact truth');
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot->fresh());
$store = app(RequestScopedDerivedStateStore::class);
expect($store->countStored(
DerivedStateFamily::ArtifactTruth,
EvidenceSnapshot::class,
(string) $snapshot->getKey(),
'evidence_snapshot',
))->toBe(1)
->and($truth->primaryLabel)->not->toBe('');
});
it('keeps the evidence overview deny-as-not-found for users outside the workspace boundary', function (): void {
$workspaceTenant = \App\Models\Tenant::factory()->create();
$user = \App\Models\User::factory()->create();
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
->get(route('admin.evidence.overview'))
->assertNotFound();
});

View File

@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('reuses operation guidance and explanation state on the canonical run detail surface', function (): void {
$tenant = \App\Models\Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'baseline_compare',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'reason_code' => 'missing_capability',
'blocked_by' => 'queued_execution_legitimacy',
'execution_legitimacy' => [
'reason_code' => 'missing_capability',
],
],
'failure_summary' => [[
'code' => 'operation.blocked',
'reason_code' => 'missing_capability',
'message' => 'Operation blocked because the initiating actor no longer has the required capability.',
]],
]);
Filament::setTenant(null, true);
$this->actingAs($user)->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('Blocked by prerequisite')
->assertSee('Execution legitimacy revalidation');
$guidance = OperationUxPresenter::surfaceGuidance($run->fresh());
$operatorExplanation = OperationUxPresenter::governanceOperatorExplanation($run->fresh());
$store = app(RequestScopedDerivedStateStore::class);
expect($store->countStored(
DerivedStateFamily::OperationUxGuidance,
OperationRun::class,
(string) $run->getKey(),
'surface_guidance',
))->toBe(1)
->and($store->countStored(
DerivedStateFamily::OperationUxExplanation,
OperationRun::class,
(string) $run->getKey(),
'governance_operator_explanation',
))->toBe(1)
->and($guidance)->toBe('Review workspace or tenant access before retrying.')
->and($operatorExplanation?->headline)->not->toBe('');
});

View File

@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\ReviewRegister;
use App\Models\TenantReview;
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('reuses one artifact-truth resolution per row on the canonical review register', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$review = composeTenantReviewForTest($tenant, $user);
setAdminPanelContext();
$this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ReviewRegister::class)
->assertCanSeeTableRecords([$review])
->assertSee('Artifact truth')
->assertSee('Next step');
$truth = app(ArtifactTruthPresenter::class)->forTenantReview($review->fresh());
$store = app(RequestScopedDerivedStateStore::class);
expect($store->countStored(
DerivedStateFamily::ArtifactTruth,
TenantReview::class,
(string) $review->getKey(),
'tenant_review',
))->toBe(1)
->and($truth->primaryLabel)->not->toBe('')
->and($truth->nextStepText())->not->toBe('');
});

View File

@ -1,201 +0,0 @@
<?php
declare(strict_types=1);
use Symfony\Component\Yaml\Yaml;
use Tests\Support\OpsUx\SourceFileScanner;
it('keeps covered derived-state consumers on declared access paths without ad hoc caches', function (): void {
$root = SourceFileScanner::projectRoot();
$contractPath = $root.'/specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml';
/** @var array<string, mixed> $contract */
$contract = Yaml::parseFile($contractPath);
$declarations = $contract['x-derived-state-consumers'] ?? [];
expect($declarations)->toBeArray()->not->toBeEmpty();
$allowedFamilies = [
'artifact_truth',
'operation_ux_guidance',
'operation_ux_explanation',
'related_navigation_primary',
'related_navigation_detail',
'related_navigation_header',
];
$allowedAccessPatterns = ['row_safe', 'page_safe', 'direct_once'];
$allowedFreshnessPolicies = ['request_stable', 'invalidate_after_mutation', 'no_reuse'];
$cachePattern = '/static\s+array\s+\$[A-Za-z0-9_]*cache\b/i';
$violations = [];
foreach ($declarations as $index => $declaration) {
if (! is_array($declaration)) {
$violations[] = [
'surface' => 'contract',
'message' => sprintf('Declaration %d must be an object.', $index),
];
continue;
}
$surface = trim((string) ($declaration['surface'] ?? ''));
$family = trim((string) ($declaration['family'] ?? ''));
$variant = trim((string) ($declaration['variant'] ?? ''));
$accessPattern = trim((string) ($declaration['accessPattern'] ?? ''));
$freshnessPolicy = trim((string) ($declaration['freshnessPolicy'] ?? ''));
$scopeInputs = $declaration['scopeInputs'] ?? null;
$guardScope = $declaration['guardScope'] ?? null;
$requiredMarkers = array_values(array_filter(
$declaration['requiredMarkers'] ?? [],
static fn (mixed $marker): bool => is_string($marker) && trim($marker) !== '',
));
$maxOccurrences = $declaration['maxOccurrences'] ?? [];
if ($surface === '' || $variant === '') {
$violations[] = [
'surface' => $surface !== '' ? $surface : 'contract',
'message' => 'Each declaration must provide non-empty surface and variant values.',
];
}
if (! in_array($family, $allowedFamilies, true)) {
$violations[] = [
'surface' => $surface !== '' ? $surface : 'contract',
'message' => sprintf('Unsupported family "%s".', $family),
];
}
if (! in_array($accessPattern, $allowedAccessPatterns, true)) {
$violations[] = [
'surface' => $surface !== '' ? $surface : 'contract',
'message' => sprintf('Unsupported accessPattern "%s".', $accessPattern),
];
}
if (! in_array($freshnessPolicy, $allowedFreshnessPolicies, true)) {
$violations[] = [
'surface' => $surface !== '' ? $surface : 'contract',
'message' => sprintf('Unsupported freshnessPolicy "%s".', $freshnessPolicy),
];
}
if (! is_array($scopeInputs) || $scopeInputs === []) {
$violations[] = [
'surface' => $surface !== '' ? $surface : 'contract',
'message' => 'Each declaration must include at least one scope input.',
];
}
if (! is_array($guardScope) || $guardScope === []) {
$violations[] = [
'surface' => $surface !== '' ? $surface : 'contract',
'message' => 'Each declaration must include at least one guardScope path.',
];
continue;
}
foreach ($guardScope as $relativePath) {
$relativePath = trim((string) $relativePath);
$absolutePath = $root.'/'.$relativePath;
if ($relativePath === '' || ! is_file($absolutePath)) {
$violations[] = [
'surface' => $surface !== '' ? $surface : 'contract',
'message' => sprintf('Missing guardScope file "%s".', $relativePath),
];
continue;
}
$source = SourceFileScanner::read($absolutePath);
foreach ($requiredMarkers as $marker) {
if (str_contains($source, $marker)) {
continue;
}
$violations[] = [
'surface' => $surface,
'file' => SourceFileScanner::relativePath($absolutePath),
'message' => sprintf('Missing required marker "%s".', $marker),
];
}
if (preg_match($cachePattern, $source, $match, PREG_OFFSET_CAPTURE) === 1) {
$offset = $match[0][1];
$line = substr_count(substr($source, 0, is_int($offset) ? $offset : 0), "\n") + 1;
$violations[] = [
'surface' => $surface,
'file' => SourceFileScanner::relativePath($absolutePath),
'line' => $line,
'message' => 'Ad hoc local cache detected in guarded surface.',
'snippet' => SourceFileScanner::snippet($source, $line),
];
}
if (! is_array($maxOccurrences)) {
continue;
}
foreach ($maxOccurrences as $occurrenceRule) {
if (! is_array($occurrenceRule)) {
continue;
}
$needle = trim((string) ($occurrenceRule['needle'] ?? ''));
$max = (int) ($occurrenceRule['max'] ?? -1);
if ($needle === '' || $max < 0) {
continue;
}
$actual = substr_count($source, $needle);
if ($actual <= $max) {
continue;
}
$offset = strpos($source, $needle);
$line = $offset === false ? 1 : substr_count(substr($source, 0, $offset), "\n") + 1;
$violations[] = [
'surface' => $surface,
'file' => SourceFileScanner::relativePath($absolutePath),
'line' => $line,
'message' => sprintf('Found %d occurrences of "%s"; expected at most %d.', $actual, $needle, $max),
'snippet' => SourceFileScanner::snippet($source, $line),
];
}
}
}
if ($violations !== []) {
$messages = array_map(static function (array $violation): string {
$location = $violation['surface'] ?? 'contract';
if (isset($violation['file'])) {
$location .= ' @ '.$violation['file'];
}
if (isset($violation['line'])) {
$location .= ':'.$violation['line'];
}
$message = $location."\n".$violation['message'];
if (isset($violation['snippet'])) {
$message .= "\n".$violation['snippet'];
}
return $message;
}, $violations);
$this->fail(
"Derived-state consumer guard violations found:\n\n".implode("\n\n", $messages)
);
}
expect($violations)->toBe([]);
});

View File

@ -1,161 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Navigation\CrossResourceNavigationMatrix;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\DerivedStateKey;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('reuses finding related navigation and caches deterministic negative results', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Security Baseline',
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
]);
$run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
]);
$finding = Finding::factory()->for($tenant)->create([
'current_operation_run_id' => (int) $run->getKey(),
'evidence_jsonb' => [
'current' => [
'policy_version_id' => (int) $version->getKey(),
],
'provenance' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'compare_operation_run_id' => (int) $run->getKey(),
],
],
]);
$resolver = app(RelatedNavigationResolver::class);
$first = $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding);
$second = $resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $finding);
expect($first?->targetUrl)->toBe($second?->targetUrl);
$store = app(RequestScopedDerivedStateStore::class);
expect($store->countStored(
DerivedStateFamily::RelatedNavigationPrimary,
Finding::class,
(string) $finding->getKey(),
CrossResourceNavigationMatrix::SOURCE_FINDING,
))->toBe(1);
$orphanedFinding = Finding::factory()->for($tenant)->create([
'evidence_jsonb' => [],
]);
expect($resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $orphanedFinding))->toBeNull()
->and($resolver->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $orphanedFinding))->toBeNull();
$negativeKey = DerivedStateKey::fromModel(
DerivedStateFamily::RelatedNavigationPrimary,
$orphanedFinding,
CrossResourceNavigationMatrix::SOURCE_FINDING,
[
'source_type' => CrossResourceNavigationMatrix::SOURCE_FINDING,
'surface' => CrossResourceNavigationMatrix::SURFACE_LIST_ROW,
'active_tenant_id' => (int) $tenant->getKey(),
'route_name' => null,
'user_id' => (int) $user->getKey(),
],
);
expect($store->resolutionRecord($negativeKey)['negative_result'])->toBeTrue();
});
it('reuses operation-run related context across detail and header consumers', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies',
'context' => [
'backup_set_id' => 123,
],
]);
$resolver = app(RelatedNavigationResolver::class);
$resolver->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $run);
$resolver->operationLinks($run, $tenant);
expect(app(RequestScopedDerivedStateStore::class)->countStored(
DerivedStateFamily::RelatedNavigationDetail,
OperationRun::class,
(string) $run->getKey(),
CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN,
))->toBe(1);
});
it('keeps related-navigation target routes tenant-safe for non-members and capability-limited members', function (): void {
$workspaceTenant = \App\Models\Tenant::factory()->create();
[$member, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'readonly');
$nonMember = \App\Models\User::factory()->create();
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $workspaceTenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $workspaceTenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
Filament::setTenant(null, true);
$this->actingAs($nonMember)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertNotFound();
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')->andReturnFalse();
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
$this->actingAs($member)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertForbidden();
});

View File

@ -1,168 +0,0 @@
<?php
declare(strict_types=1);
use App\Support\Ui\DerivedState\DerivedStateFamily;
use App\Support\Ui\DerivedState\DerivedStateKey;
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
use Illuminate\Database\Eloquent\Model;
it('builds stable key fingerprints for equivalent context payloads', function (): void {
$record = new class extends Model
{
protected $guarded = [];
public $timestamps = false;
};
$record->forceFill([
'id' => 42,
'workspace_id' => 12,
'tenant_id' => 8,
]);
$left = DerivedStateKey::fromModel(
DerivedStateFamily::ArtifactTruth,
$record,
'tenant_review',
[
'user_id' => 7,
'scope' => ['tenant' => 8, 'workspace' => 12],
],
);
$right = DerivedStateKey::fromModel(
DerivedStateFamily::ArtifactTruth,
$record,
'tenant_review',
[
'scope' => ['workspace' => 12, 'tenant' => 8],
'user_id' => 7,
],
);
expect($left->fingerprint())->toBe($right->fingerprint())
->and($left->workspaceId)->toBe(12)
->and($left->tenantId)->toBe(8);
});
it('reuses cached values after the first miss', function (): void {
$store = new RequestScopedDerivedStateStore('request-a');
$key = new DerivedStateKey(DerivedStateFamily::ArtifactTruth, 'App\\Models\\TenantReview', '55', 'tenant_review');
$resolutions = 0;
$first = $store->resolve($key, function () use (&$resolutions): string {
$resolutions++;
return 'derived-result';
});
$second = $store->resolve($key, function () use (&$resolutions): string {
$resolutions++;
return 'unexpected-second-resolution';
});
$record = $store->resolutionRecord($key);
expect($first)->toBe('derived-result')
->and($second)->toBe('derived-result')
->and($resolutions)->toBe(1)
->and($record)->not->toBeNull()
->and($record['negative_result'])->toBeFalse()
->and($record['resolved_at'])->toBe(1);
});
it('reuses deterministic negative results when the family allows it', function (): void {
$store = new RequestScopedDerivedStateStore('request-b');
$key = new DerivedStateKey(DerivedStateFamily::RelatedNavigationPrimary, 'App\\Models\\Finding', '91', 'finding');
$resolutions = 0;
$first = $store->resolve($key, function () use (&$resolutions): ?string {
$resolutions++;
return null;
});
$second = $store->resolve($key, function () use (&$resolutions): string {
$resolutions++;
return 'should-not-run';
});
expect($first)->toBeNull()
->and($second)->toBeNull()
->and($resolutions)->toBe(1)
->and($store->resolutionRecord($key)['negative_result'])->toBeTrue();
});
it('keeps variants isolated for the same family and record', function (): void {
$store = new RequestScopedDerivedStateStore('request-c');
$resolutions = 0;
$tenantReviewKey = new DerivedStateKey(DerivedStateFamily::ArtifactTruth, 'App\\Models\\TenantReview', '101', 'tenant_review');
$reviewPackKey = new DerivedStateKey(DerivedStateFamily::ArtifactTruth, 'App\\Models\\TenantReview', '101', 'review_pack');
$tenantReviewValue = $store->resolve($tenantReviewKey, function () use (&$resolutions): string {
$resolutions++;
return 'tenant-review';
});
$reviewPackValue = $store->resolve($reviewPackKey, function () use (&$resolutions): string {
$resolutions++;
return 'review-pack';
});
expect($tenantReviewValue)->toBe('tenant-review')
->and($reviewPackValue)->toBe('review-pack')
->and($resolutions)->toBe(2)
->and($store->entryCount())->toBe(2);
});
it('invalidates exact keys and whole family slices', function (): void {
$store = new RequestScopedDerivedStateStore('request-d');
$tenantReviewKey = new DerivedStateKey(DerivedStateFamily::ArtifactTruth, 'App\\Models\\TenantReview', '1', 'tenant_review');
$reviewPackKey = new DerivedStateKey(DerivedStateFamily::ArtifactTruth, 'App\\Models\\ReviewPack', '9', 'review_pack');
$navigationKey = new DerivedStateKey(DerivedStateFamily::RelatedNavigationPrimary, 'App\\Models\\Finding', '1', 'finding');
$store->resolve($tenantReviewKey, static fn (): string => 'review');
$store->resolve($reviewPackKey, static fn (): string => 'pack');
$store->resolve($navigationKey, static fn (): string => 'link');
expect($store->invalidateKey($tenantReviewKey))->toBe(1)
->and($store->resolutionRecord($tenantReviewKey))->toBeNull()
->and($store->entryCount())->toBe(2)
->and($store->invalidateFamily(DerivedStateFamily::ArtifactTruth))->toBe(1)
->and($store->entryCount())->toBe(1)
->and($store->countStored(DerivedStateFamily::RelatedNavigationPrimary))->toBe(1);
});
it('supports no-reuse and fresh-resolution paths for mutation-sensitive reads', function (): void {
$store = new RequestScopedDerivedStateStore('request-e');
$key = new DerivedStateKey(DerivedStateFamily::OperationUxGuidance, 'App\\Models\\OperationRun', '44', 'surface_guidance');
$resolutions = 0;
$first = $store->resolve($key, function () use (&$resolutions): string {
$resolutions++;
return 'queued';
}, RequestScopedDerivedStateStore::FRESHNESS_NO_REUSE);
$second = $store->resolve($key, function () use (&$resolutions): string {
$resolutions++;
return 'running';
}, RequestScopedDerivedStateStore::FRESHNESS_NO_REUSE);
$store->resolve($key, static fn (): string => 'stale');
$fresh = $store->resolveFresh($key, static fn (): string => 'fresh');
expect($first)->toBe('queued')
->and($second)->toBe('running')
->and($fresh)->toBe('fresh')
->and($resolutions)->toBe(2)
->and($store->countStored(DerivedStateFamily::OperationUxGuidance, 'App\\Models\\OperationRun', '44', 'surface_guidance'))->toBe(1)
->and($store->invalidations())->toHaveCount(1);
});