Compare commits

...

2 Commits

Author SHA1 Message Date
20b6aa6a32 refactor: reduce operation run detail density (#194)
## Summary
- collapse secondary and diagnostic operation-run sections by default to reduce page density
- visually emphasize the primary next step while keeping counts readable but secondary
- keep failures and other actionable detail available without dominating the default reading path

## Testing
- vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Filament/EnterpriseDetailTemplateRegressionTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php
- vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #194
2026-03-26 13:23:52 +00:00
c17255f854 feat: implement baseline subject resolution semantics (#193)
## Summary
- add the structured subject-resolution foundation for baseline compare and baseline capture, including capability guards, subject descriptors, resolution outcomes, and operator action categories
- persist structured evidence-gap subject records and update compare/capture surfaces, landing projections, and cleanup tooling to use the new contract
- add Spec 163 artifacts and focused Pest coverage for classification, determinism, cleanup, and DB-only rendering

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`

## Notes
- verified locally that a fresh post-restart baseline compare run now writes structured `baseline_compare.evidence_gaps.subjects` records instead of the legacy broad payload shape
- excluded the separate `docs/product/spec-candidates.md` worktree change from this branch commit and PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #193
2026-03-25 12:40:45 +00:00
89 changed files with 7233 additions and 904 deletions

View File

@ -105,6 +105,9 @@ ## Active Technologies
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
- 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` (164-run-detail-hardening)
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -124,8 +127,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 162-baseline-gap-details: Added PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services
- 161-operator-explanation-layer: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 160-operation-lifecycle-guarantees: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages
- 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`
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 163-baseline-subject-resolution: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -1,19 +1,22 @@
<!--
Sync Impact Report
- Version change: 1.11.0 → 1.12.0
- Version change: 1.12.0 → 1.13.0
- Modified principles:
- None
- Added sections:
- Operator Surface Principles (OPSURF-001)
- Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- Removed sections: None
- Templates requiring updates:
- ✅ .specify/memory/constitution.md
- ✅ .specify/templates/spec-template.md
- ✅ .specify/templates/plan-template.md
- ✅ .specify/templates/tasks-template.md
- ✅ docs/product/principles.md
- ✅ docs/product/standards/README.md
- ✅ docs/HANDOVER.md
- Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
- Follow-up TODOs:
- None.
-->
@ -416,6 +419,39 @@ ### Badge Semantics Are Centralized (BADGE-001)
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
Forbidden local replacements
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
Shared primitive before local override
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
Upgrade-safe preference
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
Exception rule
- Ad-hoc markup or styling is allowed only when all of the following are true:
- native Filament components cannot express the required semantics,
- no suitable shared primitive exists,
- and the deviation is justified briefly in code and in the governing spec or PR.
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
Review and enforcement
- Every UI review MUST answer:
- which native Filament element or shared primitive was used,
- why an existing component was insufficient if an exception was taken,
- and whether any ad-hoc status or emphasis styling was introduced.
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
### Incremental UI Standards Enforcement (UI-STD-001)
- UI consistency is enforced incrementally, not by recurring cleanup passes.
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
@ -451,4 +487,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21
**Version**: 1.13.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-26

View File

@ -49,6 +49,7 @@ ## Constitution Check
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status

View File

@ -127,6 +127,12 @@ ## Requirements *(mandatory)*
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
- which native Filament components or shared UI primitives are used,
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
notifications, audit prose, or related helper copy, the spec MUST describe:
- the target object,
@ -147,6 +153,7 @@ ## Requirements *(mandatory)*
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific

View File

@ -53,6 +53,9 @@ # Tasks: [FEATURE NAME]
- grouping bulk actions via BulkActionGroup,
- adding confirmations for destructive actions (and typed confirmation where required by scale),
- adding `AuditLog` entries for relevant mutations,
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),

View File

@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\OperationRun;
use App\Models\Tenant;
use Illuminate\Console\Command;
class PurgeLegacyBaselineGapRuns extends Command
{
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
{--workspace=* : Limit cleanup to workspace ids}
{--limit=500 : Maximum candidate runs to inspect}
{--force : Actually delete matched legacy runs}';
protected $description = 'Purge development-only baseline compare/capture runs that still use legacy broad gap payloads.';
public function handle(): int
{
if (! app()->environment(['local', 'testing'])) {
$this->error('This cleanup command is limited to local and testing environments.');
return self::FAILURE;
}
$types = $this->normalizedTypes();
$workspaceIds = array_values(array_filter(
array_map(
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
(array) $this->option('workspace'),
),
static fn (int $workspaceId): bool => $workspaceId > 0,
));
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
$limit = max(1, (int) $this->option('limit'));
$dryRun = ! (bool) $this->option('force');
$query = OperationRun::query()
->whereIn('type', $types)
->orderBy('id')
->limit($limit);
if ($workspaceIds !== []) {
$query->whereIn('workspace_id', $workspaceIds);
}
if ($tenantIds !== []) {
$query->whereIn('tenant_id', $tenantIds);
}
$candidates = $query->get();
$matched = $candidates
->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload())
->values();
if ($matched->isEmpty()) {
$this->info('No legacy baseline gap runs matched the current filters.');
return self::SUCCESS;
}
$this->table(
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
$matched
->map(fn (OperationRun $run): array => [
'Run' => (string) $run->getKey(),
'Type' => (string) $run->type,
'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—',
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
'Legacy signal' => $this->legacySignal($run),
])
->all(),
);
if ($dryRun) {
$this->warn(sprintf(
'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.',
$matched->count(),
));
return self::SUCCESS;
}
OperationRun::query()
->whereKey($matched->modelKeys())
->delete();
$this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count()));
return self::SUCCESS;
}
/**
* @return array<int, string>
*/
private function normalizedTypes(): array
{
$types = array_values(array_unique(array_filter(
array_map(
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
(array) $this->option('type'),
),
)));
if ($types === []) {
return ['baseline_compare', 'baseline_capture'];
}
return array_values(array_filter(
$types,
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
));
}
/**
* @param array<int, string> $tenantIdentifiers
* @return array<int, int>
*/
private function resolveTenantIds(array $tenantIdentifiers): array
{
if ($tenantIdentifiers === []) {
return [];
}
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()->forTenant($identifier)->first();
if ($tenant instanceof Tenant) {
$tenantIds[] = (int) $tenant->getKey();
}
}
return array_values(array_unique($tenantIds));
}
private function legacySignal(OperationRun $run): string
{
$byReason = $run->baselineGapEnvelope()['by_reason'] ?? null;
$byReason = is_array($byReason) ? $byReason : [];
if (array_key_exists('policy_not_found', $byReason)) {
return 'legacy_reason_code';
}
return 'legacy_subject_shape';
}
}

View File

@ -60,6 +60,8 @@ class BaselineCompareLanding extends Page
public ?int $duplicateNamePoliciesCount = null;
public ?int $duplicateNameSubjectsCount = null;
public ?int $operationRunId = null;
public ?int $findingsCount = null;
@ -136,6 +138,7 @@ public function refreshStats(): void
$this->profileId = $stats->profileId;
$this->snapshotId = $stats->snapshotId;
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
$this->operationRunId = $stats->operationRunId;
$this->findingsCount = $stats->findingsCount;
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;

View File

@ -178,17 +178,15 @@ public function blockedExecutionBanner(): ?array
? array_values(array_filter([
$operatorExplanation->headline,
$operatorExplanation->dominantCauseExplanation,
OperationUxPresenter::surfaceGuidance($this->run),
]))
: ($reasonEnvelope?->toBodyLines() ?? [
: ($reasonEnvelope?->toBodyLines(false) ?? [
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
]);
return [
'tone' => 'amber',
'title' => 'Blocked by prerequisite',
'body' => implode(' ', $lines),
'body' => implode(' ', array_values(array_unique($lines))),
];
}
@ -208,19 +206,17 @@ public function lifecycleBanner(): ?array
}
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
$guidance = OperationUxPresenter::surfaceGuidance($this->run);
$body = $guidance !== null ? $detail.' '.$guidance : $detail;
return match ($this->run->freshnessState()->value) {
'likely_stale' => [
'tone' => 'amber',
'title' => 'Likely stale run',
'body' => $body,
'body' => $detail,
],
'reconciled_failed' => [
'tone' => 'rose',
'title' => 'Automatically reconciled',
'body' => $body,
'body' => $detail,
],
default => null,
};

View File

@ -371,9 +371,9 @@ private static function applyLifecycleFilter(Builder $query, mixed $value): Buil
private static function gapCountExpression(Builder $query): string
{
return match ($query->getConnection()->getDriverName()) {
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.throttled') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.capture_failed') AS INTEGER), 0) + COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS INTEGER), 0)",
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_evidence}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,missing_role_definition_version_reference}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,invalid_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,duplicate_subject}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,policy_not_found}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,throttled}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,capture_failed}')::int, 0) + COALESCE((summary_jsonb #>> '{gaps,by_reason,budget_exhausted}')::int, 0)",
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_evidence') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.missing_role_definition_version_reference') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.invalid_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.duplicate_subject') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.policy_not_found') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.throttled') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.capture_failed') AS UNSIGNED), 0) + COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.budget_exhausted') AS UNSIGNED), 0)",
'sqlite' => "MAX(0, COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0) - COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS INTEGER), 0))",
'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 0))",
default => "GREATEST(0, COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS SIGNED), 0) - COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS SIGNED), 0))",
};
}

View File

@ -33,7 +33,9 @@
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\Ui\OperatorExplanation\OperatorExplanationPattern;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
@ -257,7 +259,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
$targetScope = static::targetScopeDisplay($record);
$targetScope = static::targetScopeDisplay($record) ?? 'No target scope details were recorded for this run.';
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
$referencedTenantLifecycle = $record->tenant instanceof Tenant
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
@ -266,14 +268,14 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
: null;
$operatorExplanation = $artifactTruth?->operatorExplanation;
$artifactTruthBadge = $artifactTruth !== null
? $factory->statusBadge(
$artifactTruth->primaryBadgeSpec()->label,
$artifactTruth->primaryBadgeSpec()->color,
$artifactTruth->primaryBadgeSpec()->icon,
$artifactTruth->primaryBadgeSpec()->iconColor,
)
: null;
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$supportingGroups = static::supportingGroups(
record: $record,
factory: $factory,
referencedTenantLifecycle: $referencedTenantLifecycle,
operatorExplanation: $operatorExplanation,
primaryNextStep: $primaryNextStep,
);
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
@ -284,34 +286,59 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
],
keyFacts: [
$factory->keyFact('Target', $targetScope ?? 'No target scope details were recorded for this run.'),
$factory->keyFact('Initiator', $record->initiator_name),
$factory->keyFact('Target', $targetScope),
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
$factory->keyFact('Expected', RunDurationInsights::expectedHuman($record)),
],
descriptionHint: 'Run identity, outcome, scope, and related next steps stay ahead of stored payloads and diagnostic fragments.',
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
))
->addSection(
$factory->factsSection(
id: 'run_summary',
kind: 'core_details',
title: 'Run summary',
items: [
$factory->keyFact('Operation', OperationCatalog::label((string) $record->type)),
$factory->keyFact('Initiator', $record->initiator_name),
$factory->keyFact('Target scope', $targetScope ?? 'No target scope details were recorded for this run.'),
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
],
->decisionZone($factory->decisionZone(
facts: array_values(array_filter([
$factory->keyFact(
'Execution state',
$statusSpec->label,
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
),
$factory->viewSection(
id: 'artifact_truth',
kind: 'current_status',
title: 'Artifact truth',
view: 'filament.infolists.entries.governance-artifact-truth',
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
visible: $artifactTruth !== null,
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
$factory->keyFact(
'Outcome',
$outcomeSpec->label,
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
),
static::artifactTruthFact($factory, $artifactTruth),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result meaning',
$operatorExplanation->evaluationResultLabel(),
$operatorExplanation->headline,
)
: null,
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$artifactTruth?->primaryExplanation,
),
)
: null,
])),
primaryNextStep: $factory->primaryNextStep(
$primaryNextStep['text'],
$primaryNextStep['source'],
$primaryNextStep['secondaryGuidance'],
),
description: 'Start here to see how the run ended, whether the result is trustworthy enough to use, and the one primary next step.',
compactCounts: $summaryLine !== null
? $factory->countPresentation(summaryLine: $summaryLine)
: null,
attentionNote: static::decisionAttentionNote($record),
));
if ($supportingGroups !== []) {
$builder->addSupportingGroup(...$supportingGroups);
}
$builder->addSection(
$factory->viewSection(
id: 'related_context',
kind: 'related_context',
@ -321,23 +348,216 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
emptyState: $factory->emptyState('No related context is available for this record.'),
),
)
->addSupportingCard(
$factory->supportingFactsCard(
kind: 'status',
title: 'Current state',
items: array_values(array_filter([
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
$artifactTruth !== null
? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
$factory->viewSection(
id: 'artifact_truth',
kind: 'supporting_detail',
title: 'Artifact truth details',
view: 'filament.infolists.entries.governance-artifact-truth',
viewData: [
'artifactTruthState' => $artifactTruth?->toArray(),
'surface' => 'expanded',
],
visible: $artifactTruth !== null,
description: 'Detailed artifact-truth context explains evidence quality and caveats without repeating the top decision summary.',
collapsible: true,
collapsed: true,
),
);
$counts = static::summaryCountFacts($record, $factory);
if ($counts !== []) {
$builder->addTechnicalSection(
$factory->technicalDetail(
title: 'Count diagnostics',
entries: $counts,
description: 'Normalized run counters remain available for deeper inspection without competing with the primary decision.',
collapsible: true,
collapsed: true,
variant: 'diagnostic',
),
);
}
if (! empty($record->failure_summary)) {
$builder->addTechnicalSection(
$factory->technicalDetail(
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
description: 'Detailed failure evidence stays available for investigation after the decision and supporting context.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $record->failure_summary ?? []],
collapsible: true,
collapsed: false,
),
);
}
if (static::reconciliationPayload($record) !== []) {
$builder->addTechnicalSection(
$factory->technicalDetail(
title: 'Lifecycle reconciliation',
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::reconciliationPayload($record)],
collapsible: true,
collapsed: true,
),
);
}
if ((string) $record->type === 'baseline_compare') {
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
if ($baselineCompareFacts !== []) {
$builder->addSection(
$factory->factsSection(
id: 'baseline_compare',
kind: 'type_specific_detail',
title: 'Baseline compare',
items: $baselineCompareFacts,
description: 'Type-specific comparison detail stays below the canonical decision and supporting layers.',
collapsible: true,
collapsed: true,
),
);
}
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_gap_details',
kind: 'type_specific_detail',
title: 'Evidence gap details',
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
view: 'filament.infolists.entries.evidence-gap-subjects',
viewData: [
'summary' => $gapSummary,
'buckets' => $gapBuckets,
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
],
collapsible: true,
collapsed: true,
),
);
}
if ($baselineCompareEvidence !== []) {
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_evidence',
kind: 'type_specific_detail',
title: 'Baseline compare evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCompareEvidence],
collapsible: true,
collapsed: true,
),
);
}
}
if ((string) $record->type === 'baseline_capture') {
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
if ($baselineCaptureEvidence !== []) {
$builder->addSection(
$factory->viewSection(
id: 'baseline_capture_evidence',
kind: 'type_specific_detail',
title: 'Baseline capture evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCaptureEvidence],
collapsible: true,
collapsed: true,
),
);
}
}
if (VerificationReportViewer::shouldRenderForRun($record)) {
$builder->addSection(
$factory->viewSection(
id: 'verification_report',
kind: 'type_specific_detail',
title: 'Verification report',
view: 'filament.components.verification-report-viewer',
viewData: static::verificationReportViewData($record),
),
);
}
$builder->addTechnicalSection(
$factory->technicalDetail(
title: 'Context',
entries: [
$factory->keyFact('Identity hash', $record->run_identity_hash, mono: true),
$factory->keyFact('Workspace scope', $record->workspace_id),
$factory->keyFact('Tenant scope', $record->tenant_id),
],
description: 'Stored run context stays available for debugging without dominating the default reading path.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::contextPayload($record)],
),
);
return $builder->build();
}
/**
* @return list<\App\Support\Ui\EnterpriseDetail\SupportingCardData>
*/
private static function supportingGroups(
OperationRun $record,
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
?OperatorExplanationPattern $operatorExplanation,
array $primaryNextStep,
): array {
$groups = [];
$hasElevatedLifecycleState = OperationUxPresenter::lifecycleAttentionSummary($record) !== null;
$guidanceItems = array_values(array_filter([
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null,
$operatorExplanation !== null
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->diagnosticsSummary !== null
? $factory->keyFact('Diagnostics summary', $operatorExplanation->diagnosticsSummary)
: null,
$operatorExplanation !== null
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
...array_map(
static fn (array $guidance): array => $factory->keyFact($guidance['label'], $guidance['text']),
array_values(array_filter(
$primaryNextStep['secondaryGuidance'] ?? [],
static fn (mixed $guidance): bool => is_array($guidance),
)),
),
static::blockedExecutionReasonCode($record) !== null
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
: null,
static::blockedExecutionDetail($record) !== null
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
: null,
static::blockedExecutionSource($record) !== null
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
: null,
RunDurationInsights::stuckGuidance($record) !== null
? $factory->keyFact('Queue guidance', RunDurationInsights::stuckGuidance($record))
: null,
]));
if ($guidanceItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'guidance',
title: 'Guidance',
items: $guidanceItems,
description: 'Secondary guidance explains caveats and context without competing with the primary next step.',
);
}
$lifecycleItems = array_values(array_filter([
$referencedTenantLifecycle !== null
? $factory->keyFact(
'Tenant lifecycle',
@ -356,10 +576,10 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: null,
static::freshnessLabel($record) !== null
! $hasElevatedLifecycleState && static::freshnessLabel($record) !== null
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
: null,
static::reconciliationHeadline($record) !== null
! $hasElevatedLifecycleState && static::reconciliationHeadline($record) !== null
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
: null,
static::reconciledAtLabel($record) !== null
@ -368,172 +588,221 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
static::reconciliationSourceLabel($record) !== null
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
: null,
$operatorExplanation !== null
? $factory->keyFact('Artifact next step', $operatorExplanation->nextActionText)
: ($artifactTruth !== null
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
: null),
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null,
OperationUxPresenter::surfaceGuidance($record) !== null
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
: null,
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
static::blockedExecutionReasonCode($record) !== null
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
: null,
static::blockedExecutionDetail($record) !== null
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
: null,
static::blockedExecutionSource($record) !== null
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
: null,
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
])),
),
$factory->supportingFactsCard(
kind: 'timestamps',
title: 'Timing',
items: [
]));
if ($lifecycleItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'lifecycle',
title: 'Lifecycle',
items: $lifecycleItems,
description: 'Lifecycle context explains freshness, reconciliation, and tenant-scoped caveats.',
);
}
$timingItems = [
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
];
$groups[] = $factory->supportingFactsCard(
kind: 'timing',
title: 'Timing',
items: $timingItems,
);
$metadataItems = array_values(array_filter([
$factory->keyFact('Initiator', $record->initiator_name),
RunDurationInsights::expectedHuman($record) !== null
? $factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record))
: null,
]));
if ($metadataItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'metadata',
title: 'Metadata',
items: $metadataItems,
description: 'Secondary metadata remains visible without crowding the top decision surface.',
);
}
return $groups;
}
/**
* @return array{
* text: string,
* source: string,
* secondaryGuidance: list<array{label: string, text: string, source: string}>
* }
*/
private static function resolvePrimaryNextStep(
OperationRun $record,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
): array {
$candidates = [];
static::pushNextStepCandidate($candidates, $operatorExplanation?->nextActionText, 'operator_explanation');
static::pushNextStepCandidate($candidates, $artifactTruth?->nextStepText(), 'artifact_truth');
$opsUxSource = match (true) {
(string) $record->outcome === OperationRunOutcome::Blocked->value => 'blocked_reason',
OperationUxPresenter::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
default => 'ops_ux',
};
static::pushNextStepCandidate($candidates, OperationUxPresenter::surfaceGuidance($record), $opsUxSource);
if ($candidates === []) {
return [
'text' => 'No action needed.',
'source' => 'none_required',
'secondaryGuidance' => [],
];
}
$primary = $candidates[0];
$primarySource = static::normalizeGuidance($primary['text']) === 'no action needed'
? 'none_required'
: $primary['source'];
$secondaryGuidance = array_map(
static fn (array $candidate): array => [
'label' => static::guidanceLabel($candidate['source']),
'text' => $candidate['text'],
'source' => $candidate['source'],
],
),
)
->addTechnicalSection(
$factory->technicalDetail(
title: 'Context',
entries: [
$factory->keyFact('Identity hash', $record->run_identity_hash),
$factory->keyFact('Workspace scope', $record->workspace_id),
$factory->keyFact('Tenant scope', $record->tenant_id),
],
description: 'Stored run context stays available for debugging without dominating the default reading path.',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::contextPayload($record)],
),
array_slice($candidates, 1),
);
$counts = static::summaryCountFacts($record, $factory);
return [
'text' => $primary['text'],
'source' => $primarySource,
'secondaryGuidance' => $secondaryGuidance,
];
}
if ($counts !== []) {
$builder->addSection(
$factory->factsSection(
id: 'counts',
kind: 'current_status',
title: 'Counts',
items: $counts,
),
/**
* @param array<int, array{text: string, source: string, normalized: string}> $candidates
*/
private static function pushNextStepCandidate(array &$candidates, ?string $text, string $source): void
{
$formattedText = static::formatGuidanceText($text);
if ($formattedText === null) {
return;
}
$normalized = static::normalizeGuidance($formattedText);
foreach ($candidates as $candidate) {
if (($candidate['normalized'] ?? null) === $normalized) {
return;
}
}
$candidates[] = [
'text' => $formattedText,
'source' => $source,
'normalized' => $normalized,
];
}
private static function formatGuidanceText(?string $text): ?string
{
if (! is_string($text)) {
return null;
}
$text = trim($text);
if ($text === '') {
return null;
}
if (preg_match('/[.!?]$/', $text) === 1) {
return $text;
}
return $text.'.';
}
private static function normalizeGuidance(string $text): string
{
$normalized = mb_strtolower(trim($text));
$normalized = preg_replace('/^next step:\s*/', '', $normalized) ?? $normalized;
return trim($normalized, " \t\n\r\0\x0B.!?");
}
private static function guidanceLabel(string $source): string
{
return match ($source) {
'operator_explanation' => 'Operator guidance',
'artifact_truth' => 'Artifact guidance',
'blocked_reason' => 'Blocked prerequisite',
'lifecycle_attention' => 'Lifecycle guidance',
default => 'General guidance',
};
}
/**
* @return array<string, mixed>|null
*/
private static function artifactTruthFact(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ArtifactTruthEnvelope $artifactTruth,
): ?array {
if (! $artifactTruth instanceof ArtifactTruthEnvelope) {
return null;
}
$badge = $artifactTruth->primaryBadgeSpec();
return $factory->keyFact(
'Artifact truth',
$artifactTruth->primaryLabel,
$artifactTruth->primaryExplanation,
$factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor),
);
}
if (! empty($record->failure_summary)) {
$builder->addSection(
$factory->viewSection(
id: 'failures',
kind: 'operational_context',
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $record->failure_summary ?? []],
),
);
private static function decisionAttentionNote(OperationRun $record): ?string
{
return null;
}
if (static::reconciliationPayload($record) !== []) {
$builder->addSection(
$factory->viewSection(
id: 'reconciliation',
kind: 'operational_context',
title: 'Lifecycle reconciliation',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => static::reconciliationPayload($record)],
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
),
);
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
{
$normalizedHint = static::normalizeDetailText($hint);
if ($normalizedHint === null) {
return null;
}
if ((string) $record->type === 'baseline_compare') {
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
if ($baselineCompareFacts !== []) {
$builder->addSection(
$factory->factsSection(
id: 'baseline_compare',
kind: 'operational_context',
title: 'Baseline compare',
items: $baselineCompareFacts,
),
);
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) {
return null;
}
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_gap_details',
kind: 'operational_context',
title: 'Evidence gap details',
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
view: 'filament.infolists.entries.evidence-gap-subjects',
viewData: [
'summary' => $gapSummary,
'buckets' => $gapBuckets,
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
],
collapsible: true,
collapsed: false,
),
);
return trim($hint ?? '');
}
if ($baselineCompareEvidence !== []) {
$builder->addSection(
$factory->viewSection(
id: 'baseline_compare_evidence',
kind: 'operational_context',
title: 'Baseline compare evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCompareEvidence],
),
);
}
private static function normalizeDetailText(?string $value): ?string
{
if (! is_string($value)) {
return null;
}
if ((string) $record->type === 'baseline_capture') {
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
if ($baselineCaptureEvidence !== []) {
$builder->addSection(
$factory->viewSection(
id: 'baseline_capture_evidence',
kind: 'operational_context',
title: 'Baseline capture evidence',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $baselineCaptureEvidence],
),
);
}
if ($normalized === '') {
return null;
}
if (VerificationReportViewer::shouldRenderForRun($record)) {
$builder->addSection(
$factory->viewSection(
id: 'verification_report',
kind: 'operational_context',
title: 'Verification report',
view: 'filament.components.verification-report-viewer',
viewData: static::verificationReportViewData($record),
),
);
}
return $builder->build();
return mb_strtolower($normalized);
}
/**
@ -546,12 +815,29 @@ private static function summaryCountFacts(
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
return array_map(
static fn (string $key, int $value): array => $factory->keyFact(SummaryCountsNormalizer::label($key), $value),
static fn (string $key, int $value): array => $factory->keyFact(
SummaryCountsNormalizer::label($key),
$value,
tone: self::countTone($key, $value),
),
array_keys($counts),
array_values($counts),
);
}
private static function countTone(string $key, int $value): ?string
{
if (in_array($key, ['failed', 'errors_recorded', 'findings_reopened'], true)) {
return $value > 0 ? 'danger' : 'success';
}
if ($key === 'succeeded' && $value > 0) {
return 'success';
}
return null;
}
private static function blockedExecutionReasonCode(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
@ -653,13 +939,26 @@ private static function baselineCompareFacts(
$facts[] = $factory->keyFact(
'Evidence gap detail',
match ($gapSummary['detail_state'] ?? 'no_gaps') {
'details_recorded' => 'Recorded subjects available',
'structured_details_recorded' => 'Structured subject details available',
'details_not_recorded' => 'Detailed rows were not recorded',
'legacy_broad_reason' => 'Legacy development payload should be regenerated',
default => 'No evidence gaps recorded',
},
);
}
if ((int) ($gapSummary['structural_count'] ?? 0) > 0) {
$facts[] = $factory->keyFact('Structural gaps', (string) (int) $gapSummary['structural_count']);
}
if ((int) ($gapSummary['operational_count'] ?? 0) > 0) {
$facts[] = $factory->keyFact('Operational gaps', (string) (int) $gapSummary['operational_count']);
}
if ((int) ($gapSummary['transient_count'] ?? 0) > 0) {
$facts[] = $factory->keyFact('Transient gaps', (string) (int) $gapSummary['transient_count']);
}
if ($uncoveredTypes !== []) {
sort($uncoveredTypes, SORT_STRING);
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));

View File

@ -108,6 +108,7 @@ public function handle(
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$truthfulTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode
@ -127,6 +128,7 @@ public function handle(
scope: $effectiveScope,
identity: $identity,
latestInventorySyncRunId: $latestInventorySyncRunId,
policyTypes: $truthfulTypes,
);
$subjects = $inventoryResult['subjects'];
@ -262,6 +264,9 @@ public function handle(
'gaps' => [
'count' => $gapsCount,
'by_reason' => $gapsByReason,
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
? array_values($phaseResult['gap_subjects'])
: null,
],
'resume_token' => $resumeToken,
],
@ -296,7 +301,7 @@ public function handle(
/**
* @return array{
* subjects_total: int,
* subjects: list<array{policy_type: string, subject_external_id: string}>,
* subjects: list<array{policy_type: string, subject_external_id: string, subject_key: string}>,
* inventory_by_key: array<string, array{
* tenant_subject_external_id: string,
* workspace_subject_external_id: string,
@ -317,6 +322,7 @@ private function collectInventorySubjects(
BaselineScope $scope,
BaselineSnapshotIdentity $identity,
?int $latestInventorySyncRunId = null,
?array $policyTypes = null,
): array {
$query = InventoryItem::query()
->where('tenant_id', $sourceTenant->getKey());
@ -325,7 +331,7 @@ private function collectInventorySubjects(
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
}
$query->whereIn('policy_type', $scope->allTypes());
$query->whereIn('policy_type', is_array($policyTypes) && $policyTypes !== [] ? $policyTypes : $scope->allTypes());
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
$inventoryByKey = [];
@ -413,6 +419,7 @@ private function collectInventorySubjects(
static fn (array $item): array => [
'policy_type' => (string) $item['policy_type'],
'subject_external_id' => (string) $item['tenant_subject_external_id'],
'subject_key' => (string) $item['subject_key'],
],
$inventoryByKey,
));
@ -425,6 +432,27 @@ private function collectInventorySubjects(
];
}
/**
* @param array<string, mixed> $context
* @return list<string>
*/
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
{
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
if (is_array($truthfulTypes)) {
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
if ($truthfulTypes !== []) {
sort($truthfulTypes, SORT_STRING);
return $truthfulTypes;
}
}
return $effectiveScope->allTypes();
}
/**
* @param array<string, array{
* tenant_subject_external_id: string,

View File

@ -43,6 +43,7 @@
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Baselines\SubjectResolver;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
@ -144,7 +145,7 @@ public function handle(
: null;
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
$effectiveTypes = $effectiveScope->allTypes();
$effectiveTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
@ -363,6 +364,7 @@ public function handle(
static fn (array $item): array => [
'policy_type' => (string) $item['policy_type'],
'subject_external_id' => (string) $item['subject_external_id'],
'subject_key' => (string) $item['subject_key'],
],
$currentItems,
));
@ -1111,6 +1113,27 @@ private function snapshotBlockedMessage(string $reasonCode): string
};
}
/**
* @param array<string, mixed> $context
* @return list<string>
*/
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
{
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
if (is_array($truthfulTypes)) {
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
if ($truthfulTypes !== []) {
sort($truthfulTypes, SORT_STRING);
return $truthfulTypes;
}
}
return $effectiveScope->allTypes();
}
/**
* Compare baseline items vs current inventory and produce drift results.
*
@ -1961,40 +1984,159 @@ private function mergeGapCounts(array ...$gaps): array
/**
* @param list<string> $ambiguousKeys
* @param array<string, list<string>> $phaseGapSubjects
* @param array<string, list<string>> $driftGapSubjects
* @return array<string, list<string>>
* @return list<array<string, mixed>>
*/
private function collectGapSubjects(array $ambiguousKeys, array $phaseGapSubjects, array $driftGapSubjects): array
private function collectGapSubjects(array $ambiguousKeys, mixed $phaseGapSubjects, mixed $driftGapSubjects): array
{
$subjects = [];
$seen = [];
if ($ambiguousKeys !== []) {
$subjects['ambiguous_match'] = array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT);
}
foreach ([$phaseGapSubjects, $driftGapSubjects] as $subjectMap) {
foreach ($subjectMap as $reason => $keys) {
if (! is_string($reason) || ! is_array($keys) || $keys === []) {
foreach (array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT) as $ambiguousKey) {
if (! is_string($ambiguousKey) || $ambiguousKey === '') {
continue;
}
$subjects[$reason] = array_slice(
array_values(array_unique([
...($subjects[$reason] ?? []),
...array_values(array_filter($keys, 'is_string')),
])),
0,
self::GAP_SUBJECTS_LIMIT,
);
[$policyType, $subjectKey] = $this->splitGapSubjectKey($ambiguousKey);
if ($policyType === null || $subjectKey === null) {
continue;
}
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
$record = array_merge($descriptor->toArray(), $this->subjectResolver()->ambiguousMatch($descriptor)->toArray());
$fingerprint = md5(json_encode([$record['policy_type'], $record['subject_key'], $record['reason_code']]));
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
$subjects[] = $record;
}
}
ksort($subjects);
foreach ($this->normalizeStructuredGapSubjects($phaseGapSubjects) as $record) {
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
$subjects[] = $record;
}
foreach ($this->normalizeLegacyGapSubjects($driftGapSubjects) as $record) {
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
$subjects[] = $record;
}
return array_slice($subjects, 0, self::GAP_SUBJECTS_LIMIT);
}
/**
* @return list<array<string, mixed>>
*/
private function normalizeStructuredGapSubjects(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$subjects = [];
foreach ($value as $record) {
if (! is_array($record)) {
continue;
}
if (! is_string($record['policy_type'] ?? null) || ! is_string($record['subject_key'] ?? null) || ! is_string($record['reason_code'] ?? null)) {
continue;
}
$subjects[] = $record;
}
return $subjects;
}
/**
* @return list<array<string, mixed>>
*/
private function normalizeLegacyGapSubjects(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$subjects = [];
foreach ($value as $reasonCode => $keys) {
if (! is_string($reasonCode) || ! is_array($keys)) {
continue;
}
foreach ($keys as $key) {
if (! is_string($key) || $key === '') {
continue;
}
[$policyType, $subjectKey] = $this->splitGapSubjectKey($key);
if ($policyType === null || $subjectKey === null) {
continue;
}
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
$outcome = match ($reasonCode) {
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
default => $this->subjectResolver()->captureFailed($descriptor),
};
$record = array_merge($descriptor->toArray(), $outcome->toArray());
$record['reason_code'] = $reasonCode;
$subjects[] = $record;
}
}
return $subjects;
}
/**
* @return array{0: ?string, 1: ?string}
*/
private function splitGapSubjectKey(string $value): array
{
$parts = explode('|', $value, 2);
if (count($parts) !== 2) {
return [null, null];
}
[$policyType, $subjectKey] = $parts;
$policyType = trim($policyType);
$subjectKey = trim($subjectKey);
if ($policyType === '' || $subjectKey === '') {
return [null, null];
}
return [$policyType, $subjectKey];
}
private function subjectResolver(): SubjectResolver
{
return app(SubjectResolver::class);
}
/**
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence

View File

@ -13,6 +13,7 @@
use Filament\Tables\Table;
use Filament\Tables\TableComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@ -20,14 +21,7 @@
class BaselineCompareEvidenceGapTable extends TableComponent
{
/**
* @var list<array{
* __id: string,
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* @var list<array<string, mixed>>
*/
public array $gapRows = [];
@ -84,6 +78,12 @@ public function table(Table $table): Table
SelectFilter::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type'))
->options(BaselineCompareEvidenceGapDetails::policyTypeFilterOptions($this->gapRows)),
SelectFilter::make('subject_class')
->label(__('baseline-compare.evidence_gap_subject_class'))
->options(BaselineCompareEvidenceGapDetails::subjectClassFilterOptions($this->gapRows)),
SelectFilter::make('operator_action_category')
->label(__('baseline-compare.evidence_gap_next_action'))
->options(BaselineCompareEvidenceGapDetails::actionCategoryFilterOptions($this->gapRows)),
])
->striped()
->deferLoading(! app()->runningUnitTests())
@ -92,7 +92,8 @@ public function table(Table $table): Table
->label(__('baseline-compare.evidence_gap_reason'))
->searchable()
->sortable()
->wrap(),
->wrap()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('policy_type')
->label(__('baseline-compare.evidence_gap_policy_type'))
->badge()
@ -103,12 +104,27 @@ public function table(Table $table): Table
->searchable()
->sortable()
->wrap(),
TextColumn::make('subject_class_label')
->label(__('baseline-compare.evidence_gap_subject_class'))
->badge()
->searchable()
->sortable()
->wrap(),
TextColumn::make('resolution_outcome_label')
->label(__('baseline-compare.evidence_gap_outcome'))
->searchable()
->sortable()
->wrap(),
TextColumn::make('operator_action_category_label')
->label(__('baseline-compare.evidence_gap_next_action'))
->searchable()
->sortable()
->wrap(),
TextColumn::make('subject_key')
->label(__('baseline-compare.evidence_gap_subject_key'))
->searchable()
->sortable()
->wrap()
->extraAttributes(['class' => 'font-mono text-xs']),
->wrap(),
])
->actions([])
->bulkActions([])
@ -131,6 +147,8 @@ private function filterRows(Collection $rows, ?string $search, array $filters):
$normalizedSearch = Str::lower(trim((string) $search));
$reasonCode = $filters['reason_code']['value'] ?? null;
$policyType = $filters['policy_type']['value'] ?? null;
$subjectClass = $filters['subject_class']['value'] ?? null;
$operatorActionCategory = $filters['operator_action_category']['value'] ?? null;
return $rows
->when(
@ -149,6 +167,14 @@ function (Collection $rows) use ($normalizedSearch): Collection {
filled($policyType),
fn (Collection $rows): Collection => $rows->where('policy_type', (string) $policyType)
)
->when(
filled($subjectClass),
fn (Collection $rows): Collection => $rows->where('subject_class', (string) $subjectClass)
)
->when(
filled($operatorActionCategory),
fn (Collection $rows): Collection => $rows->where('operator_action_category', (string) $operatorActionCategory)
)
->values();
}
@ -179,7 +205,12 @@ private function paginateRows(Collection $rows, int $page, int $recordsPerPage):
$perPage = max(1, $recordsPerPage);
$currentPage = max(1, $page);
$total = $rows->count();
$items = $rows->forPage($currentPage, $perPage)->values();
$items = $rows->forPage($currentPage, $perPage)
->values()
->map(fn (array $row, int $index): Model => $this->toTableRecord(
row: $row,
index: (($currentPage - 1) * $perPage) + $index,
));
return new LengthAwarePaginator(
$items,
@ -188,4 +219,36 @@ private function paginateRows(Collection $rows, int $page, int $recordsPerPage):
$currentPage,
);
}
/**
* @param array<string, mixed> $row
*/
private function toTableRecord(array $row, int $index): Model
{
$record = new class extends Model
{
public $timestamps = false;
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $table = 'baseline_compare_evidence_gap_rows';
};
$record->forceFill([
'id' => implode(':', array_filter([
(string) ($row['reason_code'] ?? 'reason'),
(string) ($row['policy_type'] ?? 'policy'),
(string) ($row['subject_key'] ?? 'subject'),
(string) $index,
])),
...$row,
]);
$record->exists = true;
return $record;
}
}

View File

@ -193,4 +193,81 @@ public function freshnessState(): OperationRunFreshnessState
{
return OperationRunFreshnessState::forRun($this);
}
/**
* @return array<string, mixed>
*/
public function baselineGapEnvelope(): array
{
$context = is_array($this->context) ? $this->context : [];
return match ((string) $this->type) {
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
? data_get($context, 'baseline_compare.evidence_gaps')
: [],
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
? data_get($context, 'baseline_capture.gaps')
: [],
default => [],
};
}
public function hasStructuredBaselineGapPayload(): bool
{
$subjects = $this->baselineGapEnvelope()['subjects'] ?? null;
if (! is_array($subjects) || ! array_is_list($subjects) || $subjects === []) {
return false;
}
foreach ($subjects as $subject) {
if (! is_array($subject)) {
return false;
}
foreach ([
'policy_type',
'subject_key',
'subject_class',
'resolution_path',
'resolution_outcome',
'reason_code',
'operator_action_category',
'structural',
'retryable',
] as $key) {
if (! array_key_exists($key, $subject)) {
return false;
}
}
}
return true;
}
public function hasLegacyBaselineGapPayload(): bool
{
$envelope = $this->baselineGapEnvelope();
$byReason = is_array($envelope['by_reason'] ?? null) ? $envelope['by_reason'] : [];
if (array_key_exists('policy_not_found', $byReason)) {
return true;
}
$subjects = $envelope['subjects'] ?? null;
if (! is_array($subjects)) {
return false;
}
if (! array_is_list($subjects)) {
return $subjects !== [];
}
if ($subjects === []) {
return false;
}
return ! $this->hasStructuredBaselineGapPayload();
}
}

View File

@ -15,6 +15,7 @@
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\OperationRunType;
final class BaselineCaptureService
@ -22,6 +23,7 @@ final class BaselineCaptureService
public function __construct(
private readonly OperationRunService $runs,
private readonly BaselineFullContentRolloutGate $rolloutGate,
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
) {}
/**
@ -53,7 +55,7 @@ public function startCapture(
],
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $sourceTenant->getKey(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
'capture_mode' => $captureMode->value,
];

View File

@ -17,6 +17,7 @@
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter;
@ -26,6 +27,7 @@ public function __construct(
private readonly OperationRunService $runs,
private readonly BaselineFullContentRolloutGate $rolloutGate,
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
) {}
/**
@ -101,7 +103,7 @@ public function startCompare(
],
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => $snapshotId,
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
'capture_mode' => $captureMode->value,
];

View File

@ -10,23 +10,28 @@
use App\Services\Intune\PolicyCaptureOrchestrator;
use App\Support\Baselines\BaselineEvidenceResumeToken;
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\Baselines\ResolutionOutcomeRecord;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectDescriptor;
use App\Support\Baselines\SubjectResolver;
use Throwable;
final class BaselineContentCapturePhase
{
public function __construct(
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
private readonly ?SubjectResolver $subjectResolver = null,
) {}
/**
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
*
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
* @param list<array{policy_type: string, subject_external_id: string, subject_key?: string}> $subjects
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
* @return array{
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
* gaps: array<string, int>,
* gap_subjects: array<string, list<string>>,
* gap_subjects: list<array<string, mixed>>,
* resume_token: ?string,
* captured_versions: array<string, array{
* policy_type: string,
@ -77,7 +82,7 @@ public function capture(
/** @var array<string, int> $gaps */
$gaps = [];
/** @var array<string, list<string>> $gapSubjects */
/** @var list<array<string, mixed>> $gapSubjects */
$gapSubjects = [];
$capturedVersions = [];
@ -90,26 +95,40 @@ public function capture(
foreach ($chunk as $subject) {
$policyType = trim((string) ($subject['policy_type'] ?? ''));
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
$subjectKey = trim((string) ($subject['subject_key'] ?? ''));
$descriptor = $this->resolver()->describeForCapture(
$policyType !== '' ? $policyType : 'unknown',
$externalId !== '' ? $externalId : null,
$subjectKey !== '' ? $subjectKey : null,
);
if ($policyType === '' || $externalId === '') {
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
$gapSubjects['invalid_subject'][] = ($policyType !== '' ? $policyType : 'unknown').'|'.($externalId !== '' ? $externalId : 'unknown');
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor));
$stats['failed']++;
continue;
}
$subjectKey = $policyType.'|'.$externalId;
$captureKey = $policyType.'|'.$externalId;
if (isset($seen[$subjectKey])) {
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
$gapSubjects['duplicate_subject'][] = $subjectKey;
if (isset($seen[$captureKey])) {
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor));
$stats['skipped']++;
continue;
}
$seen[$subjectKey] = true;
$seen[$captureKey] = true;
if (
$descriptor->resolutionPath === ResolutionPath::FoundationInventory
|| $descriptor->resolutionPath === ResolutionPath::Inventory
) {
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->structuralInventoryOnly($descriptor));
$stats['skipped']++;
continue;
}
$policy = Policy::query()
->where('tenant_id', (int) $tenant->getKey())
@ -118,8 +137,7 @@ public function capture(
->first();
if (! $policy instanceof Policy) {
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
$gapSubjects['policy_not_found'][] = $subjectKey;
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->missingExpectedRecord($descriptor));
$stats['failed']++;
continue;
@ -158,7 +176,7 @@ public function capture(
$version = $result['version'] ?? null;
if ($version instanceof PolicyVersion) {
$capturedVersions[$subjectKey] = [
$capturedVersions[$captureKey] = [
'policy_type' => $policyType,
'subject_external_id' => $externalId,
'version' => $version,
@ -184,12 +202,10 @@ public function capture(
}
if ($isThrottled) {
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
$gapSubjects['throttled'][] = $subjectKey;
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor));
$stats['throttled']++;
} else {
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
$gapSubjects['capture_failed'][] = $subjectKey;
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor));
$stats['failed']++;
}
@ -209,23 +225,26 @@ public function capture(
$remainingCount = max(0, count($subjects) - $processed);
if ($remainingCount > 0) {
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
foreach (array_slice($subjects, $processed) as $remainingSubject) {
$remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? ''));
$remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? ''));
$remainingSubjectKey = trim((string) ($remainingSubject['subject_key'] ?? ''));
if ($remainingPolicyType === '' || $remainingExternalId === '') {
continue;
}
$gapSubjects['budget_exhausted'][] = $remainingPolicyType.'|'.$remainingExternalId;
$remainingDescriptor = $this->resolver()->describeForCapture(
$remainingPolicyType,
$remainingExternalId,
$remainingSubjectKey !== '' ? $remainingSubjectKey : null,
);
$this->recordGap($gaps, $gapSubjects, $remainingDescriptor, $this->resolver()->budgetExhausted($remainingDescriptor));
}
}
}
ksort($gaps);
ksort($gapSubjects);
return [
'stats' => $stats,
@ -236,6 +255,21 @@ public function capture(
];
}
/**
* @param array<string, int> $gaps
* @param list<array<string, mixed>> $gapSubjects
*/
private function recordGap(array &$gaps, array &$gapSubjects, SubjectDescriptor $descriptor, ResolutionOutcomeRecord $outcome): void
{
$gaps[$outcome->reasonCode] = ($gaps[$outcome->reasonCode] ?? 0) + 1;
$gapSubjects[] = array_merge($descriptor->toArray(), $outcome->toArray());
}
private function resolver(): SubjectResolver
{
return $this->subjectResolver ?? app(SubjectResolver::class);
}
private function retryDelayMs(int $attempt): int
{
$attempt = max(0, $attempt);

View File

@ -9,33 +9,6 @@
final class BaselineCompareEvidenceGapDetails
{
/**
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
public static function fromOperationRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun || ! is_array($run->context)) {
@ -47,31 +20,6 @@ public static function fromOperationRun(?OperationRun $run): array
/**
* @param array<string, mixed> $context
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
public static function fromContext(array $context): array
{
@ -86,31 +34,6 @@ public static function fromContext(array $context): array
/**
* @param array<string, mixed> $baselineCompare
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
public static function fromBaselineCompare(array $baselineCompare): array
{
@ -118,31 +41,49 @@ public static function fromBaselineCompare(array $baselineCompare): array
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
$subjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
$normalizedSubjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
foreach ($subjects as $reason => $keys) {
if (! array_key_exists($reason, $byReason)) {
$byReason[$reason] = count($keys);
foreach ($normalizedSubjects['subjects'] as $reasonCode => $subjects) {
if (! array_key_exists($reasonCode, $byReason)) {
$byReason[$reasonCode] = count($subjects);
}
}
$count = self::normalizeTotalCount($evidenceGaps['count'] ?? null, $byReason, $subjects);
$detailState = self::detailState($count, $subjects);
$count = self::normalizeTotalCount(
$evidenceGaps['count'] ?? null,
$byReason,
$normalizedSubjects['subjects'],
);
$detailState = self::detailState($count, $normalizedSubjects);
$buckets = [];
foreach (self::orderedReasons($byReason, $subjects) as $reason) {
$rows = self::rowsForReason($reason, $subjects[$reason] ?? []);
$reasonCount = $byReason[$reason] ?? count($rows);
foreach (self::orderedReasons($byReason, $normalizedSubjects['subjects']) as $reasonCode) {
$rows = $detailState === 'structured_details_recorded'
? array_map(
static fn (array $subject): array => self::projectSubjectRow($subject),
$normalizedSubjects['subjects'][$reasonCode] ?? [],
)
: [];
$reasonCount = $byReason[$reasonCode] ?? count($rows);
if ($reasonCount <= 0 && $rows === []) {
continue;
}
$recordedCount = count($rows);
$structuralCount = count(array_filter(
$rows,
static fn (array $row): bool => (bool) ($row['structural'] ?? false),
));
$transientCount = count(array_filter(
$rows,
static fn (array $row): bool => (bool) ($row['retryable'] ?? false),
));
$operationalCount = max(0, $recordedCount - $structuralCount - $transientCount);
$searchText = trim(implode(' ', array_filter([
Str::lower($reason),
Str::lower(self::reasonLabel($reason)),
Str::lower($reasonCode),
Str::lower(self::reasonLabel($reasonCode)),
...array_map(
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
$rows,
@ -150,12 +91,15 @@ public static function fromBaselineCompare(array $baselineCompare): array
])));
$buckets[] = [
'reason_code' => $reason,
'reason_label' => self::reasonLabel($reason),
'reason_code' => $reasonCode,
'reason_label' => self::reasonLabel($reasonCode),
'count' => $reasonCount,
'recorded_count' => $recordedCount,
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
'detail_state' => $recordedCount > 0 ? 'details_recorded' : 'details_not_recorded',
'structural_count' => $structuralCount,
'operational_count' => $operationalCount,
'transient_count' => $transientCount,
'detail_state' => self::bucketDetailState($detailState, $recordedCount),
'search_text' => $searchText,
'rows' => $rows,
];
@ -165,6 +109,19 @@ public static function fromBaselineCompare(array $baselineCompare): array
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
$buckets,
));
$structuralCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['structural_count'] ?? 0),
$buckets,
));
$operationalCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['operational_count'] ?? 0),
$buckets,
));
$transientCount = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['transient_count'] ?? 0),
$buckets,
));
$legacyMode = $detailState === 'legacy_broad_reason';
return [
'summary' => [
@ -173,6 +130,11 @@ public static function fromBaselineCompare(array $baselineCompare): array
'detail_state' => $detailState,
'recorded_subjects_total' => $recordedSubjectsTotal,
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
'structural_count' => $structuralCount,
'operational_count' => $operationalCount,
'transient_count' => $transientCount,
'legacy_mode' => $legacyMode,
'requires_regeneration' => $legacyMode,
],
'buckets' => $buckets,
];
@ -201,21 +163,68 @@ public static function reasonLabel(string $reason): string
return match ($reason) {
'ambiguous_match' => 'Ambiguous inventory match',
'policy_not_found' => 'Policy not found',
'missing_current' => 'Missing current evidence',
'policy_record_missing' => 'Policy record missing',
'inventory_record_missing' => 'Inventory record missing',
'foundation_not_policy_backed' => 'Foundation not policy-backed',
'invalid_subject' => 'Invalid subject',
'duplicate_subject' => 'Duplicate subject',
'capture_failed' => 'Evidence capture failed',
'retryable_capture_failure' => 'Retryable evidence capture failure',
'budget_exhausted' => 'Capture budget exhausted',
'throttled' => 'Graph throttled',
'invalid_support_config' => 'Invalid support configuration',
'missing_current' => 'Missing current evidence',
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
'rollout_disabled' => 'Rollout disabled',
'policy_not_found' => 'Legacy policy not found',
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
};
}
public static function subjectClassLabel(string $subjectClass): string
{
return match (trim($subjectClass)) {
SubjectClass::PolicyBacked->value => 'Policy-backed',
SubjectClass::InventoryBacked->value => 'Inventory-backed',
SubjectClass::FoundationBacked->value => 'Foundation-backed',
default => 'Derived',
};
}
public static function resolutionOutcomeLabel(string $resolutionOutcome): string
{
return match (trim($resolutionOutcome)) {
ResolutionOutcome::ResolvedPolicy->value => 'Resolved policy',
ResolutionOutcome::ResolvedInventory->value => 'Resolved inventory',
ResolutionOutcome::PolicyRecordMissing->value => 'Policy record missing',
ResolutionOutcome::InventoryRecordMissing->value => 'Inventory record missing',
ResolutionOutcome::FoundationInventoryOnly->value => 'Foundation inventory only',
ResolutionOutcome::InvalidSubject->value => 'Invalid subject',
ResolutionOutcome::DuplicateSubject->value => 'Duplicate subject',
ResolutionOutcome::AmbiguousMatch->value => 'Ambiguous match',
ResolutionOutcome::InvalidSupportConfig->value => 'Invalid support configuration',
ResolutionOutcome::Throttled->value => 'Graph throttled',
ResolutionOutcome::CaptureFailed->value => 'Capture failed',
ResolutionOutcome::RetryableCaptureFailure->value => 'Retryable capture failure',
ResolutionOutcome::BudgetExhausted->value => 'Budget exhausted',
};
}
public static function operatorActionCategoryLabel(string $operatorActionCategory): string
{
return match (trim($operatorActionCategory)) {
OperatorActionCategory::Retry->value => 'Retry',
OperatorActionCategory::RunInventorySync->value => 'Run inventory sync',
OperatorActionCategory::RunPolicySyncOrBackup->value => 'Run policy sync or backup',
OperatorActionCategory::ReviewPermissions->value => 'Review permissions',
OperatorActionCategory::InspectSubjectMapping->value => 'Inspect subject mapping',
OperatorActionCategory::ProductFollowUp->value => 'Product follow-up',
default => 'No action',
};
}
/**
* @param array<string, int> $byReason
* @return list<array{reason_code: string, reason_label: string, count: int}>
@ -238,14 +247,7 @@ public static function topReasons(array $byReason, int $limit = 5): array
/**
* @param list<array<string, mixed>> $buckets
* @return list<array{
* __id: string,
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* @return list<array<string, mixed>>
*/
public static function tableRows(array $buckets): array
{
@ -264,26 +266,32 @@ public static function tableRows(array $buckets): array
}
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
$reasonLabel = self::stringOrNull($row['reason_label'] ?? null);
$policyType = self::stringOrNull($row['policy_type'] ?? null);
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
$subjectClass = self::stringOrNull($row['subject_class'] ?? null);
$resolutionOutcome = self::stringOrNull($row['resolution_outcome'] ?? null);
$operatorActionCategory = self::stringOrNull($row['operator_action_category'] ?? null);
if ($reasonCode === null || $reasonLabel === null || $policyType === null || $subjectKey === null) {
if ($reasonCode === null || $policyType === null || $subjectKey === null || $subjectClass === null || $resolutionOutcome === null || $operatorActionCategory === null) {
continue;
}
$rows[] = [
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey])),
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey, $resolutionOutcome])),
'reason_code' => $reasonCode,
'reason_label' => $reasonLabel,
'reason_label' => self::reasonLabel($reasonCode),
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'search_text' => Str::lower(implode(' ', [
$reasonCode,
$reasonLabel,
$policyType,
$subjectKey,
])),
'subject_class' => $subjectClass,
'subject_class_label' => self::subjectClassLabel($subjectClass),
'resolution_path' => self::stringOrNull($row['resolution_path'] ?? null),
'resolution_outcome' => $resolutionOutcome,
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
'operator_action_category' => $operatorActionCategory,
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
'structural' => (bool) ($row['structural'] ?? false),
'retryable' => (bool) ($row['retryable'] ?? false),
'search_text' => self::stringOrNull($row['search_text'] ?? null) ?? '',
];
}
}
@ -321,32 +329,35 @@ public static function policyTypeFilterOptions(array $rows): array
}
/**
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function subjectClassFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['subject_class'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['subject_class'] => (string) ($row['subject_class_label'] ?? self::subjectClassLabel((string) $row['subject_class'])),
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function actionCategoryFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['operator_action_category'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['operator_action_category'] => (string) ($row['operator_action_category_label'] ?? self::operatorActionCategoryLabel((string) $row['operator_action_category'])),
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
private static function empty(): array
{
return [
@ -356,6 +367,11 @@ private static function empty(): array
'detail_state' => 'no_gaps',
'recorded_subjects_total' => 0,
'missing_detail_count' => 0,
'structural_count' => 0,
'operational_count' => 0,
'transient_count' => 0,
'legacy_mode' => false,
'requires_regeneration' => false,
],
'buckets' => [],
];
@ -392,41 +408,125 @@ private static function normalizeCounts(mixed $value): array
}
/**
* @return array<string, list<string>>
* @return array{
* subjects: array<string, list<array<string, mixed>>>,
* legacy_mode: bool
* }
*/
private static function normalizeSubjects(mixed $value): array
{
if ($value === null) {
return [
'subjects' => [],
'legacy_mode' => false,
];
}
if (! is_array($value)) {
return [];
return [
'subjects' => [],
'legacy_mode' => true,
];
}
$normalized = [];
foreach ($value as $reason => $keys) {
if (! is_string($reason) || trim($reason) === '' || ! is_array($keys)) {
continue;
if (! array_is_list($value)) {
return [
'subjects' => [],
'legacy_mode' => true,
];
}
$items = array_values(array_unique(array_filter(array_map(
static fn (mixed $item): ?string => is_string($item) && trim($item) !== '' ? trim($item) : null,
$keys,
))));
$subjects = [];
if ($items === []) {
continue;
foreach ($value as $item) {
$normalized = self::normalizeStructuredSubject($item);
if ($normalized === null) {
return [
'subjects' => [],
'legacy_mode' => true,
];
}
$normalized[trim($reason)] = $items;
$subjects[$normalized['reason_code']][] = $normalized;
}
ksort($normalized);
foreach ($subjects as &$bucket) {
usort($bucket, static function (array $left, array $right): int {
return [$left['policy_type'], $left['subject_key'], $left['resolution_outcome']]
<=> [$right['policy_type'], $right['subject_key'], $right['resolution_outcome']];
});
}
unset($bucket);
return $normalized;
ksort($subjects);
return [
'subjects' => $subjects,
'legacy_mode' => false,
];
}
/**
* @return array<string, mixed>|null
*/
private static function normalizeStructuredSubject(mixed $value): ?array
{
if (! is_array($value)) {
return null;
}
$policyType = self::stringOrNull($value['policy_type'] ?? null);
$subjectKey = self::stringOrNull($value['subject_key'] ?? null);
$subjectClass = self::stringOrNull($value['subject_class'] ?? null);
$resolutionPath = self::stringOrNull($value['resolution_path'] ?? null);
$resolutionOutcome = self::stringOrNull($value['resolution_outcome'] ?? null);
$reasonCode = self::stringOrNull($value['reason_code'] ?? null);
$operatorActionCategory = self::stringOrNull($value['operator_action_category'] ?? null);
if ($policyType === null
|| $subjectKey === null
|| $subjectClass === null
|| $resolutionPath === null
|| $resolutionOutcome === null
|| $reasonCode === null
|| $operatorActionCategory === null) {
return null;
}
if (! SubjectClass::tryFrom($subjectClass) instanceof SubjectClass
|| ! ResolutionPath::tryFrom($resolutionPath) instanceof ResolutionPath
|| ! ResolutionOutcome::tryFrom($resolutionOutcome) instanceof ResolutionOutcome
|| ! OperatorActionCategory::tryFrom($operatorActionCategory) instanceof OperatorActionCategory) {
return null;
}
$sourceModelExpected = self::stringOrNull($value['source_model_expected'] ?? null);
$sourceModelExpected = in_array($sourceModelExpected, ['policy', 'inventory', 'derived'], true) ? $sourceModelExpected : null;
$sourceModelFound = self::stringOrNull($value['source_model_found'] ?? null);
$sourceModelFound = in_array($sourceModelFound, ['policy', 'inventory', 'derived'], true) ? $sourceModelFound : null;
return [
'policy_type' => $policyType,
'subject_external_id' => self::stringOrNull($value['subject_external_id'] ?? null),
'subject_key' => $subjectKey,
'subject_class' => $subjectClass,
'resolution_path' => $resolutionPath,
'resolution_outcome' => $resolutionOutcome,
'reason_code' => $reasonCode,
'operator_action_category' => $operatorActionCategory,
'structural' => self::boolOrFalse($value['structural'] ?? null),
'retryable' => self::boolOrFalse($value['retryable'] ?? null),
'source_model_expected' => $sourceModelExpected,
'source_model_found' => $sourceModelFound,
'legacy_reason_code' => self::stringOrNull($value['legacy_reason_code'] ?? null),
];
}
/**
* @param array<string, int> $byReason
* @param array<string, list<string>> $subjects
* @param array<string, list<array<string, mixed>>> $subjects
* @return list<string>
*/
private static function orderedReasons(array $byReason, array $subjects): array
@ -444,7 +544,7 @@ private static function orderedReasons(array $byReason, array $subjects): array
/**
* @param array<string, int> $byReason
* @param array<string, list<string>> $subjects
* @param array<string, list<array<string, mixed>>> $subjects
*/
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
{
@ -463,13 +563,13 @@ private static function normalizeTotalCount(mixed $count, array $byReason, array
}
return array_sum(array_map(
static fn (array $keys): int => count($keys),
static fn (array $rows): int => count($rows),
$subjects,
));
}
/**
* @param array<string, list<string>> $subjects
* @param array{subjects: array<string, list<array<string, mixed>>>, legacy_mode: bool} $subjects
*/
private static function detailState(int $count, array $subjects): string
{
@ -477,66 +577,57 @@ private static function detailState(int $count, array $subjects): string
return 'no_gaps';
}
return $subjects !== [] ? 'details_recorded' : 'details_not_recorded';
if ($subjects['legacy_mode']) {
return 'legacy_broad_reason';
}
return $subjects['subjects'] !== [] ? 'structured_details_recorded' : 'details_not_recorded';
}
private static function bucketDetailState(string $detailState, int $recordedCount): string
{
if ($detailState === 'legacy_broad_reason') {
return 'legacy_broad_reason';
}
if ($recordedCount > 0) {
return 'structured_details_recorded';
}
return 'details_not_recorded';
}
/**
* @param list<string> $subjects
* @return list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* @param array<string, mixed> $subject
* @return array<string, mixed>
*/
private static function rowsForReason(string $reason, array $subjects): array
private static function projectSubjectRow(array $subject): array
{
$rows = [];
$reasonCode = (string) $subject['reason_code'];
$subjectClass = (string) $subject['subject_class'];
$resolutionOutcome = (string) $subject['resolution_outcome'];
$operatorActionCategory = (string) $subject['operator_action_category'];
foreach ($subjects as $subject) {
[$policyType, $subjectKey] = self::splitSubject($subject);
if ($policyType === null || $subjectKey === null) {
continue;
}
$rows[] = [
'reason_code' => $reason,
'reason_label' => self::reasonLabel($reason),
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'search_text' => Str::lower(implode(' ', [
$reason,
self::reasonLabel($reason),
$policyType,
$subjectKey,
])),
];
}
return $rows;
}
/**
* @return array{0: ?string, 1: ?string}
*/
private static function splitSubject(string $subject): array
{
$parts = explode('|', $subject, 2);
if (count($parts) !== 2) {
return [null, null];
}
$policyType = trim($parts[0]);
$subjectKey = trim($parts[1]);
if ($policyType === '' || $subjectKey === '') {
return [null, null];
}
return [$policyType, $subjectKey];
return array_merge($subject, [
'reason_label' => self::reasonLabel($reasonCode),
'subject_class_label' => self::subjectClassLabel($subjectClass),
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
'search_text' => Str::lower(trim(implode(' ', array_filter([
$reasonCode,
self::reasonLabel($reasonCode),
(string) ($subject['policy_type'] ?? ''),
(string) ($subject['subject_key'] ?? ''),
$subjectClass,
self::subjectClassLabel($subjectClass),
(string) ($subject['resolution_path'] ?? ''),
$resolutionOutcome,
self::resolutionOutcomeLabel($resolutionOutcome),
$operatorActionCategory,
self::operatorActionCategoryLabel($operatorActionCategory),
(string) ($subject['subject_external_id'] ?? ''),
])))),
]);
}
private static function stringOrNull(mixed $value): ?string
@ -545,13 +636,26 @@ private static function stringOrNull(mixed $value): ?string
return null;
}
$value = trim($value);
$trimmed = trim($value);
return $value !== '' ? $value : null;
return $trimmed !== '' ? $trimmed : null;
}
private static function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private static function boolOrFalse(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value) || is_string($value)) {
return filter_var($value, FILTER_VALIDATE_BOOL);
}
return false;
}
}

View File

@ -181,6 +181,36 @@ private function countDescriptors(
);
}
if ($stats->evidenceGapStructuralCount !== null && $stats->evidenceGapStructuralCount > 0) {
$descriptors[] = new CountDescriptor(
label: 'Structural gaps',
value: (int) $stats->evidenceGapStructuralCount,
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: 'product or support limit',
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
);
}
if ($stats->evidenceGapOperationalCount !== null && $stats->evidenceGapOperationalCount > 0) {
$descriptors[] = new CountDescriptor(
label: 'Operational gaps',
value: (int) $stats->evidenceGapOperationalCount,
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: 'local evidence missing',
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
);
}
if ($stats->evidenceGapTransientCount !== null && $stats->evidenceGapTransientCount > 0) {
$descriptors[] = new CountDescriptor(
label: 'Transient gaps',
value: (int) $stats->evidenceGapTransientCount,
role: CountDescriptor::ROLE_RELIABILITY_SIGNAL,
qualifier: 'retry may help',
visibilityTier: CountDescriptor::VISIBILITY_DIAGNOSTIC,
);
}
if ($stats->severityCounts !== []) {
foreach (['high' => 'High severity', 'medium' => 'Medium severity', 'low' => 'Low severity'] as $key => $label) {
$value = (int) ($stats->severityCounts[$key] ?? 0);

View File

@ -58,6 +58,7 @@ private function __construct(
public readonly ?int $profileId,
public readonly ?int $snapshotId,
public readonly ?int $duplicateNamePoliciesCount,
public readonly ?int $duplicateNameSubjectsCount,
public readonly ?int $operationRunId,
public readonly ?int $findingsCount,
public readonly array $severityCounts,
@ -75,6 +76,10 @@ private function __construct(
public readonly ?array $rbacRoleDefinitionSummary = null,
public readonly array $evidenceGapDetails = [],
public readonly array $baselineCompareDiagnostics = [],
public readonly ?int $evidenceGapStructuralCount = null,
public readonly ?int $evidenceGapOperationalCount = null,
public readonly ?int $evidenceGapTransientCount = null,
public readonly ?bool $evidenceGapLegacyMode = null,
) {}
public static function forTenant(?Tenant $tenant): self
@ -119,7 +124,9 @@ public static function forTenant(?Tenant $tenant): self
: null;
$effectiveScope = BaselineScope::effective($profileScope, $overrideScope);
$duplicateNamePoliciesCount = self::duplicateNamePoliciesCount($tenant, $effectiveScope);
$duplicateNameStats = self::duplicateNameStats($tenant, $effectiveScope);
$duplicateNamePoliciesCount = $duplicateNameStats['policy_count'];
$duplicateNameSubjectsCount = $duplicateNameStats['subject_count'];
if ($snapshotId === null) {
return new self(
@ -129,6 +136,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: null,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: null,
findingsCount: null,
severityCounts: [],
@ -152,6 +160,19 @@ public static function forTenant(?Tenant $tenant): self
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
$evidenceGapSummary = is_array($evidenceGapDetails['summary'] ?? null) ? $evidenceGapDetails['summary'] : [];
$evidenceGapStructuralCount = is_numeric($evidenceGapSummary['structural_count'] ?? null)
? (int) $evidenceGapSummary['structural_count']
: null;
$evidenceGapOperationalCount = is_numeric($evidenceGapSummary['operational_count'] ?? null)
? (int) $evidenceGapSummary['operational_count']
: null;
$evidenceGapTransientCount = is_numeric($evidenceGapSummary['transient_count'] ?? null)
? (int) $evidenceGapSummary['transient_count']
: null;
$evidenceGapLegacyMode = is_bool($evidenceGapSummary['legacy_mode'] ?? null)
? (bool) $evidenceGapSummary['legacy_mode']
: null;
// Active run (queued/running)
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
@ -162,6 +183,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: (int) $latestRun->getKey(),
findingsCount: null,
severityCounts: [],
@ -179,6 +201,10 @@ public static function forTenant(?Tenant $tenant): self
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -196,6 +222,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: (int) $latestRun->getKey(),
findingsCount: null,
severityCounts: [],
@ -213,6 +240,10 @@ public static function forTenant(?Tenant $tenant): self
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -252,6 +283,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings,
severityCounts: $severityCounts,
@ -269,6 +301,10 @@ public static function forTenant(?Tenant $tenant): self
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -282,6 +318,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: (int) $latestRun->getKey(),
findingsCount: 0,
severityCounts: $severityCounts,
@ -299,6 +336,10 @@ public static function forTenant(?Tenant $tenant): self
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -309,6 +350,7 @@ public static function forTenant(?Tenant $tenant): self
profileId: $profileId,
snapshotId: $snapshotId,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: null,
findingsCount: null,
severityCounts: $severityCounts,
@ -326,6 +368,10 @@ public static function forTenant(?Tenant $tenant): self
rbacRoleDefinitionSummary: $rbacRoleDefinitionSummary,
evidenceGapDetails: $evidenceGapDetails,
baselineCompareDiagnostics: $baselineCompareDiagnostics,
evidenceGapStructuralCount: $evidenceGapStructuralCount,
evidenceGapOperationalCount: $evidenceGapOperationalCount,
evidenceGapTransientCount: $evidenceGapTransientCount,
evidenceGapLegacyMode: $evidenceGapLegacyMode,
);
}
@ -382,6 +428,7 @@ public static function forWidget(?Tenant $tenant): self
profileId: (int) $profile->getKey(),
snapshotId: $snapshotId,
duplicateNamePoliciesCount: null,
duplicateNameSubjectsCount: null,
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
findingsCount: $totalFindings,
severityCounts: [
@ -397,17 +444,23 @@ public static function forWidget(?Tenant $tenant): self
);
}
private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope $effectiveScope): int
/**
* @return array{policy_count: int, subject_count: int}
*/
private static function duplicateNameStats(Tenant $tenant, BaselineScope $effectiveScope): array
{
$policyTypes = $effectiveScope->allTypes();
if ($policyTypes === []) {
return 0;
return [
'policy_count' => 0,
'subject_count' => 0,
];
}
$latestInventorySyncRunId = self::latestInventorySyncRunId($tenant);
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): int {
$compute = static function () use ($tenant, $policyTypes, $latestInventorySyncRunId): array {
/**
* @var array<string, int> $countsByKey
*/
@ -440,14 +493,19 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
});
$duplicatePolicies = 0;
$duplicateSubjects = 0;
foreach ($countsByKey as $count) {
if ($count > 1) {
$duplicateSubjects++;
$duplicatePolicies += $count;
}
}
return $duplicatePolicies;
return [
'policy_count' => $duplicatePolicies,
'subject_count' => $duplicateSubjects,
];
};
if (app()->environment('testing')) {
@ -461,7 +519,10 @@ private static function duplicateNamePoliciesCount(Tenant $tenant, BaselineScope
$latestInventorySyncRunId ?? 'all',
);
return (int) Cache::remember($cacheKey, now()->addSeconds(60), $compute);
/** @var array{policy_count: int, subject_count: int} $stats */
$stats = Cache::remember($cacheKey, now()->addSeconds(60), $compute);
return $stats;
}
private static function latestInventorySyncRunId(Tenant $tenant): ?int
@ -675,6 +736,7 @@ private static function empty(
?string $profileName = null,
?int $profileId = null,
?int $duplicateNamePoliciesCount = null,
?int $duplicateNameSubjectsCount = null,
): self {
return new self(
state: $state,
@ -683,6 +745,7 @@ private static function empty(
profileId: $profileId,
snapshotId: null,
duplicateNamePoliciesCount: $duplicateNamePoliciesCount,
duplicateNameSubjectsCount: $duplicateNameSubjectsCount,
operationRunId: null,
findingsCount: null,
severityCounts: [],

View File

@ -118,6 +118,17 @@ public function allTypes(): array
));
}
/**
* @return list<string>
*/
public function truthfulTypes(string $operation, ?BaselineSupportCapabilityGuard $guard = null): array
{
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($this->allTypes(), $operation);
return $guardResult['allowed_types'];
}
/**
* @return array<string, mixed>
*/
@ -134,17 +145,32 @@ public function toJsonb(): array
*
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
*/
public function toEffectiveScopeContext(): array
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
{
$expanded = $this->expandDefaults();
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
return [
$context = [
'policy_types' => $expanded->policyTypes,
'foundation_types' => $expanded->foundationTypes,
'all_types' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [],
];
if (! is_string($operation) || $operation === '') {
return $context;
}
$guard ??= app(BaselineSupportCapabilityGuard::class);
$guardResult = $guard->guardTypes($allTypes, $operation);
return array_merge($context, [
'truthful_types' => $guardResult['allowed_types'],
'limited_types' => $guardResult['limited_types'],
'unsupported_types' => $guardResult['unsupported_types'],
'invalid_support_types' => $guardResult['invalid_support_types'],
'capabilities' => $guardResult['capabilities'],
]);
}
/**

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
final class BaselineSupportCapabilityGuard
{
public function __construct(
private readonly SubjectResolver $resolver,
) {}
public function inspectType(string $policyType): SupportCapabilityRecord
{
return $this->resolver->capability($policyType);
}
/**
* @param list<string> $policyTypes
* @return array{
* allowed_types: list<string>,
* limited_types: list<string>,
* unsupported_types: list<string>,
* invalid_support_types: list<string>,
* capabilities: array<string, array<string, mixed>>
* }
*/
public function guardTypes(array $policyTypes, string $operation): array
{
$allowedTypes = [];
$limitedTypes = [];
$unsupportedTypes = [];
$invalidSupportTypes = [];
$capabilities = [];
foreach (array_values(array_unique(array_filter($policyTypes, 'is_string'))) as $policyType) {
$record = $this->inspectType($policyType);
$mode = $record->supportModeFor($operation);
$capabilities[$policyType] = array_merge(
$record->toArray(),
['support_mode' => $mode],
);
if ($mode === 'invalid_support_config') {
$invalidSupportTypes[] = $policyType;
$unsupportedTypes[] = $policyType;
continue;
}
if ($record->allows($operation)) {
$allowedTypes[] = $policyType;
if ($mode === 'limited') {
$limitedTypes[] = $policyType;
}
continue;
}
$unsupportedTypes[] = $policyType;
}
sort($allowedTypes, SORT_STRING);
sort($limitedTypes, SORT_STRING);
sort($unsupportedTypes, SORT_STRING);
sort($invalidSupportTypes, SORT_STRING);
ksort($capabilities);
return [
'allowed_types' => $allowedTypes,
'limited_types' => $limitedTypes,
'unsupported_types' => $unsupportedTypes,
'invalid_support_types' => $invalidSupportTypes,
'capabilities' => $capabilities,
];
}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum OperatorActionCategory: string
{
case None = 'none';
case Retry = 'retry';
case RunInventorySync = 'run_inventory_sync';
case RunPolicySyncOrBackup = 'run_policy_sync_or_backup';
case ReviewPermissions = 'review_permissions';
case InspectSubjectMapping = 'inspect_subject_mapping';
case ProductFollowUp = 'product_follow_up';
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum ResolutionOutcome: string
{
case ResolvedPolicy = 'resolved_policy';
case ResolvedInventory = 'resolved_inventory';
case PolicyRecordMissing = 'policy_record_missing';
case InventoryRecordMissing = 'inventory_record_missing';
case FoundationInventoryOnly = 'foundation_inventory_only';
case ResolutionTypeMismatch = 'resolution_type_mismatch';
case UnresolvableSubject = 'unresolvable_subject';
case InvalidSupportConfig = 'invalid_support_config';
case PermissionOrScopeBlocked = 'permission_or_scope_blocked';
case AmbiguousMatch = 'ambiguous_match';
case InvalidSubject = 'invalid_subject';
case DuplicateSubject = 'duplicate_subject';
case RetryableCaptureFailure = 'retryable_capture_failure';
case CaptureFailed = 'capture_failed';
case Throttled = 'throttled';
case BudgetExhausted = 'budget_exhausted';
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
final class ResolutionOutcomeRecord
{
/**
* @param non-empty-string $reasonCode
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
* @param 'policy'|'inventory'|'derived'|null $sourceModelFound
*/
public function __construct(
public readonly ResolutionOutcome $resolutionOutcome,
public readonly string $reasonCode,
public readonly OperatorActionCategory $operatorActionCategory,
public readonly bool $structural,
public readonly bool $retryable,
public readonly ?string $sourceModelExpected = null,
public readonly ?string $sourceModelFound = null,
) {}
public function toArray(): array
{
return [
'resolution_outcome' => $this->resolutionOutcome->value,
'reason_code' => $this->reasonCode,
'operator_action_category' => $this->operatorActionCategory->value,
'structural' => $this->structural,
'retryable' => $this->retryable,
'source_model_expected' => $this->sourceModelExpected,
'source_model_found' => $this->sourceModelFound,
];
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum ResolutionPath: string
{
case Policy = 'policy';
case Inventory = 'inventory';
case FoundationPolicy = 'foundation_policy';
case FoundationInventory = 'foundation_inventory';
case Derived = 'derived';
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
enum SubjectClass: string
{
case PolicyBacked = 'policy_backed';
case InventoryBacked = 'inventory_backed';
case FoundationBacked = 'foundation_backed';
case Derived = 'derived';
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
final class SubjectDescriptor
{
/**
* @param non-empty-string $policyType
* @param non-empty-string $subjectKey
* @param 'supported'|'limited'|'excluded'|'invalid_support_config' $supportMode
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
*/
public function __construct(
public readonly string $policyType,
public readonly ?string $subjectExternalId,
public readonly string $subjectKey,
public readonly SubjectClass $subjectClass,
public readonly ResolutionPath $resolutionPath,
public readonly string $supportMode,
public readonly ?string $sourceModelExpected,
) {}
public function expectsPolicy(): bool
{
return $this->sourceModelExpected === 'policy';
}
public function expectsInventory(): bool
{
return $this->sourceModelExpected === 'inventory';
}
public function toArray(): array
{
return [
'policy_type' => $this->policyType,
'subject_external_id' => $this->subjectExternalId,
'subject_key' => $this->subjectKey,
'subject_class' => $this->subjectClass->value,
'resolution_path' => $this->resolutionPath->value,
'support_mode' => $this->supportMode,
'source_model_expected' => $this->sourceModelExpected,
];
}
}

View File

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Support\Inventory\InventoryPolicyTypeMeta;
final class SubjectResolver
{
public function capability(string $policyType): SupportCapabilityRecord
{
$contract = InventoryPolicyTypeMeta::baselineSupportContract($policyType);
return new SupportCapabilityRecord(
policyType: $policyType,
subjectClass: SubjectClass::from($contract['subject_class']),
compareCapability: $contract['compare_capability'],
captureCapability: $contract['capture_capability'],
resolutionPath: ResolutionPath::from($contract['resolution_path']),
configSupported: (bool) $contract['config_supported'],
runtimeValid: (bool) $contract['runtime_valid'],
sourceModelExpected: $contract['source_model_expected'],
);
}
public function describeForCompare(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
{
return $this->describe(operation: 'compare', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
}
public function describeForCapture(string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
{
return $this->describe(operation: 'capture', policyType: $policyType, subjectExternalId: $subjectExternalId, subjectKey: $subjectKey);
}
public function resolved(SubjectDescriptor $descriptor, ?string $sourceModelFound = null): ResolutionOutcomeRecord
{
$outcome = $descriptor->expectsPolicy()
? ResolutionOutcome::ResolvedPolicy
: ResolutionOutcome::ResolvedInventory;
return new ResolutionOutcomeRecord(
resolutionOutcome: $outcome,
reasonCode: $outcome->value,
operatorActionCategory: OperatorActionCategory::None,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
sourceModelFound: $sourceModelFound ?? $descriptor->sourceModelExpected,
);
}
public function missingExpectedRecord(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
$expectsPolicy = $descriptor->expectsPolicy();
return new ResolutionOutcomeRecord(
resolutionOutcome: $expectsPolicy ? ResolutionOutcome::PolicyRecordMissing : ResolutionOutcome::InventoryRecordMissing,
reasonCode: $expectsPolicy ? 'policy_record_missing' : 'inventory_record_missing',
operatorActionCategory: $expectsPolicy ? OperatorActionCategory::RunPolicySyncOrBackup : OperatorActionCategory::RunInventorySync,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function structuralInventoryOnly(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::FoundationInventoryOnly,
reasonCode: 'foundation_not_policy_backed',
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
structural: true,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
sourceModelFound: 'inventory',
);
}
public function invalidSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::InvalidSubject,
reasonCode: 'invalid_subject',
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function duplicateSubject(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::DuplicateSubject,
reasonCode: 'duplicate_subject',
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function ambiguousMatch(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::AmbiguousMatch,
reasonCode: 'ambiguous_match',
operatorActionCategory: OperatorActionCategory::InspectSubjectMapping,
structural: false,
retryable: false,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function invalidSupportConfiguration(SupportCapabilityRecord $capability): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::InvalidSupportConfig,
reasonCode: 'invalid_support_config',
operatorActionCategory: OperatorActionCategory::ProductFollowUp,
structural: true,
retryable: false,
sourceModelExpected: $capability->sourceModelExpected,
);
}
public function throttled(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::Throttled,
reasonCode: 'throttled',
operatorActionCategory: OperatorActionCategory::Retry,
structural: false,
retryable: true,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function captureFailed(SubjectDescriptor $descriptor, bool $retryable = false): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: $retryable ? ResolutionOutcome::RetryableCaptureFailure : ResolutionOutcome::CaptureFailed,
reasonCode: $retryable ? 'retryable_capture_failure' : 'capture_failed',
operatorActionCategory: OperatorActionCategory::Retry,
structural: false,
retryable: $retryable,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
public function budgetExhausted(SubjectDescriptor $descriptor): ResolutionOutcomeRecord
{
return new ResolutionOutcomeRecord(
resolutionOutcome: ResolutionOutcome::BudgetExhausted,
reasonCode: 'budget_exhausted',
operatorActionCategory: OperatorActionCategory::Retry,
structural: false,
retryable: true,
sourceModelExpected: $descriptor->sourceModelExpected,
);
}
private function describe(string $operation, string $policyType, ?string $subjectExternalId = null, ?string $subjectKey = null): SubjectDescriptor
{
$capability = $this->capability($policyType);
$resolvedSubjectKey = $this->normalizeSubjectKey($policyType, $subjectExternalId, $subjectKey);
return new SubjectDescriptor(
policyType: $policyType,
subjectExternalId: $subjectExternalId !== null && trim($subjectExternalId) !== '' ? trim($subjectExternalId) : null,
subjectKey: $resolvedSubjectKey,
subjectClass: $capability->subjectClass,
resolutionPath: $capability->resolutionPath,
supportMode: $capability->supportModeFor($operation),
sourceModelExpected: $capability->sourceModelExpected,
);
}
private function normalizeSubjectKey(string $policyType, ?string $subjectExternalId, ?string $subjectKey): string
{
$trimmedSubjectKey = is_string($subjectKey) ? trim($subjectKey) : '';
if ($trimmedSubjectKey !== '') {
return $trimmedSubjectKey;
}
$generated = BaselineSubjectKey::forPolicy($policyType, subjectExternalId: $subjectExternalId);
if (is_string($generated) && $generated !== '') {
return $generated;
}
$fallbackExternalId = is_string($subjectExternalId) && trim($subjectExternalId) !== ''
? trim($subjectExternalId)
: 'unknown';
return trim($policyType).'|'.$fallbackExternalId;
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use InvalidArgumentException;
final class SupportCapabilityRecord
{
/**
* @param non-empty-string $policyType
* @param 'supported'|'limited'|'unsupported' $compareCapability
* @param 'supported'|'limited'|'unsupported' $captureCapability
* @param 'policy'|'inventory'|'derived'|null $sourceModelExpected
*/
public function __construct(
public readonly string $policyType,
public readonly SubjectClass $subjectClass,
public readonly string $compareCapability,
public readonly string $captureCapability,
public readonly ResolutionPath $resolutionPath,
public readonly bool $configSupported,
public readonly bool $runtimeValid,
public readonly ?string $sourceModelExpected = null,
) {}
/**
* @return 'supported'|'limited'|'excluded'|'invalid_support_config'
*/
public function supportModeFor(string $operation): string
{
$capability = match ($operation) {
'compare' => $this->compareCapability,
'capture' => $this->captureCapability,
default => throw new InvalidArgumentException('Unsupported operation ['.$operation.'].'),
};
if ($this->configSupported && ! $this->runtimeValid) {
return 'invalid_support_config';
}
return match ($capability) {
'supported', 'limited' => $capability,
default => 'excluded',
};
}
public function allows(string $operation): bool
{
return in_array($this->supportModeFor($operation), ['supported', 'limited'], true);
}
public function toArray(): array
{
return [
'policy_type' => $this->policyType,
'subject_class' => $this->subjectClass->value,
'compare_capability' => $this->compareCapability,
'capture_capability' => $this->captureCapability,
'resolution_path' => $this->resolutionPath->value,
'config_supported' => $this->configSupported,
'runtime_valid' => $this->runtimeValid,
'source_model_expected' => $this->sourceModelExpected,
];
}
}

View File

@ -4,6 +4,9 @@
namespace App\Support\Inventory;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
class InventoryPolicyTypeMeta
{
/**
@ -175,4 +178,141 @@ public static function baselineCompareLabel(?string $type): ?string
return static::label($type);
}
/**
* @return array{
* config_supported: bool,
* runtime_valid: bool,
* subject_class: string,
* resolution_path: string,
* compare_capability: string,
* capture_capability: string,
* source_model_expected: 'policy'|'inventory'|'derived'|null
* }
*/
public static function baselineSupportContract(?string $type): array
{
$contract = static::defaultBaselineSupportContract($type);
$resolution = static::baselineCompareMeta($type)['resolution'] ?? null;
if (is_array($resolution)) {
$contract = array_replace($contract, array_filter([
'subject_class' => is_string($resolution['subject_class'] ?? null) ? $resolution['subject_class'] : null,
'resolution_path' => is_string($resolution['resolution_path'] ?? null) ? $resolution['resolution_path'] : null,
'compare_capability' => is_string($resolution['compare_capability'] ?? null) ? $resolution['compare_capability'] : null,
'capture_capability' => is_string($resolution['capture_capability'] ?? null) ? $resolution['capture_capability'] : null,
'source_model_expected' => is_string($resolution['source_model_expected'] ?? null) ? $resolution['source_model_expected'] : null,
], static fn (mixed $value): bool => $value !== null));
}
$subjectClass = SubjectClass::tryFrom((string) ($contract['subject_class'] ?? ''));
$resolutionPath = ResolutionPath::tryFrom((string) ($contract['resolution_path'] ?? ''));
$compareCapability = in_array($contract['compare_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
? (string) $contract['compare_capability']
: 'unsupported';
$captureCapability = in_array($contract['capture_capability'] ?? null, ['supported', 'limited', 'unsupported'], true)
? (string) $contract['capture_capability']
: 'unsupported';
$sourceModelExpected = in_array($contract['source_model_expected'] ?? null, ['policy', 'inventory', 'derived'], true)
? (string) $contract['source_model_expected']
: null;
$runtimeValid = $subjectClass instanceof SubjectClass
&& $resolutionPath instanceof ResolutionPath
&& static::pathMatchesSubjectClass($subjectClass, $resolutionPath)
&& static::pathMatchesExpectedSource($resolutionPath, $sourceModelExpected);
if (! $runtimeValid) {
$compareCapability = 'unsupported';
$captureCapability = 'unsupported';
}
return [
'config_supported' => (bool) ($contract['config_supported'] ?? false),
'runtime_valid' => $runtimeValid,
'subject_class' => ($subjectClass ?? SubjectClass::Derived)->value,
'resolution_path' => ($resolutionPath ?? ResolutionPath::Derived)->value,
'compare_capability' => $compareCapability,
'capture_capability' => $captureCapability,
'source_model_expected' => $sourceModelExpected,
];
}
/**
* @return array{
* config_supported: bool,
* subject_class: string,
* resolution_path: string,
* compare_capability: string,
* capture_capability: string,
* source_model_expected: 'policy'|'inventory'|'derived'|null
* }
*/
private static function defaultBaselineSupportContract(?string $type): array
{
if (filled($type) && ! static::isFoundation($type) && static::metaFor($type) !== []) {
return [
'config_supported' => true,
'subject_class' => SubjectClass::PolicyBacked->value,
'resolution_path' => ResolutionPath::Policy->value,
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'policy',
];
}
if (static::isFoundation($type)) {
$supported = (bool) (static::baselineCompareMeta($type)['supported'] ?? false);
$identityStrategy = static::baselineCompareIdentityStrategy($type);
$usesPolicyPath = $identityStrategy === 'external_id';
return [
'config_supported' => $supported,
'subject_class' => SubjectClass::FoundationBacked->value,
'resolution_path' => $usesPolicyPath
? ResolutionPath::FoundationPolicy->value
: ResolutionPath::FoundationInventory->value,
'compare_capability' => ! $supported
? 'unsupported'
: ($usesPolicyPath ? 'supported' : 'limited'),
'capture_capability' => ! $supported
? 'unsupported'
: ($usesPolicyPath ? 'supported' : 'limited'),
'source_model_expected' => $usesPolicyPath ? 'policy' : 'inventory',
];
}
return [
'config_supported' => false,
'subject_class' => SubjectClass::Derived->value,
'resolution_path' => ResolutionPath::Derived->value,
'compare_capability' => 'unsupported',
'capture_capability' => 'unsupported',
'source_model_expected' => 'derived',
];
}
private static function pathMatchesSubjectClass(SubjectClass $subjectClass, ResolutionPath $resolutionPath): bool
{
return match ($subjectClass) {
SubjectClass::PolicyBacked => $resolutionPath === ResolutionPath::Policy,
SubjectClass::InventoryBacked => $resolutionPath === ResolutionPath::Inventory,
SubjectClass::FoundationBacked => in_array($resolutionPath, [
ResolutionPath::FoundationInventory,
ResolutionPath::FoundationPolicy,
], true),
SubjectClass::Derived => $resolutionPath === ResolutionPath::Derived,
};
}
private static function pathMatchesExpectedSource(ResolutionPath $resolutionPath, ?string $sourceModelExpected): bool
{
return match ($resolutionPath) {
ResolutionPath::Policy,
ResolutionPath::FoundationPolicy => $sourceModelExpected === 'policy',
ResolutionPath::Inventory,
ResolutionPath::FoundationInventory => $sourceModelExpected === 'inventory',
ResolutionPath::Derived => $sourceModelExpected === 'derived',
};
}
}

View File

@ -10,6 +10,11 @@ final class EnterpriseDetailBuilder
{
private ?SummaryHeaderData $header = null;
/**
* @var array<string, mixed>|null
*/
private ?array $decisionZone = null;
/**
* @var list<DetailSectionData>
*/
@ -18,7 +23,7 @@ final class EnterpriseDetailBuilder
/**
* @var list<SupportingCardData>
*/
private array $supportingCards = [];
private array $supportingGroups = [];
/**
* @var list<TechnicalDetailData>
@ -47,6 +52,16 @@ public function header(SummaryHeaderData $header): self
return $this;
}
/**
* @param array<string, mixed> $decisionZone
*/
public function decisionZone(array $decisionZone): self
{
$this->decisionZone = $decisionZone;
return $this;
}
public function addSection(DetailSectionData ...$sections): self
{
foreach ($sections as $section) {
@ -58,8 +73,13 @@ public function addSection(DetailSectionData ...$sections): self
public function addSupportingCard(SupportingCardData ...$cards): self
{
foreach ($cards as $card) {
$this->supportingCards[] = $card;
return $this->addSupportingGroup(...$cards);
}
public function addSupportingGroup(SupportingCardData ...$groups): self
{
foreach ($groups as $group) {
$this->supportingGroups[] = $group;
}
return $this;
@ -94,13 +114,16 @@ public function build(): EnterpriseDetailPageData
resourceType: $this->resourceType,
scope: $this->scope,
header: $this->header,
decisionZone: is_array($this->decisionZone) && $this->decisionZone !== []
? $this->decisionZone
: null,
mainSections: array_values(array_filter(
$this->mainSections,
static fn (DetailSectionData $section): bool => $section->shouldRender(),
)),
supportingCards: array_values(array_filter(
$this->supportingCards,
static fn (SupportingCardData $card): bool => $card->shouldRender(),
supportingGroups: array_values(array_filter(
$this->supportingGroups,
static fn (SupportingCardData $group): bool => $group->shouldRender(),
)),
technicalSections: array_values(array_filter(
$this->technicalSections,

View File

@ -7,8 +7,25 @@
final readonly class EnterpriseDetailPageData
{
/**
* @param array{
* title?: string,
* description?: ?string,
* facts?: list<array<string, mixed>>,
* primaryNextStep?: array{
* label?: string,
* text: string,
* source: string,
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
* },
* compactCounts?: array{
* summaryLine?: ?string,
* primaryFacts?: list<array<string, mixed>>,
* diagnosticFacts?: list<array<string, mixed>>
* },
* attentionNote?: ?string
* }|null $decisionZone
* @param list<DetailSectionData> $mainSections
* @param list<SupportingCardData> $supportingCards
* @param list<SupportingCardData> $supportingGroups
* @param list<TechnicalDetailData> $technicalSections
* @param list<array{title: string, description?: ?string, icon?: ?string}> $emptyStateNotes
*/
@ -16,8 +33,9 @@ public function __construct(
public string $resourceType,
public string $scope,
public SummaryHeaderData $header,
public ?array $decisionZone = null,
public array $mainSections = [],
public array $supportingCards = [],
public array $supportingGroups = [],
public array $technicalSections = [],
public array $emptyStateNotes = [],
) {}
@ -34,8 +52,9 @@ public function __construct(
* primaryActions: list<array{label: string, placement: string, url: ?string, actionName: ?string, destructive: bool, requiresConfirmation: bool, visible: bool, icon: ?string, openInNewTab: bool}>,
* descriptionHint: ?string
* },
* decisionZone: array<string, mixed>|null,
* mainSections: list<array<string, mixed>>,
* supportingCards: list<array<string, mixed>>,
* supportingGroups: list<array<string, mixed>>,
* technicalSections: list<array<string, mixed>>,
* emptyStateNotes: list<array{title: string, description?: ?string, icon?: ?string}>
* }
@ -46,13 +65,14 @@ public function toArray(): array
'resourceType' => $this->resourceType,
'scope' => $this->scope,
'header' => $this->header->toArray(),
'decisionZone' => $this->decisionZone,
'mainSections' => array_values(array_map(
static fn (DetailSectionData $section): array => $section->toArray(),
$this->mainSections,
)),
'supportingCards' => array_values(array_map(
static fn (SupportingCardData $card): array => $card->toArray(),
$this->supportingCards,
'supportingGroups' => array_values(array_map(
static fn (SupportingCardData $group): array => $group->toArray(),
$this->supportingGroups,
)),
'technicalSections' => array_values(array_map(
static fn (TechnicalDetailData $section): array => $section->toArray(),

View File

@ -8,9 +8,11 @@ final class EnterpriseDetailSectionFactory
{
/**
* @param array{label: string, color?: string, icon?: ?string, iconColor?: ?string}|null $badge
* @return array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}}
* @param 'default'|'danger'|'success'|'warning'|null $tone Optional color tone for the card border/value
* @param bool $mono Whether the value should be rendered in monospace font (e.g. hashes, IDs)
* @return array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}, tone?: string, mono?: bool}
*/
public function keyFact(string $label, mixed $value, ?string $hint = null, ?array $badge = null): array
public function keyFact(string $label, mixed $value, ?string $hint = null, ?array $badge = null, ?string $tone = null, bool $mono = false): array
{
$displayValue = match (true) {
is_bool($value) => $value ? 'Yes' : 'No',
@ -24,6 +26,8 @@ public function keyFact(string $label, mixed $value, ?string $hint = null, ?arra
'value' => $displayValue,
'hint' => $hint,
'badge' => $badge,
'tone' => $tone,
'mono' => $mono ?: null,
], static fn (mixed $item): bool => $item !== null);
}
@ -52,6 +56,92 @@ public function emptyState(string $title, ?string $description = null, ?string $
], static fn (mixed $item): bool => $item !== null);
}
/**
* @param list<array<string, mixed>> $facts
* @param array{
* label?: string,
* text: string,
* source: string,
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
* } $primaryNextStep
* @param array{
* summaryLine?: ?string,
* primaryFacts?: list<array<string, mixed>>,
* diagnosticFacts?: list<array<string, mixed>>
* }|null $compactCounts
* @return array{
* title: string,
* description?: ?string,
* facts: list<array<string, mixed>>,
* primaryNextStep: array{
* label?: string,
* text: string,
* source: string,
* secondaryGuidance?: list<array{label: string, text: string, source: string}>
* },
* compactCounts?: array{
* summaryLine?: ?string,
* primaryFacts?: list<array<string, mixed>>,
* diagnosticFacts?: list<array<string, mixed>>
* },
* attentionNote?: ?string
* }
*/
public function decisionZone(
array $facts,
array $primaryNextStep,
?string $description = null,
?array $compactCounts = null,
?string $attentionNote = null,
string $title = 'Decision',
): array {
return array_filter([
'title' => $title,
'description' => $description,
'facts' => array_values($facts),
'primaryNextStep' => $primaryNextStep,
'compactCounts' => $compactCounts,
'attentionNote' => $attentionNote,
], static fn (mixed $item): bool => $item !== null);
}
/**
* @param list<array{label: string, text: string, source: string}> $secondaryGuidance
* @return array{
* label: string,
* text: string,
* source: string,
* secondaryGuidance: list<array{label: string, text: string, source: string}>
* }
*/
public function primaryNextStep(string $text, string $source, array $secondaryGuidance = [], string $label = 'Primary next step'): array
{
return [
'label' => $label,
'text' => $text,
'source' => $source,
'secondaryGuidance' => array_values($secondaryGuidance),
];
}
/**
* @param list<array<string, mixed>> $primaryFacts
* @param list<array<string, mixed>> $diagnosticFacts
* @return array{
* summaryLine?: ?string,
* primaryFacts: list<array<string, mixed>>,
* diagnosticFacts: list<array<string, mixed>>
* }
*/
public function countPresentation(?string $summaryLine = null, array $primaryFacts = [], array $diagnosticFacts = []): array
{
return [
'summaryLine' => $summaryLine,
'primaryFacts' => array_values($primaryFacts),
'diagnosticFacts' => array_values($diagnosticFacts),
];
}
/**
* @param list<array<string, mixed>> $items
*/
@ -174,6 +264,7 @@ public function technicalDetail(
bool $visible = true,
bool $collapsible = true,
bool $collapsed = true,
string $variant = 'technical',
): TechnicalDetailData {
return new TechnicalDetailData(
title: $title,
@ -185,6 +276,7 @@ public function technicalDetail(
view: $view,
viewData: $viewData,
emptyState: $emptyState,
variant: $variant,
);
}
}

View File

@ -21,6 +21,7 @@ public function __construct(
public ?string $view = null,
public array $viewData = [],
public ?array $emptyState = null,
public string $variant = 'technical',
) {}
public function shouldRender(): bool
@ -59,6 +60,7 @@ public function toArray(): array
'view' => $this->view,
'viewData' => $this->viewData,
'emptyState' => $this->emptyState,
'variant' => $this->variant,
];
}
}

View File

@ -412,6 +412,13 @@
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'display_name',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_inventory',
'compare_capability' => 'limited',
'capture_capability' => 'limited',
'source_model_expected' => 'inventory',
],
],
],
[
@ -426,6 +433,13 @@
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'display_name',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_inventory',
'compare_capability' => 'limited',
'capture_capability' => 'limited',
'source_model_expected' => 'inventory',
],
],
],
[
@ -440,6 +454,13 @@
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'external_id',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'policy',
],
],
],
[
@ -454,6 +475,13 @@
'baseline_compare' => [
'supported' => false,
'identity_strategy' => 'external_id',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
'compare_capability' => 'unsupported',
'capture_capability' => 'unsupported',
'source_model_expected' => 'policy',
],
],
],
[
@ -468,6 +496,13 @@
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'display_name',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_inventory',
'compare_capability' => 'limited',
'capture_capability' => 'limited',
'source_model_expected' => 'inventory',
],
],
],
],

View File

@ -120,7 +120,7 @@ ### Missing (no code, no spec beyond brainstorming)
## Architecture & Principles (Non-Negotiables)
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.12.0)
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.13.0)
### Core Principles
@ -131,6 +131,7 @@ ### Core Principles
5. **Workspace Isolation** — Non-member → 404; workspace is primary session context. Enforced via `DenyNonMemberTenantAccess` middleware + `EnsureFilamentTenantSelected`.
6. **Tenant Isolation** — Every read/write must be tenant-scoped; `DerivesWorkspaceIdFromTenant` concern auto-fills `workspace_id` from tenant.
7. **Operator Surface Principles**`/admin` defaults are operator-first, diagnostics are progressively disclosed, status dimensions stay distinct, mutation scope is explicit before execution, and every materially changed operator page carries an explicit page contract.
8. **Filament-native first / no ad-hoc styling** — Admin and operator UI must use Filament-native components or shared primitives before any local Blade/Tailwind assembly; page-local status styling is not an acceptable substitute.
### RBAC-UX Rules
@ -158,6 +159,7 @@ ### Filament Standards
- **Action Surface Contract**: List/View/Create/Edit pages each have defined required surfaces (Spec 082, 090).
- **Layout**: Main/Aside layout, sections required, view pages use Infolists.
- **Badge Semantics**: Centralized via `BadgeCatalog`/`BadgeRenderer` (Specs 059, 060).
- **Filament-native UI**: Native Filament components and shared primitives come before any local styling or replacement markup for semantic UI elements.
- **No naked forms**: Everything in sections/cards with proper enterprise IA.
### Provider Gateway

View File

@ -3,7 +3,7 @@ # Product Principles
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
> New specs must align with these. If a principle needs to change, update this file first.
**Last reviewed**: 2026-03-21
**Last reviewed**: 2026-03-26
---
@ -93,6 +93,11 @@ ### Action Surface Contract (non-negotiable)
### Badge semantics centralized
All status badges via `BadgeCatalog` / `BadgeRenderer`. No ad-hoc badge mappings.
### Filament-native first, no ad-hoc styling
Admin and operator UI uses native Filament components or shared primitives first.
No hand-built status chips, alert cards, or local semantic color/border styling when Filament or a central primitive already expresses the meaning.
Any exception must be justified explicitly and stay minimal.
### Canonical navigation and terminology
Consistent naming, consistent routing, consistent mental model.
No competing terms for the same concept.

View File

@ -47,6 +47,25 @@ ### Baseline Drift Engine (Cutover)
**Active specs**: 119 (cutover)
### R1.9 Platform Localization v1 (DE/EN)
UI-Sprache umschaltbar (`de`, `en`) mit sauberem Locale-Foundation-Layer.
Goal: Konsistente, durchgängige Lokalisierung aller Governance-Oberflächen — ohne Brüche in Export, Audit oder Maschinenformaten.
- Locale-Priorität: expliziter Override → User Preference → Workspace Default → System Default
- Workspace Default Language für neue Nutzer, User kann persönliche Sprache überschreiben
- Core-Surfaces zuerst: Navigation, Dashboard, Tenant Views, Findings, Baseline Compare, Risk Exceptions, Alerts, Operations, Audit-nahe Grundtexte
- Canonical Glossary für Governance-Begriffe (Finding, Baseline, Drift, Risk Accepted, Evidence Gap, Run) — konsistente Terminologie über alle Views
- Locale-aware Anzeigeformate für Datum, Uhrzeit, Zahlen und relative Zeiten
- Maschinen- und Exportformate bleiben invariant/stabil (keine lokalisierte Semantik in CSV/JSON/Audit-Artefakten)
- Notifications, E-Mails und operatorseitige Systemtexte nutzen die aufgelöste Locale des Empfängers
- Fallback-Regel: fehlende Übersetzungen fallen kontrolliert auf Englisch zurück; keine leeren/rohen Keys im UI
- Translation-Key Governance für Labels, Actions, Statuswerte, Empty States, Table Filters, Notifications und Validation-/Systemtexte
- HTML/UI i18n-Foundation: korrektes `lang`/Locale-Setup, keine hartcodierten kritischen UI-Strings, layouts sprachrobust
- Search/Sort/Filter auf kritischen Listen für locale-sensitives Verhalten prüfen
- QA/Foundation: Missing-Key Detection, Locale Regression Tests, Pseudolocalization Smoke Tests für kritische Flows
**Active specs**: — (not yet specced)
---
## Planned (Next Quarter)

View File

@ -4,7 +4,7 @@ # Product Standards
> Specs reference these standards; they do not redefine them.
> Guard tests enforce critical constraints automatically.
**Last reviewed**: 2026-03-21
**Last reviewed**: 2026-03-26
---
@ -42,7 +42,7 @@ ## Related Docs
| Document | Location | Purpose |
|---|---|---|
| Constitution | `.specify/memory/constitution.md` | Permanent principles (OPSURF-001, UX-001, Action Surface Contract, RBAC-UX) |
| Constitution | `.specify/memory/constitution.md` | Permanent principles (OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
| Product Principles | `docs/product/principles.md` | High-level product decisions |
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |

View File

@ -12,8 +12,8 @@
// Duplicate-name warning banner
'duplicate_warning_title' => 'Warning',
'duplicate_warning_body_plural' => ':count policies in this tenant share the same display name. :app cannot match them to the baseline. Please rename the duplicates in the Microsoft Intune portal.',
'duplicate_warning_body_singular' => ':count policy in this tenant shares the same display name. :app cannot match it to the baseline. Please rename the duplicate in the Microsoft Intune portal.',
'duplicate_warning_body_plural' => ':count policies in this tenant share generic display names, resulting in :ambiguous_count ambiguous subjects. :app cannot match them safely to the baseline.',
'duplicate_warning_body_singular' => ':count policy in this tenant shares a generic display name, resulting in :ambiguous_count ambiguous subject. :app cannot match it safely to the baseline.',
// Stats card labels
'stat_assigned_baseline' => 'Assigned Baseline',
@ -30,21 +30,37 @@
'badge_evidence_gaps' => 'Evidence gaps: :count',
'evidence_gaps_tooltip' => 'Top gaps: :summary',
'evidence_gap_details_heading' => 'Evidence gap details',
'evidence_gap_details_description' => 'Search recorded gap subjects by reason, policy type, or subject key before falling back to raw diagnostics.',
'evidence_gap_details_description' => 'Search recorded gap subjects by reason, policy type, subject class, outcome, next action, or subject key before falling back to raw diagnostics.',
'evidence_gap_search_label' => 'Search gap details',
'evidence_gap_search_placeholder' => 'Search by reason, policy type, or subject key',
'evidence_gap_search_help' => 'Filter matches across reason, policy type, and subject key.',
'evidence_gap_bucket_help' => 'Reason summaries stay separate from the detailed row table below.',
'evidence_gap_search_placeholder' => 'Search by reason, type, class, outcome, action, or subject key',
'evidence_gap_search_help' => 'Filter matches across reason, policy type, subject class, outcome, next action, and subject key.',
'evidence_gap_bucket_help_ambiguous_match' => 'Multiple inventory records matched the same policy subject — inspect the mapping to confirm the correct pairing.',
'evidence_gap_bucket_help_policy_record_missing' => 'The expected policy record was not found in the baseline snapshot — verify the policy still exists in the tenant.',
'evidence_gap_bucket_help_inventory_record_missing' => 'No inventory record could be matched for these subjects — confirm the inventory sync is current.',
'evidence_gap_bucket_help_foundation_not_policy_backed' => 'These subjects exist in the foundation layer but are not backed by a managed policy — review whether a policy should be created.',
'evidence_gap_bucket_help_capture_failed' => 'Evidence capture failed for these subjects — retry the comparison or check Graph connectivity.',
'evidence_gap_bucket_help_default' => 'These subjects were flagged during comparison — review the affected rows below for details.',
'evidence_gap_reason' => 'Reason',
'evidence_gap_reason_affected' => ':count affected',
'evidence_gap_reason_recorded' => ':count recorded',
'evidence_gap_reason_missing_detail' => ':count missing detail',
'evidence_gap_structural' => 'Structural: :count',
'evidence_gap_operational' => 'Operational: :count',
'evidence_gap_transient' => 'Transient: :count',
'evidence_gap_bucket_structural' => ':count structural',
'evidence_gap_bucket_operational' => ':count operational',
'evidence_gap_bucket_transient' => ':count transient',
'evidence_gap_missing_details_title' => 'Detailed rows were not recorded for this run',
'evidence_gap_missing_details_body' => 'Evidence gaps were counted for this compare run, but subject-level detail was not stored. Review the raw diagnostics below or rerun the comparison for fresh detail.',
'evidence_gap_missing_reason_body' => ':count affected subjects were counted for this reason, but detailed rows were not recorded for this run.',
'evidence_gap_legacy_title' => 'Legacy development gap payload detected',
'evidence_gap_legacy_body' => 'This run still uses the retired broad reason shape. Regenerate the run or purge stale local development payloads before treating the gap details as operator-safe.',
'evidence_gap_diagnostics_heading' => 'Baseline compare evidence',
'evidence_gap_diagnostics_description' => 'Raw diagnostics remain available for support and deep troubleshooting after the operator summary and searchable detail.',
'evidence_gap_policy_type' => 'Policy type',
'evidence_gap_subject_class' => 'Subject class',
'evidence_gap_outcome' => 'Outcome',
'evidence_gap_next_action' => 'Next action',
'evidence_gap_subject_key' => 'Subject key',
'evidence_gap_table_empty_heading' => 'No recorded gap rows match this view',
'evidence_gap_table_empty_description' => 'Adjust the current search or filters to review other affected subjects.',

View File

@ -0,0 +1,69 @@
@php
$decisionZone = $decisionZone ?? [];
$decisionZone = is_array($decisionZone) ? $decisionZone : [];
$facts = array_values(array_filter($decisionZone['facts'] ?? [], 'is_array'));
$primaryNextStep = is_array($decisionZone['primaryNextStep'] ?? null) ? $decisionZone['primaryNextStep'] : null;
$compactCounts = is_array($decisionZone['compactCounts'] ?? null) ? $decisionZone['compactCounts'] : null;
$countFacts = array_values(array_filter($compactCounts['primaryFacts'] ?? [], 'is_array'));
$attentionNote = is_string($decisionZone['attentionNote'] ?? null) && trim($decisionZone['attentionNote']) !== ''
? trim($decisionZone['attentionNote'])
: null;
@endphp
<x-filament::section
:heading="$decisionZone['title'] ?? 'Decision'"
:description="$decisionZone['description'] ?? 'Start here to see how the run ended, whether the result is usable, and what to do next.'"
>
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(18rem,1fr)]">
<div class="space-y-4">
@if ($attentionNote !== null)
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ $attentionNote }}
</div>
@endif
@if ($facts !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $facts,
'variant' => 'summary',
])
@endif
</div>
<div class="space-y-4">
@if ($primaryNextStep !== null)
<div class="rounded-xl border-l-4 border-primary-500 bg-primary-50 px-4 py-4 dark:bg-primary-500/10">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-primary-600 dark:text-primary-400">
{{ $primaryNextStep['label'] ?? 'Primary next step' }}
</div>
<div class="mt-2 text-base font-semibold text-gray-950 dark:text-white">
{{ $primaryNextStep['text'] ?? 'No action needed.' }}
</div>
</div>
@endif
@if (filled($compactCounts['summaryLine'] ?? null) || $countFacts !== [])
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-4 dark:border-gray-700 dark:bg-gray-800/50">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
Counts
</div>
<div class="mt-2 space-y-4">
@if (filled($compactCounts['summaryLine'] ?? null))
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $compactCounts['summaryLine'] }}
</div>
@endif
@if ($countFacts !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $countFacts,
'variant' => 'supporting',
])
@endif
</div>
</div>
@endif
</div>
</div>
</x-filament::section>

View File

@ -9,9 +9,45 @@
$primaryActions = array_values(array_filter($header['primaryActions'] ?? [], 'is_array'));
@endphp
<div class="rounded-2xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
<div class="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-3">
<x-filament::section
:heading="$header['title'] ?? 'Detail'"
:description="$header['subtitle'] ?? null"
>
@if ($primaryActions !== [])
<x-slot name="afterHeader">
<div class="flex flex-wrap items-center gap-2">
@foreach ($primaryActions as $action)
@if (filled($action['url'] ?? null))
@if (($action['openInNewTab'] ?? false) === true)
<x-filament::button
tag="a"
size="sm"
:color="($action['destructive'] ?? false) === true ? 'danger' : 'gray'"
:href="$action['url']"
:icon="$action['icon'] ?? null"
target="_blank"
rel="noreferrer noopener"
>
{{ $action['label'] }}
</x-filament::button>
@else
<x-filament::button
tag="a"
size="sm"
:color="($action['destructive'] ?? false) === true ? 'danger' : 'gray'"
:href="$action['url']"
:icon="$action['icon'] ?? null"
>
{{ $action['label'] }}
</x-filament::button>
@endif
@endif
@endforeach
</div>
</x-slot>
@endif
<div class="space-y-4">
@if ($statusBadges !== [])
<div class="flex flex-wrap items-center gap-2">
@foreach ($statusBadges as $badge)
@ -26,81 +62,17 @@
</div>
@endif
<div class="space-y-1">
<div class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
{{ $header['title'] ?? 'Detail' }}
</div>
@if (filled($header['subtitle'] ?? null))
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $header['subtitle'] }}
</div>
@endif
@if (filled($header['descriptionHint'] ?? null))
<div class="max-w-3xl text-sm text-gray-600 dark:text-gray-300">
{{ $header['descriptionHint'] }}
</div>
@endif
</div>
</div>
@if ($primaryActions !== [])
<div class="flex flex-wrap items-center gap-2">
@foreach ($primaryActions as $action)
@if (filled($action['url'] ?? null))
<a
href="{{ $action['url'] }}"
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ ($action['destructive'] ?? false) === true ? 'border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200' : 'border-gray-300 bg-gray-50 text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800' }}"
>
@if (filled($action['icon'] ?? null))
<x-filament::icon :icon="$action['icon']" class="h-4 w-4" />
@endif
{{ $action['label'] }}
</a>
@endif
@endforeach
</div>
@endif
</div>
@if ($keyFacts !== [])
<div class="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($keyFacts as $fact)
@php
$displayValue = FactPresentation::value($fact);
$badge = is_array($fact['badge'] ?? null) ? $fact['badge'] : null;
@endphp
<div class="rounded-xl border border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/40">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{{ $fact['label'] ?? 'Fact' }}
</div>
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
@if ($displayValue !== null)
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
@endif
@if ($badge !== null)
<x-filament::badge
:color="$badge['color'] ?? 'gray'"
:icon="$badge['icon'] ?? null"
:icon-color="$badge['iconColor'] ?? null"
size="sm"
>
{{ $badge['label'] ?? 'State' }}
</x-filament::badge>
@endif
</div>
@if (filled($fact['hint'] ?? null))
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $fact['hint'] }}
</div>
@endif
</div>
@endforeach
</div>
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $keyFacts,
'variant' => 'header',
])
@endif
</div>
</x-filament::section>

View File

@ -2,8 +2,9 @@
$detail = isset($getState) ? $getState() : ($detail ?? null);
$detail = is_array($detail) ? $detail : [];
$decisionZone = is_array($detail['decisionZone'] ?? null) ? $detail['decisionZone'] : [];
$mainSections = array_values(array_filter($detail['mainSections'] ?? [], 'is_array'));
$supportingCards = array_values(array_filter($detail['supportingCards'] ?? [], 'is_array'));
$supportingGroups = array_values(array_filter($detail['supportingGroups'] ?? [], 'is_array'));
$technicalSections = array_values(array_filter($detail['technicalSections'] ?? [], 'is_array'));
$emptyStateNotes = array_values(array_filter($detail['emptyStateNotes'] ?? [], 'is_array'));
@endphp
@ -13,6 +14,12 @@
'header' => is_array($detail['header'] ?? null) ? $detail['header'] : [],
])
@if ($decisionZone !== [])
@include('filament.infolists.entries.enterprise-detail.decision-zone', [
'decisionZone' => $decisionZone,
])
@endif
@if ($emptyStateNotes !== [])
<div class="space-y-3">
@foreach ($emptyStateNotes as $state)
@ -21,8 +28,15 @@
</div>
@endif
<div class="grid gap-6 xl:grid-cols-3">
<div class="{{ $supportingCards === [] ? 'xl:col-span-3' : 'xl:col-span-2' }} space-y-6">
@if ($supportingGroups !== [])
<div class="grid gap-4 xl:grid-cols-2">
@foreach ($supportingGroups as $card)
@include('filament.infolists.entries.enterprise-detail.supporting-card', ['card' => $card])
@endforeach
</div>
@endif
<div class="space-y-6">
@foreach ($mainSections as $section)
@php
$view = is_string($section['view'] ?? null) && trim($section['view']) !== '' ? trim($section['view']) : null;
@ -50,15 +64,6 @@
@endforeach
</div>
@if ($supportingCards !== [])
<aside class="space-y-4">
@foreach ($supportingCards as $card)
@include('filament.infolists.entries.enterprise-detail.supporting-card', ['card' => $card])
@endforeach
</aside>
@endif
</div>
@if ($technicalSections !== [])
<div class="space-y-4">
@foreach ($technicalSections as $section)

View File

@ -5,23 +5,44 @@
$items = is_array($items) ? array_values(array_filter($items, 'is_array')) : [];
$action = $action ?? null;
$action = is_array($action) ? $action : null;
$variant = is_string($variant ?? null) && trim($variant) !== '' ? trim($variant) : 'default';
$gridClasses = match ($variant) {
'header' => 'grid gap-3 sm:grid-cols-2 xl:grid-cols-4',
'summary' => 'grid gap-3 lg:grid-cols-2',
'supporting' => 'grid gap-3 sm:grid-cols-2',
'diagnostic' => 'grid gap-3 sm:grid-cols-2 xl:grid-cols-4',
'technical' => 'grid gap-3 sm:grid-cols-2 xl:grid-cols-3',
default => 'grid gap-3 sm:grid-cols-2',
};
$cardClasses = match ($variant) {
'summary' => 'rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900',
default => 'rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/30',
};
@endphp
<div class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div class="{{ $gridClasses }}">
@foreach ($items as $item)
@php
$displayValue = FactPresentation::value($item);
$badge = is_array($item['badge'] ?? null) ? $item['badge'] : null;
$tone = is_string($item['tone'] ?? null) ? $item['tone'] : null;
$mono = (bool) ($item['mono'] ?? false);
$toneValueClasses = match ($tone) {
'danger' => 'text-danger-600 dark:text-danger-400',
'success' => 'text-success-600 dark:text-success-400',
'warning' => 'text-warning-600 dark:text-warning-400',
default => 'text-gray-900 dark:text-white',
};
@endphp
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/30">
<div class="{{ $cardClasses }}">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{{ $item['label'] ?? 'Detail' }}
</div>
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-sm font-medium {{ $toneValueClasses }}">
@if ($displayValue !== null)
<span class="min-w-0 break-all whitespace-normal">{{ $displayValue }}</span>
<span class="min-w-0 break-all whitespace-normal {{ $mono ? 'font-mono text-xs' : '' }}">{{ $displayValue }}</span>
@endif
@if ($badge !== null)
@ -47,16 +68,25 @@
@if ($action !== null && filled($action['url'] ?? null))
<div>
<a
href="{{ $action['url'] }}"
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
class="inline-flex items-center gap-2 text-sm font-medium {{ ($action['destructive'] ?? false) === true ? 'text-rose-700 hover:text-rose-600 dark:text-rose-200 dark:hover:text-rose-100' : 'text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300' }}"
@if (($action['openInNewTab'] ?? false) === true)
<x-filament::link
:href="$action['url']"
:icon="$action['icon'] ?? null"
size="sm"
target="_blank"
rel="noreferrer noopener"
>
@if (filled($action['icon'] ?? null))
<x-filament::icon :icon="$action['icon']" class="h-4 w-4" />
@endif
{{ $action['label'] }}
</a>
</x-filament::link>
@else
<x-filament::link
:href="$action['url']"
:icon="$action['icon'] ?? null"
size="sm"
>
{{ $action['label'] }}
</x-filament::link>
@endif
</div>
@endif
</div>

View File

@ -6,21 +6,45 @@
$items = is_array($card['items'] ?? null) ? $card['items'] : [];
$emptyState = is_array($card['emptyState'] ?? null) ? $card['emptyState'] : null;
$action = is_array($card['action'] ?? null) ? $card['action'] : null;
$eyebrow = match ($card['kind'] ?? null) {
'guidance' => 'Guidance',
'lifecycle' => 'Lifecycle',
'timing' => 'Timing',
'metadata' => 'Metadata',
default => 'Supporting detail',
};
@endphp
<div class="space-y-2" @if (filled($card['kind'] ?? null)) data-supporting-group-kind="{{ $card['kind'] }}" @endif>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
{{ $eyebrow }}
</div>
<x-filament::section
:heading="$card['title'] ?? 'Supporting detail'"
:description="$card['description'] ?? null"
>
@if ($action !== null && filled($action['url'] ?? null))
<x-slot name="headerEnd">
<a
href="{{ $action['url'] }}"
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text-rose-700 hover:text-rose-600 dark:text-rose-200 dark:hover:text-rose-100' : 'text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300' }}"
<x-slot name="afterHeader">
@if (($action['openInNewTab'] ?? false) === true)
<x-filament::link
:href="$action['url']"
:icon="$action['icon'] ?? null"
size="sm"
target="_blank"
rel="noreferrer noopener"
>
{{ $action['label'] }}
</a>
</x-filament::link>
@else
<x-filament::link
:href="$action['url']"
:icon="$action['icon'] ?? null"
size="sm"
>
{{ $action['label'] }}
</x-filament::link>
@endif
</x-slot>
@endif
@ -28,9 +52,13 @@ class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text
@if ($view !== null)
{!! view($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])->render() !!}
@elseif ($items !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $items])
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $items,
'variant' => 'supporting',
])
@elseif ($emptyState !== null)
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
@endif
</div>
</x-filament::section>
</div>

View File

@ -14,17 +14,16 @@
:collapsed="(bool) ($section['collapsed'] ?? true)"
>
@if ($entries !== [])
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $entries])
@include('filament.infolists.entries.enterprise-detail.section-items', [
'items' => $entries,
'variant' => is_string($section['variant'] ?? null) ? $section['variant'] : 'technical',
])
@endif
@if ($view !== null)
@if ($entries !== [])
<div class="mt-4">
<div @class(['mt-4' => $entries !== []])>
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
</div>
@else
{!! view($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])->render() !!}
@endif
@elseif ($emptyState !== null)
<div @class(['mt-4' => $entries !== []])>
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])

View File

@ -2,10 +2,24 @@
$summary = is_array($summary ?? null) ? $summary : [];
$buckets = is_array($buckets ?? null) ? $buckets : [];
$detailState = is_string($summary['detail_state'] ?? null) ? $summary['detail_state'] : 'no_gaps';
$structuralCount = is_numeric($summary['structural_count'] ?? null) ? (int) $summary['structural_count'] : 0;
$operationalCount = is_numeric($summary['operational_count'] ?? null) ? (int) $summary['operational_count'] : 0;
$transientCount = is_numeric($summary['transient_count'] ?? null) ? (int) $summary['transient_count'] : 0;
$tableContext = is_string($searchId ?? null) && $searchId !== '' ? $searchId : 'evidence-gap-search';
@endphp
@if ($detailState === 'details_not_recorded' && $buckets === [])
@if ($detailState === 'legacy_broad_reason')
<div class="rounded-xl border border-danger-300 bg-danger-50/80 p-4 dark:border-danger-800 dark:bg-danger-950/30">
<div class="space-y-1">
<div class="text-sm font-semibold text-danger-950 dark:text-danger-100">
{{ __('baseline-compare.evidence_gap_legacy_title') }}
</div>
<p class="text-sm text-danger-900 dark:text-danger-200">
{{ __('baseline-compare.evidence_gap_legacy_body') }}
</p>
</div>
</div>
@elseif ($detailState === 'details_not_recorded' && $buckets === [])
<div class="rounded-xl border border-warning-300 bg-warning-50/80 p-4 dark:border-warning-800 dark:bg-warning-950/30">
<div class="space-y-1">
<div class="text-sm font-semibold text-warning-950 dark:text-warning-100">
@ -18,6 +32,20 @@
</div>
@elseif ($buckets !== [])
<div class="space-y-4">
@if ($detailState === 'structured_details_recorded' && ($structuralCount > 0 || $operationalCount > 0 || $transientCount > 0))
<div class="flex flex-wrap gap-2">
<x-filament::badge color="danger" size="sm">
{{ __('baseline-compare.evidence_gap_structural', ['count' => $structuralCount]) }}
</x-filament::badge>
<x-filament::badge color="primary" size="sm">
{{ __('baseline-compare.evidence_gap_operational', ['count' => $operationalCount]) }}
</x-filament::badge>
<x-filament::badge color="warning" size="sm">
{{ __('baseline-compare.evidence_gap_transient', ['count' => $transientCount]) }}
</x-filament::badge>
</div>
@endif
@if ($detailState === 'details_not_recorded')
<div class="rounded-xl border border-warning-300 bg-warning-50/80 p-4 dark:border-warning-800 dark:bg-warning-950/30">
<div class="space-y-1">
@ -35,9 +63,14 @@
@foreach ($buckets as $bucket)
@php
$reasonLabel = is_string($bucket['reason_label'] ?? null) ? $bucket['reason_label'] : 'Evidence gap';
$reasonCode = is_string($bucket['reason_code'] ?? null) ? $bucket['reason_code'] : 'default';
$count = is_numeric($bucket['count'] ?? null) ? (int) $bucket['count'] : 0;
$recordedCount = is_numeric($bucket['recorded_count'] ?? null) ? (int) $bucket['recorded_count'] : 0;
$missingDetailCount = is_numeric($bucket['missing_detail_count'] ?? null) ? (int) $bucket['missing_detail_count'] : 0;
$bucketHelpKey = 'baseline-compare.evidence_gap_bucket_help_'.$reasonCode;
$bucketHelp = __($bucketHelpKey) !== $bucketHelpKey
? __($bucketHelpKey)
: __('baseline-compare.evidence_gap_bucket_help_default');
@endphp
<section class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
@ -47,21 +80,36 @@
{{ $reasonLabel }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ __('baseline-compare.evidence_gap_bucket_help') }}
{{ $bucketHelp }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center rounded-full bg-warning-100 px-2.5 py-1 text-xs font-medium text-warning-900 dark:bg-warning-900/40 dark:text-warning-100">
<x-filament::badge color="warning" size="sm">
{{ __('baseline-compare.evidence_gap_reason_affected', ['count' => $count]) }}
</span>
<span class="inline-flex items-center rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-900 dark:bg-primary-900/40 dark:text-primary-100">
</x-filament::badge>
<x-filament::badge color="primary" size="sm">
{{ __('baseline-compare.evidence_gap_reason_recorded', ['count' => $recordedCount]) }}
</span>
</x-filament::badge>
@if ($missingDetailCount > 0)
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
<x-filament::badge color="gray" size="sm">
{{ __('baseline-compare.evidence_gap_reason_missing_detail', ['count' => $missingDetailCount]) }}
</span>
</x-filament::badge>
@endif
@if ((int) ($bucket['structural_count'] ?? 0) > 0)
<x-filament::badge color="danger" size="sm">
{{ __('baseline-compare.evidence_gap_bucket_structural', ['count' => (int) $bucket['structural_count']]) }}
</x-filament::badge>
@endif
@if ((int) ($bucket['operational_count'] ?? 0) > 0)
<x-filament::badge color="primary" size="sm">
{{ __('baseline-compare.evidence_gap_bucket_operational', ['count' => (int) $bucket['operational_count']]) }}
</x-filament::badge>
@endif
@if ((int) ($bucket['transient_count'] ?? 0) > 0)
<x-filament::badge color="warning" size="sm">
{{ __('baseline-compare.evidence_gap_bucket_transient', ['count' => (int) $bucket['transient_count']]) }}
</x-filament::badge>
@endif
</div>

View File

@ -32,6 +32,7 @@
$reason = is_array($state['reason'] ?? null) ? $state['reason'] : [];
$nextSteps = is_array($reason['nextSteps'] ?? null) ? $reason['nextSteps'] : [];
$operatorExplanation = is_array($state['operatorExplanation'] ?? null) ? $state['operatorExplanation'] : [];
$surface = is_string($surface ?? null) && trim($surface) !== '' ? trim($surface) : 'summary';
$evaluationSpec = is_string($operatorExplanation['evaluationResult'] ?? null)
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationEvaluationResult, $operatorExplanation['evaluationResult'])
: null;
@ -39,8 +40,187 @@
? BadgeCatalog::spec(BadgeDomain::OperatorExplanationTrustworthiness, $operatorExplanation['trustworthinessLevel'])
: null;
$operatorCounts = collect(is_array($operatorExplanation['countDescriptors'] ?? null) ? $operatorExplanation['countDescriptors'] : []);
$normalizeArtifactTruthText = static function (mixed $value): ?string {
if (! is_string($value)) {
return null;
}
$value = trim((string) preg_replace('/\s+/', ' ', $value));
return $value !== '' ? $value : null;
};
$uniqueArtifactTruthParagraphs = static function (array $values) use ($normalizeArtifactTruthText): array {
$paragraphs = [];
$seen = [];
foreach ($values as $value) {
$normalized = $normalizeArtifactTruthText($value);
if ($normalized === null) {
continue;
}
$key = mb_strtolower($normalized);
if (array_key_exists($key, $seen)) {
continue;
}
$seen[$key] = true;
$paragraphs[] = $normalized;
}
return $paragraphs;
};
$decisionSummaryArtifactTruthParagraph = $normalizeArtifactTruthText(
$operatorExplanation['reliabilityStatement'] ?? ($state['primaryExplanation'] ?? null)
);
$expandedArtifactTruthParagraphs = $uniqueArtifactTruthParagraphs([
$state['primaryExplanation'] ?? null,
$operatorExplanation['reliabilityStatement'] ?? null,
data_get($operatorExplanation, 'dominantCause.explanation'),
]);
$expandedArtifactTruthParagraphs = array_values(array_filter(
$expandedArtifactTruthParagraphs,
static fn (string $paragraph): bool => $decisionSummaryArtifactTruthParagraph === null
|| mb_strtolower($paragraph) !== mb_strtolower($decisionSummaryArtifactTruthParagraph),
));
$summaryArtifactTruthParagraphs = $uniqueArtifactTruthParagraphs([
$operatorExplanation['reliabilityStatement'] ?? ($state['primaryExplanation'] ?? null),
data_get($operatorExplanation, 'dominantCause.explanation'),
]);
@endphp
@if ($surface === 'expanded')
<div class="space-y-4">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
Detailed artifact-truth context
</div>
<div class="mt-3 space-y-2">
@foreach ($expandedArtifactTruthParagraphs as $index => $paragraph)
<p class="text-sm {{ $index === 0 ? 'text-gray-700 dark:text-gray-200' : 'text-gray-600 dark:text-gray-300' }}">
{{ $paragraph }}
</p>
@endforeach
@if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '')
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Coverage: {{ $operatorExplanation['coverageStatement'] }}
</p>
@endif
@if (is_string($state['diagnosticLabel'] ?? null) && trim($state['diagnosticLabel']) !== '')
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Diagnostic: {{ $state['diagnosticLabel'] }}
</p>
@endif
</div>
</div>
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@if ($evaluationSpec && $evaluationSpec->label !== 'Unknown')
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result meaning</dt>
<dd class="mt-1">
<x-filament::badge :color="$evaluationSpec->color" :icon="$evaluationSpec->icon" size="sm">
{{ $evaluationSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($trustSpec && $trustSpec->label !== 'Unknown')
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Result trust</dt>
<dd class="mt-1">
<x-filament::badge :color="$trustSpec->color" :icon="$trustSpec->icon" size="sm">
{{ $trustSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($primarySpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Primary artifact state</dt>
<dd class="mt-1">
<x-filament::badge :color="$primarySpec->color" :icon="$primarySpec->icon" size="sm">
{{ $primarySpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($actionabilitySpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Actionability</dt>
<dd class="mt-1">
<x-filament::badge :color="$actionabilitySpec->color" :icon="$actionabilitySpec->icon" size="sm">
{{ $actionabilitySpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($existenceSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Artifact exists</dt>
<dd class="mt-1">
<x-filament::badge :color="$existenceSpec->color" :icon="$existenceSpec->icon" size="sm">
{{ $existenceSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($freshnessSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness</dt>
<dd class="mt-1">
<x-filament::badge :color="$freshnessSpec->color" :icon="$freshnessSpec->icon" size="sm">
{{ $freshnessSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
@if ($publicationSpec)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication</dt>
<dd class="mt-1">
<x-filament::badge :color="$publicationSpec->color" :icon="$publicationSpec->icon" size="sm">
{{ $publicationSpec->label }}
</x-filament::badge>
</dd>
</div>
@endif
</dl>
@if ($operatorCounts->isNotEmpty())
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($operatorCounts as $count)
@continue(! is_array($count))
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $count['label'] ?? 'Count' }}
</div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ (int) ($count['value'] ?? 0) }}
</div>
@if (filled($count['qualifier'] ?? null))
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $count['qualifier'] }}
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
@else
<div class="space-y-4">
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start gap-2">
@ -74,21 +254,11 @@
{{ $operatorExplanation['headline'] ?? ($state['primaryLabel'] ?? 'Artifact truth') }}
</div>
@if (is_string($operatorExplanation['reliabilityStatement'] ?? null) && trim($operatorExplanation['reliabilityStatement']) !== '')
@foreach ($summaryArtifactTruthParagraphs as $paragraph)
<p class="text-sm text-gray-600 dark:text-gray-300">
{{ $operatorExplanation['reliabilityStatement'] }}
{{ $paragraph }}
</p>
@elseif (is_string($state['primaryExplanation'] ?? null) && trim($state['primaryExplanation']) !== '')
<p class="text-sm text-gray-600 dark:text-gray-300">
{{ $state['primaryExplanation'] }}
</p>
@endif
@if (is_string(data_get($operatorExplanation, 'dominantCause.explanation')) && trim(data_get($operatorExplanation, 'dominantCause.explanation')) !== '')
<p class="text-sm text-gray-600 dark:text-gray-300">
{{ data_get($operatorExplanation, 'dominantCause.explanation') }}
</p>
@endif
@endforeach
@if (is_string($operatorExplanation['coverageStatement'] ?? null) && trim($operatorExplanation['coverageStatement']) !== '')
<p class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
@ -194,3 +364,4 @@
</div>
@endif
</div>
@endif

View File

@ -27,9 +27,9 @@
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-1">
@if ($isAvailable)
<a href="{{ $entry['targetUrl'] }}" class="text-sm font-semibold text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400">
<x-filament::link :href="$entry['targetUrl']">
{{ $entry['value'] ?? 'Open related record' }}
</a>
</x-filament::link>
@else
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $entry['value'] ?? 'Unavailable' }}
@ -63,12 +63,13 @@
@endunless
@if ($isAvailable && filled($entry['actionLabel'] ?? null))
<a
href="{{ $entry['targetUrl'] }}"
class="text-xs font-medium text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400"
<x-filament::link
:href="$entry['targetUrl']"
icon="heroicon-m-arrow-top-right-on-square"
size="sm"
>
{{ $entry['actionLabel'] }}
</a>
</x-filament::link>
@endif
</div>
</div>

View File

@ -6,6 +6,7 @@
@php
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
$explanationCounts = collect(is_array($explanation['countDescriptors'] ?? null) ? $explanation['countDescriptors'] : []);
$evaluationSpec = is_string($explanation['evaluationResult'] ?? null)
@ -27,6 +28,7 @@
<div class="text-sm text-warning-800 dark:text-warning-300">
{{ __($duplicateNamePoliciesCountValue === 1 ? 'baseline-compare.duplicate_warning_body_singular' : 'baseline-compare.duplicate_warning_body_plural', [
'count' => $duplicateNamePoliciesCountValue,
'ambiguous_count' => $duplicateNameSubjectsCountValue,
'app' => config('app.name', 'TenantPilot'),
]) }}
</div>

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Baseline Subject Resolution and Evidence Gap Semantics Foundation
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-24
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/163-baseline-subject-resolution/spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated on first pass.
- The spec intentionally establishes root-cause and runtime-support semantics only; fidelity richness, renderer density, and wording refinement remain deferred follow-on work.

View File

@ -0,0 +1,334 @@
openapi: 3.1.0
info:
title: Baseline Subject Resolution Semantics Contract
version: 0.1.0
description: >-
Read-model and validation contracts for baseline compare and capture subject-resolution
semantics. This contract documents the payloads existing operator surfaces consume after
the foundation upgrade and the capability matrix used to keep baseline support truthful
at runtime.
servers:
- url: https://tenantpilot.local
paths:
/admin/api/operations/{operationRun}/baseline-gap-semantics:
get:
summary: Get structured baseline gap semantics for an operation run
operationId: getBaselineGapSemanticsForRun
parameters:
- name: operationRun
in: path
required: true
schema:
type: integer
responses:
'200':
description: Structured baseline gap semantics for the run
content:
application/json:
schema:
$ref: '#/components/schemas/BaselineGapSemanticsResponse'
'404':
description: Run not found or not visible in current workspace or tenant scope
'403':
description: Caller is a member but lacks permission to inspect the run
/admin/api/tenants/{tenant}/baseline-compare/latest-gap-semantics:
get:
summary: Get latest baseline compare gap semantics for a tenant and profile
operationId: getLatestTenantBaselineCompareGapSemantics
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: baseline_profile_id
in: query
required: true
schema:
type: integer
responses:
'200':
description: Latest tenant baseline compare semantics projection
content:
application/json:
schema:
$ref: '#/components/schemas/BaselineGapSemanticsResponse'
'404':
description: Tenant or run not found for current entitled scope
'403':
description: Caller lacks compare or review visibility within the established tenant scope
/admin/api/baseline-support/resolution-capabilities:
get:
summary: Get runtime baseline support and resolution capability matrix
operationId: getBaselineResolutionCapabilities
responses:
'200':
description: Capability matrix used to validate truthful compare and capture support
content:
application/json:
schema:
type: object
required:
- data
properties:
data:
type: array
items:
$ref: '#/components/schemas/SupportCapabilityRecord'
components:
schemas:
BaselineGapSemanticsResponse:
type: object
required:
- summary
- buckets
properties:
summary:
$ref: '#/components/schemas/BaselineGapSummary'
buckets:
type: array
items:
$ref: '#/components/schemas/BaselineGapBucket'
BaselineGapSummary:
type: object
required:
- count
- by_reason
- detail_state
- recorded_subjects_total
- missing_detail_count
- structural_count
- operational_count
- transient_count
- legacy_mode
- requires_regeneration
properties:
count:
type: integer
minimum: 0
by_reason:
type: object
additionalProperties:
type: integer
minimum: 0
detail_state:
type: string
enum:
- no_gaps
- structured_details_recorded
- details_not_recorded
- legacy_broad_reason
recorded_subjects_total:
type: integer
minimum: 0
missing_detail_count:
type: integer
minimum: 0
structural_count:
type: integer
minimum: 0
operational_count:
type: integer
minimum: 0
transient_count:
type: integer
minimum: 0
legacy_mode:
type: boolean
requires_regeneration:
type: boolean
BaselineGapBucket:
type: object
required:
- reason_code
- reason_label
- count
- recorded_count
- missing_detail_count
- structural_count
- operational_count
- transient_count
- detail_state
- search_text
- rows
properties:
reason_code:
type: string
reason_label:
type: string
count:
type: integer
minimum: 0
recorded_count:
type: integer
minimum: 0
missing_detail_count:
type: integer
minimum: 0
structural_count:
type: integer
minimum: 0
operational_count:
type: integer
minimum: 0
transient_count:
type: integer
minimum: 0
detail_state:
type: string
enum:
- structured_details_recorded
- details_not_recorded
- legacy_broad_reason
search_text:
type: string
rows:
type: array
items:
$ref: '#/components/schemas/EvidenceGapDetailRecord'
EvidenceGapDetailRecord:
type: object
required:
- policy_type
- subject_key
- subject_class
- resolution_path
- resolution_outcome
- reason_code
- operator_action_category
- structural
- retryable
properties:
policy_type:
type: string
subject_external_id:
type:
- string
- 'null'
subject_key:
type: string
subject_class:
$ref: '#/components/schemas/SubjectClass'
resolution_path:
$ref: '#/components/schemas/ResolutionPath'
resolution_outcome:
$ref: '#/components/schemas/ResolutionOutcome'
reason_code:
type: string
operator_action_category:
$ref: '#/components/schemas/OperatorActionCategory'
structural:
type: boolean
retryable:
type: boolean
source_model_expected:
type:
- string
- 'null'
enum:
- policy
- inventory
- derived
- 'null'
source_model_found:
type:
- string
- 'null'
enum:
- policy
- inventory
- derived
- 'null'
legacy_reason_code:
type:
- string
- 'null'
SupportCapabilityRecord:
type: object
required:
- policy_type
- subject_class
- compare_capability
- capture_capability
- resolution_path
- config_supported
- runtime_valid
properties:
policy_type:
type: string
subject_class:
$ref: '#/components/schemas/SubjectClass'
compare_capability:
type: string
enum:
- supported
- limited
- unsupported
capture_capability:
type: string
enum:
- supported
- limited
- unsupported
resolution_path:
$ref: '#/components/schemas/ResolutionPath'
config_supported:
type: boolean
runtime_valid:
type: boolean
SubjectClass:
type: string
enum:
- policy_backed
- inventory_backed
- foundation_backed
- derived
ResolutionPath:
type: string
enum:
- policy
- inventory
- foundation_policy
- foundation_inventory
- derived
- unsupported
ResolutionOutcome:
type: string
enum:
- resolved_policy
- resolved_inventory
- policy_record_missing
- inventory_record_missing
- foundation_inventory_only
- resolution_type_mismatch
- unresolvable_subject
- permission_or_scope_blocked
- retryable_capture_failure
- capture_failed
- throttled
- budget_exhausted
- ambiguous_match
- invalid_subject
- duplicate_subject
- invalid_support_config
OperatorActionCategory:
type: string
enum:
- none
- retry
- run_inventory_sync
- run_policy_sync_or_backup
- review_permissions
- inspect_subject_mapping
- product_follow_up

View File

@ -0,0 +1,167 @@
# Data Model: Baseline Subject Resolution and Evidence Gap Semantics Foundation
## Overview
This feature does not require a new primary database table. It introduces a richer logical model for subject classification and resolution, then persists that model inside existing compare and capture run context for new runs. Development-only run payloads using the old broad reason shape may be removed or regenerated instead of preserved through a compatibility contract.
## Entity: SubjectDescriptor
Business-level descriptor for a compare or capture target before local resolution.
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `policy_type` | string | yes | Canonical policy or foundation type from support metadata |
| `subject_external_id` | string | no | Stable local or tenant-local external identifier when available |
| `subject_key` | string | yes | Deterministic comparison key used across capture and compare |
| `subject_class` | enum | yes | One of `policy_backed`, `inventory_backed`, `foundation_backed`, `derived` |
| `resolution_path` | enum | yes | Intended local path, such as `policy`, `inventory`, `foundation_policy`, `foundation_inventory`, or `derived` |
| `support_mode` | enum | yes | `supported`, `limited`, `excluded`, or `invalid_support_config` |
| `source_model_expected` | enum | no | Which local model is expected to satisfy the lookup |
### Validation Rules
- `policy_type` must exist in canonical support metadata.
- `subject_class` must be one of the supported subject-class values.
- `resolution_path` must be compatible with `subject_class`.
- `support_mode=invalid_support_config` is only valid when metadata claims support but no truthful runtime path exists.
## Entity: ResolutionOutcomeRecord
Deterministic result of attempting to resolve a `SubjectDescriptor` against tenant-local state.
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `resolution_outcome` | enum | yes | Precise outcome such as `resolved_policy`, `resolved_inventory`, `policy_record_missing`, `inventory_record_missing`, `foundation_inventory_only`, `resolution_type_mismatch`, `unresolvable_subject`, `permission_or_scope_blocked`, `retryable_capture_failure`, `capture_failed`, `throttled`, or `budget_exhausted` |
| `reason_code` | string | yes | Stable operator-facing reason family persisted with the run |
| `operator_action_category` | enum | yes | `none`, `retry`, `run_inventory_sync`, `run_policy_sync_or_backup`, `review_permissions`, `inspect_subject_mapping`, or `product_follow_up` |
| `structural` | boolean | yes | Whether the outcome is structural rather than operational or transient |
| `retryable` | boolean | yes | Whether retry may change the outcome without prerequisite changes |
| `source_model_found` | enum or null | no | Which local model actually satisfied the lookup when resolution succeeded |
### State Families
| Family | Outcomes |
|---|---|
| `resolved` | `resolved_policy`, `resolved_inventory` |
| `structural` | `foundation_inventory_only`, `resolution_type_mismatch`, `unresolvable_subject`, `invalid_support_config` |
| `operational` | `policy_record_missing`, `inventory_record_missing`, `permission_or_scope_blocked`, `ambiguous_match`, `invalid_subject`, `duplicate_subject` |
| `transient` | `retryable_capture_failure`, `throttled`, `budget_exhausted`, `capture_failed` |
### Validation Rules
- `resolution_outcome` must map to exactly one state family.
- `structural=true` is only valid for structural state-family outcomes.
- `retryable=true` is only valid for transient outcomes or explicitly retryable operational outcomes.
## Entity: SupportCapabilityRecord
Runtime truth contract for whether a subject type may enter baseline compare or capture.
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `policy_type` | string | yes | Canonical type key |
| `subject_class` | enum | yes | Dominant subject class for the type |
| `compare_capability` | enum | yes | `supported`, `limited`, or `unsupported` |
| `capture_capability` | enum | yes | `supported`, `limited`, or `unsupported` |
| `resolution_path` | enum | yes | Truthful runtime resolution path |
| `config_supported` | boolean | yes | Whether metadata claims support |
| `runtime_valid` | boolean | yes | Whether the runtime can honor that support claim |
### Validation Rules
- `config_supported=true` and `runtime_valid=false` must be surfaced as `invalid_support_config` rather than silently ignored.
- Types with `compare_capability=unsupported` must not enter compare scope.
- Types with `capture_capability=unsupported` must not enter capture execution.
## Entity: EvidenceGapDetailRecord
Structured subject-level record persisted under compare or capture run context for new runs.
### Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `policy_type` | string | yes | Canonical type |
| `subject_external_id` | string or null | no | Stable identifier when available |
| `subject_key` | string | yes | Deterministic subject identity |
| `subject_class` | enum | yes | Classified subject class |
| `resolution_path` | enum | yes | Path attempted or declared |
| `resolution_outcome` | enum | yes | Deterministic resolution result |
| `reason_code` | string | yes | Stable reason family |
| `operator_action_category` | enum | yes | Recommended next action family |
| `structural` | boolean | yes | Structural versus non-structural marker |
| `retryable` | boolean | yes | Retryability marker |
| `source_model_expected` | enum or null | no | Expected local evidence model |
| `source_model_found` | enum or null | no | Actual local evidence model when present |
### Storage Locations
- `operation_runs.context.baseline_compare.evidence_gaps.subjects[]`
- `operation_runs.context.baseline_capture.gaps.subjects[]` or equivalent capture-context namespace
### Validation Rules
- New-run records must store structured objects, not only string subject tokens.
- `subject_key` must be deterministic for identical inputs.
- `reason_code` and `resolution_outcome` must not contradict each other.
- Old development rows that omit the new fields are cleanup candidates and should be regenerated or deleted rather than treated as a first-class runtime shape.
## Derived Entity: EvidenceGapProjection
Read-model projection used by canonical run-detail and tenant review surfaces.
### Fields
| Field | Type | Description |
|---|---|---|
| `detail_state` | enum | `no_gaps`, `structured_details_recorded`, `details_not_recorded`, `legacy_broad_reason` |
| `count` | integer | Total gap count |
| `by_reason` | map<string,int> | Aggregate counts by reason |
| `recorded_subjects_total` | integer | Number of structured subject rows available for projection |
| `missing_detail_count` | integer | Gap count that has no structured row attached |
| `structural_count` | integer | Number of recorded structural gap rows |
| `operational_count` | integer | Number of recorded non-structural, non-retryable rows |
| `transient_count` | integer | Number of recorded retryable rows |
| `legacy_mode` | boolean | Indicates the run still stores a broad legacy gap payload |
| `buckets` | list | Grouped records by reason with searchable row payload |
| `requires_regeneration` | boolean | Whether stale local development data should be regenerated rather than interpreted semantically |
## State Transitions
### Resolution lifecycle for a subject
1. `described`
- `SubjectDescriptor` is created from scope, metadata, and capability information.
2. `validated`
- Runtime support guard confirms whether the subject may enter compare or capture.
3. `resolved`
- The system attempts the appropriate local path and emits a `ResolutionOutcomeRecord`.
4. `persisted`
- New runs store the structured `EvidenceGapDetailRecord` or resolved outcome details in `OperationRun.context`.
5. `projected`
- Existing operator surfaces consume the new structured projection. Stale development data is regenerated or removed instead of driving a permanent compatibility path.
## Example New-Run Compare Gap Record
```json
{
"policy_type": "roleScopeTag",
"subject_external_id": "42f4fcb8-5b35-44ec-9240-0a1ad7c31fb1",
"subject_key": "rolescopetag|42f4fcb8-5b35-44ec-9240-0a1ad7c31fb1",
"subject_class": "foundation_backed",
"resolution_path": "foundation_inventory",
"resolution_outcome": "foundation_inventory_only",
"reason_code": "foundation_not_policy_backed",
"operator_action_category": "product_follow_up",
"structural": true,
"retryable": false,
"source_model_expected": "inventory_item",
"source_model_found": "inventory_item"
}
```

View File

@ -0,0 +1,246 @@
# Implementation Plan: 163 — Baseline Subject Resolution and Evidence Gap Semantics Foundation
**Branch**: `163-baseline-subject-resolution` | **Date**: 2026-03-24 | **Spec**: `specs/163-baseline-subject-resolution/spec.md`
**Input**: Feature specification from `specs/163-baseline-subject-resolution/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce an explicit backend subject-resolution contract for baseline compare and baseline capture so the system can classify each subject before resolution, select the correct local model path, and persist precise operator-safe gap semantics instead of collapsing structural, operational, and transient causes into broad `policy_not_found` style states. The implementation will extend existing baseline scope, inventory policy-type metadata, compare and capture jobs, baseline evidence-gap detail parsing, and OperationRun context persistence rather than introducing a parallel execution stack, with a bounded runtime support guard that prevents baseline-supported types from entering compare or capture on a resolver path that cannot truthfully classify them.
## Technical Context
**Language/Version**: PHP 8.4
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
**Storage**: PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON
**Testing**: Pest v4 on PHPUnit 12
**Target Platform**: Dockerized Laravel web application running through Sail locally and Dokploy in deployment
**Project Type**: Web application
**Performance Goals**: Preserve DB-only render behavior for Monitoring and tenant review surfaces, add no render-time Graph calls, and keep evidence-gap interpretation deterministic and lightweight enough for existing run-detail and landing surfaces
**Constraints**:
- No new render-time remote work and no bypass of `GraphClientInterface`
- No change to `OperationRun` lifecycle ownership, notification channels, or summary-count rules
- No new operator screen; existing surfaces must present richer semantics
- Existing development-only run payloads may be deleted or regenerated if that simplifies migration to the new structured contract
- Baseline-supported configuration must not overpromise runtime capability
**Scale/Scope**: Cross-cutting backend semantic work across baseline compare and capture pipelines, support-layer parsers and translators, OperationRun context contracts, tenant and canonical read surfaces, and focused Pest coverage for deterministic classification and development-safe contract cleanup
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS — the design keeps inventory as last-observed truth and distinguishes inventory-backed evidence from policy-backed evidence rather than conflating them.
- Read/write separation: PASS — this feature changes classification and persisted run semantics inside existing compare and capture flows; it does not add new write or restore actions.
- Graph contract path: PASS — no new Graph contract or direct endpoint use is introduced; existing capture and sync services remain the only remote paths.
- Deterministic capabilities: PASS — subject-class derivation, resolution outcome mapping, and support-capability guards are explicitly designed to be deterministic and testable.
- RBAC-UX: PASS — existing `/admin` and tenant-context authorization boundaries remain unchanged; only read semantics improve.
- Workspace isolation: PASS — no new workspace leakage is introduced and canonical run-detail remains tenant-safe.
- RBAC confirmations: PASS — no new destructive actions are added.
- Global search: PASS — unaffected.
- Tenant isolation: PASS — all compare, capture, inventory, and run data remain tenant-bound and entitlement-checked.
- Run observability: PASS — compare and capture continue to use existing `OperationRun` types; this slice enriches context semantics only.
- Ops-UX 3-surface feedback: PASS — no new toast, progress, or terminal-notification channels are added.
- Ops-UX lifecycle: PASS — `OperationRun.status` and `OperationRun.outcome` remain service-owned; only context enrichment changes.
- Ops-UX summary counts: PASS — no non-numeric values are moved into `summary_counts`; richer semantics live in context and read models.
- Ops-UX guards: PASS — focused regression tests can protect classification determinism and development cleanup behavior without relaxing existing CI rules.
- Ops-UX system runs: PASS — unchanged.
- Automation: PASS — existing queue, retry, and backoff behavior stays intact; transient outcomes are classified more precisely, not re-executed differently.
- Data minimization: PASS — the new gap detail contract stores classification and stable identifiers, not raw policy payloads or secrets.
- Badge semantics (BADGE-001): PASS — if structural, operational, or transient labels surface as badges, they must route through centralized badge or presentation helpers rather than ad hoc maps.
- UI naming (UI-NAMING-001): PASS — the feature exists to replace implementation-first broad error prose with domain-first operator meaning.
- Operator surfaces (OPSURF-001): PASS — existing run detail and tenant review surfaces remain operator-first and diagnostics-secondary.
- Filament UI Action Surface Contract: PASS — action topology stays unchanged; this is a read-surface semantics upgrade.
- Filament UI UX-001 (Layout & IA): PASS — existing layouts remain, but sections become more semantically truthful. No exemption required.
## Project Structure
### Documentation (this feature)
```text
specs/163-baseline-subject-resolution/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── BaselineCompareLanding.php
│ ├── Resources/
│ │ ├── OperationRunResource.php
│ │ └── BaselineSnapshotResource.php
│ └── Widgets/
├── Jobs/
│ ├── CompareBaselineToTenantJob.php
│ └── CaptureBaselineSnapshotJob.php
├── Services/
│ ├── Baselines/
│ │ ├── BaselineCompareService.php
│ │ ├── BaselineCaptureService.php
│ │ ├── BaselineContentCapturePhase.php
│ │ └── Evidence/
│ ├── Intune/
│ │ └── PolicySyncService.php
│ └── Inventory/
│ └── InventorySyncService.php
├── Support/
│ ├── Baselines/
│ ├── Inventory/
│ ├── OpsUx/
│ └── Ui/
├── Livewire/
└── Models/
config/
├── tenantpilot.php
└── graph_contracts.php
tests/
├── Feature/
│ ├── Baselines/
│ ├── Filament/
│ └── Monitoring/
└── Unit/
└── Support/
```
**Structure Decision**: Web application. The work stays inside existing baseline jobs and services, support-layer value objects and presenters, current Filament surfaces, and focused Pest coverage. No new top-level architecture area is required.
## Complexity Tracking
No constitution violations are required for this feature.
## Phase 0 — Outline & Research (DONE)
Outputs:
- `specs/163-baseline-subject-resolution/research.md`
Key decisions captured:
- Introduce a first-class subject-resolution contract in the backend instead of solving the problem with UI-only relabeling.
- Persist both `subject_class` and `resolution_outcome` because they answer different operator questions.
- Keep foundation-backed subjects eligible only when the runtime can truthfully classify them through an inventory-backed or limited-capability path.
- Add a runtime consistency guard during scope or resolver preparation so `baseline_compare.supported` cannot silently overpromise structural capability.
- Preserve transient reasons such as throttling and capture failure as precise operational outcomes rather than absorbing them into structural taxonomy.
- Treat broad legacy gap shapes as development-only cleanup candidates rather than a compatibility requirement for the new runtime contract.
## Phase 1 — Design & Contracts (DONE)
Outputs:
- `specs/163-baseline-subject-resolution/data-model.md`
- `specs/163-baseline-subject-resolution/contracts/openapi.yaml`
- `specs/163-baseline-subject-resolution/quickstart.md`
Design highlights:
- The core semantic unit is a `SubjectDescriptor` that is classified before resolution and yields a deterministic `ResolutionOutcomeRecord`.
- `OperationRun.context` remains the canonical persisted contract for compare and capture evidence-gap semantics, but new runs store richer subject-level objects instead of reason plus raw string only.
- The runtime support guard sits before compare and capture execution so unsupported structural mismatches are blocked or reclassified before misleading `policy_not_found`-style outcomes are emitted.
- Existing detail and landing surfaces are updated for the new structured gap contract, and development fixtures or stale local run data are regenerated instead of driving a permanent compatibility layer.
- Compare and capture share the same root-cause vocabulary, but retain operation-specific outcome families where needed.
## Phase 1 — Agent Context Update (REQUIRED)
Run:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Constitution Check — Post-Design Re-evaluation
- PASS — the design remains inside existing compare and capture operations and does not add new remote-call paths or lifecycle mutations.
- PASS — inventory-first semantics are strengthened because inventory-backed subjects are no longer mislabeled as missing policy records.
- PASS — operator surfaces stay on existing pages and remain DB-only at render time.
- PASS — development cleanup is explicit and bounded; the new contract remains the only forward-looking runtime shape.
- PASS — no Action Surface or UX-001 exemptions are needed because action topology and layouts remain intact.
## Phase 2 — Implementation Plan
### Step 1 — Subject classification and runtime capability foundation
Goal: implement FR-001 through FR-003, FR-008, FR-015, and FR-016 by creating a deterministic subject-resolution foundation shared by compare and capture.
Changes:
- Introduce a dedicated subject-resolution support layer under `app/Support/Baselines/` that defines:
- subject classes
- resolution paths
- resolution outcomes
- operator action categories
- structural versus operational versus transient classification
- Extend `InventoryPolicyTypeMeta` and related metadata accessors so baseline support can express whether a type is policy-backed, inventory-backed, foundation-backed, or limited.
- Add a runtime capability guard used by `BaselineScope`, `BaselineCompareService`, and `BaselineCaptureService` so types only enter compare or capture on a truthful path.
- Keep the guard deterministic and explicit in logs or run context when support is limited or excluded.
Tests:
- Add unit tests for subject-class derivation, resolution-path derivation, and runtime-capability guard behavior.
- Add golden-style tests covering supported, limited, and structurally invalid foundation types.
### Step 2 — Capture-path resolution and gap taxonomy upgrade
Goal: implement FR-004 through FR-010 on the capture side so structural resolver mismatches are no longer emitted as generic missing-policy cases.
Changes:
- Refactor `BaselineContentCapturePhase` so it resolves subjects through the new subject contract rather than assuming a policy lookup for all subjects.
- Replace broad `policy_not_found` capture gaps with precise structured outcomes such as:
- policy record missing
- inventory record missing
- foundation-backed via inventory path
- resolution type mismatch
- unresolvable subject
- Preserve existing transient outcomes like `throttled`, `capture_failed`, and `budget_exhausted` unchanged except for richer structured metadata.
- Persist new structured gap-subject objects for new runs and remove any requirement to keep broad legacy reason shapes alive for future writes.
Tests:
- Add feature and unit coverage for capture-path classification across policy-backed, inventory-backed, foundation-backed, duplicate, invalid, and transient cases.
- Add deterministic replay coverage proving unchanged capture inputs produce unchanged outcomes.
- Add regressions proving structural foundation subjects no longer produce new generic `policy_not_found` gaps.
### Step 3 — Compare-path resolution and evidence-gap detail contract
Goal: implement FR-004 through FR-014 on the compare side by aligning current-evidence resolution, evidence-gap reasoning, and persisted run context with the new contract.
Changes:
- Refactor `CompareBaselineToTenantJob` so baseline item interpretation and current-state resolution produce explicit `resolution_outcome` records rather than only count buckets and raw subject keys.
- Add structured evidence-gap subject records under `baseline_compare.evidence_gaps.subjects` for new runs, including subject class, resolution path, resolution outcome, reason code, operator action category, and retryability or structural flags.
- Preserve already precise compare reasons such as `missing_current`, `ambiguous_match`, and role-definition-specific gap families while separating them from structural non-policy-backed outcomes.
- Ensure baseline compare reason translation remains aligned with the new detailed reason taxonomy instead of flattening distinct root causes.
Tests:
- Add feature tests for mixed compare runs containing structural, operational, transient, and successful subjects.
- Add deterministic compare tests proving identical inputs yield identical resolution outcomes.
- Add regressions for evidence-gap persistence shape and compare-surface rendering against the new structured contract.
### Step 4 — Development cleanup and operator-surface adoption
Goal: implement FR-011 through FR-014 and the User Story 3 acceptance scenarios by moving existing read surfaces to the new gap contract and treating stale development data as disposable.
Changes:
- Extend `BaselineCompareEvidenceGapDetails`, `BaselineCompareStats`, `OperationRunResource`, `BaselineCompareLanding`, and any related Livewire gap tables so they read the new structured gap subject records consistently.
- Add an explicit development cleanup mechanism for stale local run payloads, preferably a dedicated development-only Artisan command plus fixture regeneration steps, so old broad string-only gap subjects can be purged instead of preserved.
- Introduce operator-facing labels that answer root cause before action advice while keeping diagnostics secondary.
- Keep existing pages and sections, but expose structural versus operational versus transient semantics consistently across dense and detailed surfaces.
- Update snapshot and compare summary surfaces where old broad reason aggregations would otherwise misread the new taxonomy.
Tests:
- Add or update Filament feature tests for canonical run detail and tenant baseline compare landing against the new structured run shape.
- Add cleanup-oriented tests proving the development cleanup mechanism removes or invalidates stale broad-reason run payloads without extending production semantics.
### Step 5 — Focused validation pack and rollout safety
Goal: protect the foundation from semantic regressions and make follow-on fidelity work safe.
Changes:
- Add a focused regression pack spanning compare, capture, capability guard, and development-safe contract cleanup.
- Review every touched reason-label and badge usage to ensure structural, operational, and transient meanings remain centralized.
- Document the new backend contract shape in code-level PHPDoc and tests so follow-on specs can build on stable semantics.
- Keep rollout bounded to baseline compare and capture semantics without adding renderer-richness work from Spec 164.
Tests:
- Run the focused Pest pack in `quickstart.md`.
- Add one regression proving no render-time Graph calls occur on affected run-detail or landing surfaces.

View File

@ -0,0 +1,87 @@
# Quickstart: Baseline Subject Resolution and Evidence Gap Semantics Foundation
## Prerequisites
1. Start the local stack.
```bash
vendor/bin/sail up -d
```
2. Clear stale cached state if you have been switching branches or configs.
```bash
vendor/bin/sail artisan optimize:clear
```
## Focused Verification Pack
Run the minimum targeted regression pack for this foundation:
```bash
vendor/bin/sail artisan test --compact tests/Unit/Support/Baselines tests/Feature/Baselines tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
```
If the implementation introduces dedicated new files, narrow the pack further to the new subject-resolution, compare, capture, and development-cleanup tests.
Format touched files before final review:
```bash
vendor/bin/sail bin pint --dirty --format agent
```
## Manual Verification Flow
1. Ensure a tenant has fresh inventory data for at least one policy-backed type and one baseline-supported foundation type.
2. Trigger or locate a baseline capture run and a baseline compare run for that tenant and profile.
3. Open the canonical run detail at `/admin/operations/{run}`.
4. Confirm the page distinguishes:
- structural cases
- operational or missing-local-data cases
- transient retryable cases
5. Confirm inventory-only foundation subjects no longer surface as a new generic `policy_not_found` gap.
6. Confirm policy-backed missing-local-record cases still surface as an operational missing-record outcome.
## Development Cleanup Verification
1. Remove or invalidate old local compare or capture runs that still contain broad legacy gap reasons.
Dry-run:
```bash
vendor/bin/sail artisan tenantpilot:baselines:purge-legacy-gap-runs
```
Apply deletion:
```bash
vendor/bin/sail artisan tenantpilot:baselines:purge-legacy-gap-runs --force
```
2. Regenerate fresh runs under the new structured contract.
3. Confirm the product and targeted tests no longer depend on the broad legacy shape being preserved in runtime code.
## Runtime Capability Guard Verification
1. Configure or seed one baseline-supported type whose runtime resolver path is valid.
2. Configure or seed one type whose support claim would be structurally invalid without the new guard.
3. Start compare or capture preparation.
4. Confirm the valid type enters execution with a truthful path.
5. Confirm the invalid type is limited, excluded, or explicitly classified as invalid support configuration before misleading gap output is produced.
## Determinism Verification
1. Run the same compare scenario twice against unchanged tenant-local data.
2. Confirm both runs persist the same `subject_class`, `resolution_outcome`, and `operator_action_category` values for the same subject.
## Render-Safety Verification
1. Bind the fail-hard Graph client in affected UI tests.
2. Verify canonical run detail and tenant baseline compare landing render without triggering Graph calls.
3. Verify the richer semantics are derived solely from persisted run context and local metadata.
## Deployment Notes
- No new panel provider is required; Laravel 12 continues to register providers in `bootstrap/providers.php`.
- Filament remains on Livewire v4-compatible patterns and does not require view publishing.
- No new shared or panel assets are required, so this slice adds no new `filament:assets` deployment step beyond the existing deployment baseline.
- Existing compare and capture operations remain on current `OperationRun` types and notification behavior.

View File

@ -0,0 +1,65 @@
# Research: Baseline Subject Resolution and Evidence Gap Semantics Foundation
## Decision 1: Introduce a backend subject-resolution contract instead of UI-only relabeling
- Decision: Add an explicit backend resolution layer that classifies each compare or capture subject before lookup and returns a structured resolution outcome.
- Rationale: The current failure is rooted in the resolver path itself. `BaselineContentCapturePhase` still assumes every subject can be resolved via `Policy`, while compare scope already admits foundation types through inventory metadata. Renaming `policy_not_found` in the UI would preserve the wrong source-of-truth contract.
- Alternatives considered:
- UI-only copy fixes: rejected because they would leave incorrect persisted semantics and non-deterministic operator meaning.
- Per-surface one-off translation rules: rejected because compare, capture, and run detail would drift semantically.
## Decision 2: Persist both `subject_class` and `resolution_outcome`
- Decision: Store both the business class of the subject and the result of resolving it.
- Rationale: `subject_class` answers what kind of object the system is dealing with, while `resolution_outcome` answers what actually happened. A single field would either blur object identity or overload root-cause meaning.
- Alternatives considered:
- Only store `resolution_outcome`: rejected because operators and future renderer work still need to know whether the target was policy-backed, inventory-backed, foundation-backed, or derived.
- Only store `subject_class`: rejected because class alone cannot distinguish resolved, missing-local-record, throttled, or structurally unsupported states.
## Decision 3: Keep inventory-only foundation subjects in scope only when the runtime can truthfully classify them
- Decision: Inventory-only foundation subjects may remain compare or capture eligible only when the runtime explicitly supports an inventory-backed or limited-capability path for them.
- Rationale: The product already includes supported foundations in baseline scope via `InventoryPolicyTypeMeta::baselineSupportedFoundations()` and `BaselineScope::allTypes()`. Removing them wholesale would hide legitimate support cases. Allowing them in scope without a truthful path produces predictable false alarms.
- Alternatives considered:
- Remove all inventory-only foundations from compare and capture: rejected because it would throw away potentially valid baseline support.
- Keep all supported foundations in scope and tolerate broad `policy_not_found`: rejected because it preserves the current trust problem.
## Decision 4: Add a runtime consistency guard before compare and capture execution
- Decision: Add a deterministic support-capability guard in scope or service preparation that validates each supported type against an actual resolution path before compare or capture runs.
- Rationale: The specs core “config must not overpromise” requirement is best enforced before job execution. This prevents structurally invalid types from silently entering a run and only failing later as misleading gaps.
- Alternatives considered:
- Validate only after gap generation: rejected because it still emits misleading runtime states.
- Validate only in configuration review or documentation: rejected because runtime truth must not depend on manual discipline.
## Decision 5: Preserve transient and already-precise operational reasons as distinct outcomes
- Decision: Keep `throttled`, `capture_failed`, `budget_exhausted`, `ambiguous_match`, and related precise reasons intact, while adding new structural and missing-local-record outcomes beside them.
- Rationale: These reasons already carry actionable meaning and should not be re-modeled into a coarse structural taxonomy. The new foundation is about separating root-cause families, not flattening them.
- Alternatives considered:
- Replace the entire existing reason vocabulary: rejected because it would cause unnecessary churn and regress already-useful operator semantics.
- Collapse transient reasons into one retryable bucket: rejected because rate limiting, capture errors, and budget exhaustion still imply different remediation paths.
## Decision 6: Prefer development cleanup over legacy compatibility
- Decision: Newly created runs write the richer structured shape immediately, and obsolete development-only run payloads may be deleted or regenerated instead of preserved through a compatibility parser.
- Rationale: The repository is still in development, so preserving broad historical reason codes would keep ambiguous semantics alive in the runtime model for no real product benefit. Rebuilding local data and fixtures is cheaper and cleaner than carrying a long-term compatibility path.
- Alternatives considered:
- Keep a compatibility parser for old run shapes: rejected because it would preserve the old semantic contract in code paths that should move to the new model immediately.
- Backfill old runs with inferred outcomes: rejected because the original resolver context is incomplete and inference would still be unreliable.
## Decision 7: Reuse `OperationRun.context` as the canonical persistence boundary
- Decision: Store the richer gap semantics inside existing compare and capture run context rather than creating a new relational evidence-gap table.
- Rationale: Compare and capture results are already run-scoped, immutable operational artifacts. Monitoring and tenant review surfaces must stay DB-only at render time. Extending the existing run context keeps the persistence boundary aligned with execution truth.
- Alternatives considered:
- New evidence-gap relational tables: rejected because they add mutable join complexity for a run-bounded artifact.
- On-demand recomputation from current inventory and policy state: rejected because current state can drift away from the runs original truth.
## Decision 8: Upgrade existing surfaces instead of adding a new operator page
- Decision: Surface the richer semantics on existing canonical run-detail, tenant baseline compare landing, and related evidence-gap detail surfaces.
- Rationale: The feature is about truthfulness of semantics, not information architecture expansion. Existing surfaces already have the right operator entry points.
- Alternatives considered:
- Add a dedicated resolver diagnostics page: rejected because it would make core trust semantics secondary and harder to discover.
- Keep structured semantics backend-only: rejected because the operator value comes from clearer action guidance on current pages.

View File

@ -0,0 +1,174 @@
# Feature Specification: Baseline Subject Resolution and Evidence Gap Semantics Foundation
**Feature Branch**: `163-baseline-subject-resolution`
**Created**: 2026-03-24
**Status**: Draft
**Input**: User description: "Spec 163 — Baseline Subject Resolution & Evidence Gap Semantics Foundation"
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant, canonical-view
- **Primary Routes**: `/admin/operations/{run}`, `/admin/t/{tenant}/baseline-compare-landing`, and existing baseline compare and baseline capture entry points that surface evidence-gap meaning
- **Data Ownership**: Tenant-owned local evidence records, captured baseline comparison results, and operation-run context remain the operational source of truth for resolution outcomes. Workspace-owned baseline support metadata remains the source of support promises and subject-class expectations.
- **RBAC**: Existing workspace membership, tenant entitlement, and baseline compare or monitoring view permissions remain authoritative. This feature does not introduce new roles or broaden visibility.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Canonical monitoring surfaces must continue to respect active tenant context in navigation and related links, while direct run-detail access remains explicit to the run's tenant and must not silently widen visibility.
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical run-detail and tenant-scoped compare review surfaces must continue to enforce workspace entitlement first and tenant entitlement second, with deny-as-not-found behavior for non-members and no cross-tenant hinting through resolution outcomes or evidence-gap details.
## 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 |
|---|---|---|---|---|---|---|---|---|---|
| Monitoring → baseline compare or capture run detail | Workspace manager or entitled tenant operator | Canonical detail | Is this gap structural, operational, or transient, and what action should I take next? | Resolution meaning, subject class, operator-safe next step, whether retry or sync is relevant, whether the issue is a product limitation or tenant-local data issue | Raw context payloads and low-level diagnostic fragments | execution outcome, evidence completeness, root-cause class, actionability | Simulation only for compare interpretation, TenantPilot only for rendering and classification persistence | View run, inspect gap meaning, navigate to related tenant review surfaces | None |
| Tenant baseline compare landing and related review surfaces | Tenant operator | Tenant-scoped review surface | Can I trust this compare result, and what exactly is missing or mismatched locally? | Structural versus operational meaning, subject class, local evidence expectation, next-step guidance, compare support limitations | Raw stored context and secondary technical diagnostics | compare trust, data completeness, root-cause class, actionability | Simulation only | Compare now, inspect latest run, review evidence-gap meaning | None |
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Distinguish structural from missing-local-data gaps (Priority: P1)
An operator reviewing a baseline compare or capture result needs the product to tell them whether the gap is structurally expected for that subject class or whether a policy or inventory record is actually missing locally.
**Why this priority**: This is the core trust problem. If structural limits and missing-local-data cases are collapsed into one generic reason, operators take the wrong follow-up action and lose confidence in the product.
**Independent Test**: Run compare and capture flows that include both policy-backed and foundation-backed subjects, then verify that the resulting gaps clearly separate structural resolver limits from missing local records without relying on raw diagnostics.
**Acceptance Scenarios**:
1. **Given** a baseline-supported subject that is inventory-backed but not policy-backed, **When** a new compare or capture run evaluates it, **Then** the run records a structural foundation or inventory outcome instead of a generic policy-not-found meaning.
2. **Given** a policy-backed subject with no local policy record, **When** the same flow evaluates it, **Then** the run records an operational missing-local-data outcome that is distinct from structural subject-class limits.
---
### User Story 2 - Keep support promises truthful at runtime (Priority: P2)
A product owner or operator needs baseline-supported subject types to enter compare or capture only when the runtime can classify and resolve them truthfully, so support configuration does not overpromise capabilities the resolver cannot deliver.
**Why this priority**: False support promises create predictable false alarms and make baseline support metadata untrustworthy.
**Independent Test**: Evaluate supported subject types against current resolver capability and verify that each type either enters the run with a valid resolution path and meaningful outcome set or is explicitly limited or excluded before misleading gaps are produced.
**Acceptance Scenarios**:
1. **Given** a subject type marked as baseline-supported, **When** the runtime has no truthful resolution path for that subject class, **Then** the type is either explicitly limited, explicitly excluded, or classified through a non-policy path instead of silently producing a generic missing-policy signal.
2. **Given** a subject type with a valid resolution path, **When** the run evaluates it, **Then** the stored outcome reflects the correct subject class and local evidence model.
---
### User Story 3 - Replace dev-era broad reasons with the new contract cleanly (Priority: P3)
A developer or operator needs the repository to move to the new structured gap contract without carrying obsolete development-only run payloads forward just for compatibility.
**Why this priority**: Staying in a mixed old-and-new state during development would preserve ambiguity in exactly the area this feature is trying to fix.
**Independent Test**: Remove or regenerate old development runs, create a new run under the updated contract, and verify that the existing surfaces expose subject class, resolution meaning, and action category without fallback to the old broad reason contract.
**Acceptance Scenarios**:
1. **Given** development-only runs that use the old broad reason shape, **When** the team chooses to clean or regenerate them, **Then** the product does not require runtime preservation of the obsolete shape to proceed.
2. **Given** a new run created after this foundation is implemented, **When** an operator opens the existing detail surfaces, **Then** the run exposes subject class, resolution meaning, and action category without requiring a new screen.
### Edge Cases
- A run contains a mix of policy-backed, foundation-backed, inventory-backed, and derived subjects. Each subject must keep its own resolution meaning instead of being normalized into one broad reason bucket.
- A subject is supported in configuration but currently lacks a truthful runtime resolution path. The system must not silently enter the subject into compare or capture as if the path were valid.
- A transient upstream or budget-related failure occurs for one subject while another subject in the same run is structurally not policy-backed. The surface must keep transient and structural meaning distinct.
- Development data may still contain obsolete broad-reason payloads during rollout. The team may remove or regenerate those runs instead of extending the runtime contract to support them indefinitely.
- Two identical subjects evaluated against the same tenant-local state at different points in the same release must produce the same resolution outcome and operator meaning.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not add new external provider calls or a new long-running operation type. It establishes a stricter semantic contract for how existing baseline compare and capture workflows classify subjects, persist evidence-gap meaning, and describe operator action truth. Existing tenant isolation, preview, and audit expectations remain in force.
**Constitution alignment (OPS-UX):** Existing compare and capture runs continue to use the established three-surface feedback contract. Run status and outcome remain service-owned. Summary counts remain numeric and lifecycle-safe. This feature extends the semantic detail stored in run context so evidence-gap meaning is deterministic, reproducible, and available on progress and terminal surfaces without redefining run lifecycle ownership.
**Constitution alignment (RBAC-UX):** This feature changes what existing entitled users can understand on run-detail and tenant review surfaces, not who may access those surfaces. Non-members remain deny-as-not-found. Members who lack the relevant capability remain forbidden only after entitlement is established. No cross-tenant visibility or capability broadening is introduced.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable beyond reaffirming that monitoring and operations surfaces continue to avoid synchronous auth-handshake behavior.
**Constitution alignment (BADGE-001):** If any status-like labels are refined for evidence-gap meaning, the semantic mapping must remain centralized and shared across dense and detailed surfaces. This feature must not create ad hoc surface-specific meanings for structural, operational, or transient states.
**Constitution alignment (UI-NAMING-001):** Operator-facing wording must describe the object class and root cause before advice. Labels should use domain language such as “Policy record missing locally”, “Inventory-backed foundation subject”, or “Retry may help”, and avoid implementation-first phrasing.
**Constitution alignment (OPSURF-001):** Existing run detail and tenant review surfaces remain operator-first. Default-visible content must answer whether the issue is structural, operational, or transient before exposing raw diagnostics. Mutation scope messaging for existing compare actions remains unchanged.
**Constitution alignment (Filament Action Surfaces):** The affected Filament pages remain compliant with the Action Surface Contract. No new destructive actions are introduced. Existing compare or review actions remain read or inspect oriented, and this feature changes interpretation rather than mutation behavior.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature reuses existing layouts and surfaces. The required change is semantic clarity, not a new layout pattern. Existing sections and detail affordances must present the new meaning without introducing naked diagnostics or a parallel screen.
### Functional Requirements
- **FR-001**: The system MUST determine the subject class for every compare or capture subject before attempting local resolution.
- **FR-002**: The system MUST support, at minimum, policy-backed, inventory-backed, foundation-backed, and derived subject classes for new runs.
- **FR-003**: The system MUST choose the local resolution strategy from the subject class and supported capability contract, rather than implicitly treating every in-scope subject as policy-backed.
- **FR-004**: The system MUST distinguish policy-backed missing-local-record cases from structural foundation or inventory-only cases in new run outputs.
- **FR-005**: The system MUST support a precise evidence-gap reason taxonomy for new runs that can separately represent missing policy records, missing inventory records, structural non-policy-backed subjects, resolution mismatches, invalid or duplicate subjects, transient capture failures, ambiguity, and budget or throttling limits.
- **FR-006**: The system MUST persist structured gap metadata for new runs that includes subject class, resolution meaning, and operator action category, rather than relying only on a broad reason code and a raw subject key.
- **FR-007**: The system MUST provide an explicit resolution outcome for each evaluated subject, including successful resolution path, structural limitation, missing local artifact, or transient failure as applicable.
- **FR-008**: The system MUST prevent baseline support metadata from overpromising compare or capture capability when no truthful runtime resolution path exists for that subject class.
- **FR-009**: The system MUST classify new gaps so operators can tell whether retry, backup or sync, or product follow-up is the correct next action.
- **FR-010**: The system MUST NOT persist the historical broad policy-not-found reason as the sole reason for newly created structural cases that have a more precise semantic classification.
- **FR-011**: During development, the system MAY invalidate or discard previously stored run payloads that only contain the broad legacy reason if that simplifies migration to the new structured contract.
- **FR-012**: The system MUST preserve already precise reason families, including transient and ambiguity-related cases, without collapsing them into the new structural taxonomy.
- **FR-013**: The system MUST keep the semantic meaning aligned across dense landing surfaces and richer detail surfaces so the same run does not communicate different root causes on different pages.
- **FR-014**: The system MUST derive resolution meaning on the backend so run context, auditability, and diagnostic replay do not depend on UI-only interpretation.
- **FR-015**: The system MUST produce the same resolution outcome and operator-facing meaning for the same subject and tenant-local state whenever the input conditions are unchanged.
- **FR-016**: The system MUST allow inventory-backed or foundation-backed supported subjects to remain in scope only when their compare or capture behavior can be described truthfully through the resolution contract.
### Assumptions
- Foundation-backed subjects remain eligible for compare or capture only when the product can truthfully classify them through an inventory-backed or limited non-policy resolution path. Otherwise they are treated as explicitly unsupported for that operation rather than as generic missing-policy cases.
- Subject class and resolution outcome are both required because they answer different operator questions: what kind of object is this, and what happened when the system tried to resolve it.
- The repository is still in active development, so breaking cleanup of previously stored development run payloads is acceptable when it removes obsolete broad-reason semantics instead of preserving them.
- Newly created runs are expected to use the new structured contract immediately; there is no requirement to keep the old broad reason shape alive for future writes.
- This foundation spec establishes root-cause truth and runtime support truth. Fidelity richness, renderer density, and deeper wording refinements are handled in follow-on work.
### Deferred Scope
- New renderer families, fidelity badges, or snapshot richness redesign are not included in this feature.
- This feature does not redefine content diff algorithms, reporting exports, or large historical data backfills.
- This feature does not require a new operator screen. It upgrades semantic truth on existing surfaces.
- This feature does not preserve historical development run payloads only for compatibility's sake.
- This feature does not create dual-read or dual-write architecture for old and new gap semantics unless a concrete development need emerges later.
- New downstream domain behavior, including new findings, alerts, or follow-on automation, must be designed around the new structured contract rather than the old broad reason.
## Development Migration Policy
- **Breaking Cleanup Is Acceptable**: Existing development-only compare and capture runs MAY be deleted, regenerated, or rendered invalid if that removes obsolete broad-reason semantics and keeps the runtime model cleaner.
- **Single Contract Going Forward**: Newly created runs MUST write the new structured resolution and gap contract only.
- **No Parallel Semantic Core**: The old broad reason MAY be recognized temporarily in one-off development utilities or cleanup scripts, but it MUST NOT remain a first-class domain contract for ongoing feature work.
- **Regenerate Over Preserve**: When tests, fixtures, or local demo data depend on the old shape, the preferred path is to rebuild them against the new contract instead of extending production code to preserve the obsolete structure.
## 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 compare or capture run detail | Existing canonical run-detail surface | Existing navigation and refresh actions remain | Existing detail and diagnostic sections remain the inspect affordance | None added | None | No new CTA. Empty states explain whether the run has no gaps or whether development data must be regenerated under the new contract. | Existing run-detail header actions remain | Not applicable | Existing run-backed audit semantics remain | Read-only semantic upgrade; no new mutation surface |
| Tenant baseline compare landing and related review surfaces | Existing tenant-scoped review surfaces | Existing compare and navigation actions remain | Existing summary and detail sections remain the inspect affordance | None added | None | Existing compare CTA remains; no new dangerous action is introduced | Existing page-level actions remain | Not applicable | Existing run-backed audit semantics remain | Read-only semantic upgrade; same actions, clearer meaning |
### Key Entities *(include if feature involves data)*
- **Subject**: A compare or capture target that the product must classify and resolve against tenant-local evidence before it can judge trust or completeness.
- **Subject Class**: The business-level class that describes whether a subject is policy-backed, inventory-backed, foundation-backed, or derived.
- **Resolution Outcome**: The deterministic result of attempting to resolve a subject locally, including both successful resolution paths and precise failure or limitation meanings.
- **Evidence Gap Detail**: The structured record attached to a run that captures which subject was affected, how it was classified, what local evidence expectation applied, and which operator action category follows.
- **Support Capability Contract**: The support promise that states whether a subject type may enter compare or capture and through which truthful resolution path.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In validation runs containing both structural and missing-local-data cases, operators can distinguish the two classes from the default-visible surface without opening raw diagnostics in 100% of sampled review sessions.
- **SC-002**: For every supported subject type included in a release validation pack, the runtime either produces a truthful resolution path or classifies the type as explicitly limited or unsupported before misleading broad-cause gaps are emitted.
- **SC-003**: Local development data, tests, and fixtures can be regenerated against the new structured contract without requiring production code to preserve the obsolete broad-reason payload shape.
- **SC-004**: New runs expose enough structured gap metadata that operators can determine whether retry, backup or sync, or product follow-up is the next action in a single page visit.
- **SC-005**: Replaying the same subject against the same tenant-local state yields the same stored resolution outcome and operator action category across repeated validation runs.
## Definition of Done
- Newly created compare and capture runs persist the new structured resolution contract and do not rely on the broad legacy reason as their primary semantic output.
- Development fixtures, local data, and tests that depended on the old broad reason shape are either regenerated or intentionally removed instead of forcing the runtime to preserve obsolete semantics.
- New domain logic introduced for this feature uses subject class, resolution outcome, and structured gap metadata as the source of truth instead of branching on the legacy broad reason.
- Structural, operational, and transient cases are distinguishable in backend persistence and in operator-facing interpretation.
- Baseline-supported subject types do not enter the runtime path with a silent structural resolver mismatch.

View File

@ -0,0 +1,194 @@
# Tasks: Baseline Subject Resolution and Evidence Gap Semantics Foundation
**Input**: Design documents from `/specs/163-baseline-subject-resolution/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/openapi.yaml`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature because it changes runtime behavior in compare, capture, and operator surfaces.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently where practical.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Create shared test support and optional cleanup-command scaffolding needed by later story work.
- [X] T001 Scaffold the development-only cleanup command entry point in `app/Console/Commands/PurgeLegacyBaselineGapRuns.php`
- [X] T002 [P] Create shared compare and capture fixture builders for subject-resolution scenarios in `tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php`
- [X] T003 [P] Create shared assertion helpers for structured gap payloads in `tests/Feature/Baselines/Support/AssertsStructuredBaselineGaps.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Build the shared subject-resolution contract and runtime capability foundation before user story work begins.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 [P] Create subject-resolution enums in `app/Support/Baselines/SubjectClass.php`, `app/Support/Baselines/ResolutionPath.php`, `app/Support/Baselines/ResolutionOutcome.php`, and `app/Support/Baselines/OperatorActionCategory.php`
- [X] T005 [P] Create subject-resolution value objects in `app/Support/Baselines/SubjectDescriptor.php`, `app/Support/Baselines/ResolutionOutcomeRecord.php`, and `app/Support/Baselines/SupportCapabilityRecord.php`
- [X] T006 Implement the shared resolver and capability services in `app/Support/Baselines/SubjectResolver.php` and `app/Support/Baselines/BaselineSupportCapabilityGuard.php`
- [X] T007 Update metadata derivation for subject classes and support capability in `app/Support/Inventory/InventoryPolicyTypeMeta.php` and `config/tenantpilot.php`
- [X] T008 Update shared scope and service wiring for the new resolver contract in `app/Support/Baselines/BaselineScope.php`, `app/Services/Baselines/BaselineCompareService.php`, and `app/Services/Baselines/BaselineCaptureService.php`
- [X] T009 [P] Add foundational unit coverage for enums, metadata rules, and resolver behavior in `tests/Unit/Support/Baselines/SubjectResolverTest.php` and `tests/Unit/Support/Inventory/InventoryPolicyTypeMetaResolutionContractTest.php`
**Checkpoint**: Subject-resolution foundation and runtime capability guard are ready.
---
## Phase 3: User Story 1 - Distinguish structural from missing-local-data gaps (Priority: P1) 🎯 MVP
**Goal**: Ensure compare and capture can tell structural inventory or foundation limitations apart from missing local policy or inventory records.
**Independent Test**: Run compare and capture flows that include both policy-backed and foundation-backed subjects and verify the resulting gaps separate structural resolver limits from missing local records without relying on raw diagnostics.
### Tests for User Story 1
- [X] T010 [P] [US1] Add compare gap classification and ambiguity-preservation coverage for structural versus missing-local-data cases in `tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`
- [X] T011 [P] [US1] Add capture gap classification coverage for policy-backed and foundation-backed subjects in `tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php`
- [X] T012 [P] [US1] Add deterministic replay coverage for unchanged compare and capture inputs in `tests/Feature/Baselines/BaselineResolutionDeterminismTest.php`
### Implementation for User Story 1
- [X] T013 [US1] Refactor subject lookup and outcome emission in `app/Services/Baselines/BaselineContentCapturePhase.php` to use `SubjectResolver` instead of raw policy-only lookup
- [X] T014 [US1] Update compare-side subject persistence and deterministic subject keys in `app/Jobs/CompareBaselineToTenantJob.php` and `app/Support/Baselines/BaselineSubjectKey.php`
- [X] T015 [US1] Replace broad gap taxonomy handling with structured structural versus operational semantics while preserving ambiguity-related reason families in `app/Support/Baselines/BaselineCompareReasonCode.php` and `app/Support/Baselines/BaselineCompareEvidenceGapDetails.php`
- [X] T016 [US1] Update compare summary aggregation for structural, operational, and transient counts in `app/Support/Baselines/BaselineCompareStats.php`
**Checkpoint**: User Story 1 is functional and testable on its own.
---
## Phase 4: User Story 2 - Keep support promises truthful at runtime (Priority: P2)
**Goal**: Prevent baseline-supported types from entering compare or capture on a resolver path that cannot classify them truthfully.
**Independent Test**: Evaluate supported subject types against runtime resolver capability and verify each type either enters execution with a valid path and meaningful outcome set or is limited or excluded before misleading gaps are produced.
### Tests for User Story 2
- [X] T017 [P] [US2] Add feature coverage for runtime support-capability guard decisions in `tests/Feature/Baselines/BaselineSupportCapabilityGuardTest.php`
- [X] T018 [P] [US2] Add unit coverage for subject-class and support-mode derivation in `tests/Unit/Support/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php`
### Implementation for User Story 2
- [X] T019 [US2] Extend baseline support metadata with subject-class and capability truth in `config/tenantpilot.php` and `app/Support/Inventory/InventoryPolicyTypeMeta.php`
- [X] T020 [US2] Enforce capability guard decisions before compare execution in `app/Support/Baselines/BaselineScope.php` and `app/Services/Baselines/BaselineCompareService.php`
- [X] T021 [US2] Enforce the same capability guard before capture execution in `app/Services/Baselines/BaselineCaptureService.php` and `app/Jobs/CaptureBaselineSnapshotJob.php`
- [X] T022 [US2] Persist operator-safe capability and support outcomes in `app/Jobs/CompareBaselineToTenantJob.php` and `app/Services/Baselines/BaselineContentCapturePhase.php`
**Checkpoint**: User Story 2 is functional and testable on its own.
---
## Phase 5: User Story 3 - Replace dev-era broad reasons with the new contract cleanly (Priority: P3)
**Goal**: Move existing operator surfaces, tests, and development fixtures to the new structured gap contract without preserving the old broad reason shape in runtime code.
**Independent Test**: Remove or regenerate old development runs, create a new run under the updated contract, and verify the existing surfaces expose subject class, resolution meaning, and action category without fallback to the old broad reason contract.
### Tests for User Story 3
- [X] T023 [P] [US3] Add canonical run-detail regression coverage for structured gap semantics in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T024 [P] [US3] Add tenant landing regression coverage for structured gap semantics in `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
- [X] T025 [P] [US3] Add DB-only render regression coverage for gap surfaces in `tests/Feature/Filament/BaselineGapSurfacesDbOnlyRenderTest.php`
- [X] T026 [P] [US3] Add development cleanup and regeneration coverage for stale run payloads in `tests/Feature/Baselines/BaselineGapContractCleanupTest.php`
### Implementation for User Story 3
- [X] T027 [US3] Update run-detail semantics for structured gap records in `app/Filament/Resources/OperationRunResource.php` and `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
- [X] T028 [US3] Update tenant landing semantics for structured gap records in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
- [X] T029 [US3] Implement the cleanup command logic and run-selection criteria in `app/Console/Commands/PurgeLegacyBaselineGapRuns.php` and `app/Models/OperationRun.php`
- [X] T030 [US3] Remove broad-reason dev fixture usage and regenerate structured payload fixtures in `tests/Feature/Baselines/Support/BaselineSubjectResolutionFixtures.php`, `tests/Feature/Baselines`, and `tests/Feature/Filament`
- [X] T031 [US3] Finalize projection states and empty-state semantics for development cleanup in `app/Support/Baselines/BaselineCompareEvidenceGapDetails.php` and `app/Support/Baselines/BaselineCompareStats.php`
**Checkpoint**: User Story 3 is functional and testable on its own.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final consistency, documentation alignment, and focused validation across all stories.
- [X] T032 [P] Document the implemented cleanup command and final contract examples in `specs/163-baseline-subject-resolution/contracts/openapi.yaml`, `specs/163-baseline-subject-resolution/data-model.md`, and `specs/163-baseline-subject-resolution/quickstart.md`
- [X] T033 Run the focused validation pack and the cleanup command flow documented in `specs/163-baseline-subject-resolution/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: T002 and T003 start immediately; T001 is optional early scaffolding for US3 and does not block semantic work.
- **Foundational (Phase 2)**: Depends on shared support where needed for tests, but is not blocked by T001 cleanup-command scaffolding; blocks all user stories once started.
- **User Story 1 (Phase 3)**: Depends on Foundational completion; delivers the MVP semantic contract.
- **User Story 2 (Phase 4)**: Depends on Foundational completion; can proceed after Phase 2 and integrate with US1 outputs.
- **User Story 3 (Phase 5)**: Depends on US1 structured gap contract and benefits from US2 capability guard outputs.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: Starts after Phase 2; no dependency on later stories.
- **US2 (P2)**: Starts after Phase 2; shares foundational components but remains independently testable.
- **US3 (P3)**: Starts after US1 because surface adoption depends on the new structured gap payload; it consumes T001 if the cleanup command scaffold was created early.
### Within Each User Story
- Tests must be written and fail before the implementation tasks they cover.
- Resolver or metadata changes must land before surface or projection updates that consume them.
- Story-level verification must pass before moving to the next dependent story.
### Parallel Opportunities
- T002 and T003 can run in parallel.
- T004 and T005 can run in parallel.
- T009 can run in parallel with the end of T006 through T008 once the foundational interfaces stabilize.
- T010, T011, and T012 can run in parallel.
- T017 and T018 can run in parallel.
- T023, T024, T025, and T026 can run in parallel.
- T032 can run in parallel with final validation prep once implementation stabilizes.
---
## Parallel Example: User Story 1
```bash
# Launch the independent US1 tests together:
Task: "Add compare gap classification coverage in tests/Feature/Baselines/BaselineCompareGapClassificationTest.php"
Task: "Add capture gap classification coverage in tests/Feature/Baselines/BaselineCaptureGapClassificationTest.php"
```
## Parallel Example: User Story 2
```bash
# Launch the independent US2 tests together:
Task: "Add feature coverage for runtime support-capability guard decisions in tests/Feature/Baselines/BaselineSupportCapabilityGuardTest.php"
Task: "Add unit coverage for subject-class and support-mode derivation in tests/Unit/Support/Inventory/InventoryPolicyTypeMetaBaselineSupportTest.php"
```
## Parallel Example: User Story 3
```bash
# Launch the independent US3 regression tests together:
Task: "Add canonical run-detail regression coverage in tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php"
Task: "Add tenant landing regression coverage in tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php"
Task: "Add DB-only render regression coverage in tests/Feature/Filament/BaselineGapSurfacesDbOnlyRenderTest.php"
Task: "Add development cleanup and regeneration coverage in tests/Feature/Baselines/BaselineGapContractCleanupTest.php"
```
---
## Implementation Strategy
### MVP First
Deliver Phase 3 (US1) first after the foundational phase. That provides the core semantic win: structural versus missing-local-data gaps become distinguishable in persisted run context.
### Incremental Delivery
1. Finish Setup and Foundational phases.
2. Deliver US1 to establish the new structured resolution and gap contract.
3. Deliver US2 to stop support metadata from overpromising runtime capability.
4. Deliver US3 to move existing surfaces and development fixtures fully onto the new contract.
5. Finish with Polish to align the design docs and validation steps with the implemented behavior.
### Suggested MVP Scope
US1 only is the smallest valuable slice. It fixes the primary trust problem and creates the contract that US2 and US3 build on.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-26
**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
- Validation pass 1 completed with no open issues.
- The spec stays scoped to the canonical run-detail decision surface and keeps route, RBAC, and domain semantics unchanged.
- The spec is ready for `/speckit.plan`.

View File

@ -0,0 +1,303 @@
openapi: 3.1.0
info:
title: Operation Run Detail Page Contract
version: 1.0.0
description: >-
Internal reference contract for the canonical operation-run detail page. The route
still returns rendered HTML; the structured schema below documents the decision-first
page payload that must be derivable before rendering. This is not a public API commitment.
paths:
/admin/operations/{run}:
get:
summary: Canonical operation-run detail page
description: >-
Returns the rendered canonical run-detail page. The vendor media type documents
the internal structured page model used to drive the enterprise-detail surface.
parameters:
- name: run
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rendered canonical run detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.operation-run-detail+json:
schema:
$ref: '#/components/schemas/OperationRunDetailPage'
'403':
description: Viewer is in scope but lacks the required capability
'404':
description: Run is not visible because it does not exist or tenant/workspace entitlement is missing
components:
schemas:
OperationRunDetailPage:
type: object
required:
- header
- decisionZone
- supportingGroups
- mainSections
- technicalSections
properties:
header:
$ref: '#/components/schemas/PageHeader'
decisionZone:
$ref: '#/components/schemas/DecisionZone'
supportingGroups:
type: array
items:
$ref: '#/components/schemas/SupportingGroup'
mainSections:
type: array
items:
$ref: '#/components/schemas/DetailSection'
technicalSections:
type: array
items:
$ref: '#/components/schemas/DetailSection'
attentionBanners:
type: array
items:
$ref: '#/components/schemas/AttentionBanner'
PageHeader:
type: object
required:
- title
- statusBadges
- keyFacts
properties:
title:
type: string
subtitle:
type:
- string
- 'null'
statusBadges:
type: array
items:
$ref: '#/components/schemas/Badge'
keyFacts:
type: array
items:
$ref: '#/components/schemas/Fact'
primaryActions:
type: array
items:
$ref: '#/components/schemas/PageAction'
descriptionHint:
type:
- string
- 'null'
DecisionZone:
type: object
required:
- executionState
- outcome
- primaryNextStep
properties:
executionState:
$ref: '#/components/schemas/Fact'
outcome:
$ref: '#/components/schemas/Fact'
artifactTruth:
oneOf:
- $ref: '#/components/schemas/Fact'
- type: 'null'
resultMeaning:
oneOf:
- $ref: '#/components/schemas/Fact'
- type: 'null'
resultTrust:
oneOf:
- $ref: '#/components/schemas/Fact'
- type: 'null'
primaryNextStep:
$ref: '#/components/schemas/PrimaryNextStep'
compactCounts:
oneOf:
- $ref: '#/components/schemas/CountPresentation'
- type: 'null'
attentionNote:
type:
- string
- 'null'
PrimaryNextStep:
type: object
required:
- text
- source
properties:
text:
type: string
source:
type: string
enum:
- operator_explanation
- artifact_truth
- blocked_reason
- lifecycle_attention
- ops_ux
- none_required
secondaryGuidance:
type: array
items:
type: string
CountPresentation:
type: object
properties:
summaryLine:
type:
- string
- 'null'
primaryFacts:
type: array
items:
$ref: '#/components/schemas/Fact'
diagnosticFacts:
type: array
items:
$ref: '#/components/schemas/Fact'
SupportingGroup:
type: object
required:
- kind
- title
properties:
kind:
type: string
enum:
- guidance
- lifecycle
- timing
- metadata
title:
type: string
description:
type:
- string
- 'null'
items:
type: array
items:
$ref: '#/components/schemas/Fact'
DetailSection:
type: object
required:
- id
- title
properties:
id:
type: string
kind:
type:
- string
- 'null'
title:
type: string
description:
type:
- string
- 'null'
collapsible:
type: boolean
collapsed:
type: boolean
items:
type: array
items:
$ref: '#/components/schemas/Fact'
AttentionBanner:
type: object
required:
- tone
- title
- body
properties:
tone:
type: string
enum:
- info
- amber
- rose
- slate
title:
type: string
body:
type: string
Fact:
type: object
required:
- label
- value
properties:
label:
type: string
value:
type: string
hint:
type:
- string
- 'null'
badge:
oneOf:
- $ref: '#/components/schemas/Badge'
- type: 'null'
Badge:
type: object
required:
- label
properties:
label:
type: string
color:
type:
- string
- 'null'
icon:
type:
- string
- 'null'
iconColor:
type:
- string
- 'null'
PageAction:
type: object
required:
- label
- destructive
- requiresConfirmation
- visible
- openInNewTab
properties:
label:
type: string
placement:
type:
- string
- 'null'
url:
type:
- string
- 'null'
actionName:
type:
- string
- 'null'
destructive:
type: boolean
requiresConfirmation:
type: boolean
visible:
type: boolean
icon:
type:
- string
- 'null'
openInNewTab:
type: boolean

View File

@ -0,0 +1,182 @@
# Data Model: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
## Overview
This feature does not add or change persisted domain entities. It introduces a stricter derived page model for the canonical operation-run detail surface using existing `OperationRun`, artifact-truth, operator-explanation, summary-count, and lifecycle data.
The core design task is to transform an existing run record into a decision-first presentation contract without changing:
- `OperationRun` persistence
- route identity
- RBAC semantics
- status and outcome lifecycle ownership
- type-specific payload availability
## Existing Persistent Entity
### OperationRun
- Purpose: Canonical workspace-owned execution record for operational work tracked in Monitoring.
- Persistent fields used by this feature:
- `id`
- `workspace_id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `initiator_name`
- `summary_counts`
- `failure_summary`
- `context`
- `created_at`
- `started_at`
- `completed_at`
- Existing relationships used by this feature:
- `workspace`
- `tenant`
- `user`
## Derived View Models
### 1. RunDetailPageModel
Top-level page payload consumed by the enterprise-detail layout and viewer wrapper.
| Field | Type | Source | Notes |
|---|---|---|---|
| `header` | object | Existing `SummaryHeaderData` | Run identity, badges, key facts, existing page-level actions |
| `decisionZone` | object | New derived payload | First-class operator summary immediately after header |
| `supportingGroups` | list<object> | Existing supporting-card inputs regrouped | Replaces or restructures the current dense `Current state` card |
| `mainSections` | list<object> | Existing section builder output | Used for supporting detail and type-specific detail after the decision zone |
| `technicalSections` | list<object> | Existing technical-detail output | Diagnostics-last content such as context JSON |
| `attentionBanners` | list<object> | Existing viewer wrapper helpers | Context mismatch, blocked prerequisite, stale or reconciled lifecycle attention |
## 2. DecisionZoneModel
The primary operator reading block. It must answer: what happened, is the result trustworthy enough to use, and what should happen next.
| Field | Type | Source | Required | Notes |
|---|---|---|---|---|
| `executionState` | badge or labeled fact | `status` plus centralized badge rendering | Yes | Lifecycle state, not result quality |
| `outcome` | badge or labeled fact | `outcome` plus centralized badge rendering | Yes | Execution result, not artifact usability |
| `artifactTruth` | object | `ArtifactTruthPresenter::forOperationRun()` when available | Conditional | Required when the run has artifact-relevant operator meaning |
| `resultMeaning` | string | operator explanation headline or evaluation label | Conditional | Human-readable interpretation of what the result means |
| `resultTrust` | string or badge | operator explanation trust level | Conditional | Confidence or reliability meaning stays distinct from outcome |
| `primaryNextStep` | object | Derived precedence chain | Yes | Exactly one primary next step |
| `compactCounts` | object or null | `SummaryCountsNormalizer` output | Optional | Only when counts add decision value and do not duplicate diagnostics |
| `attentionNote` | string or null | lifecycle or caveat helper | Optional | Short exceptional-state caveat if needed in-zone |
### PrimaryNextStepModel
| Field | Type | Source | Notes |
|---|---|---|---|
| `text` | string | Derived from prioritized guidance sources | Canonical operator action statement |
| `source` | enum-like string | `operator_explanation`, `artifact_truth`, `ops_ux`, `blocked_reason`, `lifecycle_attention` | Debugging and testability only |
| `secondaryGuidance` | list<string> | Non-primary guidance sources | Must not render as equal-priority duplicates |
### Proposed precedence for `primaryNextStep`
1. Operator explanation next action when present
2. Artifact-truth next step when it is more specific than generic run guidance
3. Blocked-execution guidance when outcome is `blocked`
4. Lifecycle guidance when stale or reconciled state requires explicit attention
5. Generic `OperationUxPresenter::surfaceGuidance()`
6. Fallback `No action needed` only when the result is explicitly trustworthy and no follow-up is indicated
## 3. SupportingGroupModel
Semantically grouped supporting context that sits below or beside the decision zone but above diagnostics.
| Group | Purpose | Typical fields |
|---|---|---|
| `guidance` | Secondary operator context that supports the primary next step | coverage statement, contextual caveat, related follow-up links |
| `lifecycle` | Freshness, reconciliation, tenant lifecycle, and contextual caveats | freshness, lifecycle truth, reconciled at, reconciled by |
| `timing` | Operational timestamps and elapsed timing | created, started, completed, elapsed |
| `metadata` | Secondary facts that do not drive the first decision | target scope, initiator, viewer-context notes |
Rules:
- The same semantic fact must not appear in multiple supporting groups unless the representation serves a genuinely different purpose.
- Supporting groups must not restate the same primary next step as the decision zone.
- Supporting groups may explain why the primary next step exists, but not compete with it.
## 4. CountPresentationModel
| Field | Type | Purpose |
|---|---|---|
| `summaryLine` | string or null | Compact operator-facing count hint |
| `primaryFacts` | list<object> | Single main count presentation if counts materially help first-pass understanding |
| `diagnosticFacts` | list<object> | Optional deeper breakdown when the detailed count grid serves investigation |
Rules:
- `primaryFacts` and `diagnosticFacts` must not be identical renderings of the same normalized counts.
- If the detailed count grid remains in main content, the compact summary line must be materially different and lighter-weight.
- If counts are low-signal for the run, both may be omitted.
## 5. TypeSpecificDetailModel
Represents operation-type-specific content that follows the canonical decision summary.
| Operation Type | Existing section examples | Ordering rule |
|---|---|---|
| `baseline_compare` | compare facts, evidence gap details, compare evidence | After decision zone and supporting groups |
| `baseline_capture` | capture evidence | After decision zone and supporting groups |
| verification-capable runs | verification report | After decision zone and supporting groups |
| any run with failures | failures section | After decision zone and supporting groups |
| reconciled runs | lifecycle reconciliation payload | After decision zone and supporting groups |
Rules:
- Type-specific sections may deepen or explain the decision, but they must not replace the canonical summary.
- Raw JSON remains valid only as diagnostics or deep detail.
## 6. DiagnosticSectionModel
Lower-priority technical or investigative information.
| Section | Source | Default posture |
|---|---|---|
| `failures` | `failure_summary` | Visible after decision and supporting context |
| `reconciliation` | `context.reconciliation` | Visible only when relevant; diagnostic |
| `context` | redacted `context` payload | Collapsed by default |
| raw evidence payloads | baseline compare or capture payload arrays | Secondary or collapsible |
Rules:
- Diagnostic sections must remain available for support and investigation.
- Diagnostic sections must never be required to answer the primary operator questions.
## Render Ordering Contract
The surface must obey this order:
1. Attention banners that indicate exceptional context without duplicating neutral page facts
2. Header identity and route-level actions
3. Primary decision zone
4. Supporting semantic groups
5. Type-specific supporting detail
6. Diagnostic and raw context sections
## Validation Rules
- Exactly one primary next-step statement is rendered at top-level priority.
- No identical normalized count block appears more than once in the main content hierarchy.
- `Outcome`, `Artifact truth`, and `Result trust` remain independently visible when the run supplies those semantics.
- Diagnostic JSON or raw technical context cannot appear before the decision zone.
- Type-specific detail cannot render above the canonical decision zone.
## State Notes
There are no new persisted state transitions in this feature.
The relevant page-state modes that must remain representable are:
- completed and trustworthy
- completed with follow-up required
- failed
- blocked
- stale
- reconciled
- artifact unavailable or limited confidence
- type-specific diagnostic-heavy runs

View File

@ -0,0 +1,257 @@
# Implementation Plan: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
**Branch**: `164-run-detail-hardening` | **Date**: 2026-03-26 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/164-run-detail-hardening/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/164-run-detail-hardening/spec.md`
## Summary
Harden the canonical operation-run detail page into a decision-first enterprise surface without changing `OperationRun` domain semantics, routing, or authorization. The implementation introduces an explicit primary decision zone, centralizes the leading next step and count presentation, separates supporting context from diagnostics, preserves artifact truth and trust as distinct signals, keeps type-specific detail below the canonical summary layer, and validates the result with focused Pest feature and Livewire coverage.
Key approach: work inside the existing `OperationRunResource::enterpriseDetailPage()` and `TenantlessOperationRunViewer` seams, extend the `EnterpriseDetail` payload shape only where necessary, preserve the current canonical route and permission model, and rely on regrouping and prioritization rather than on new persistence or new screen families.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
**Storage**: PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned
**Testing**: Pest feature tests, Livewire page tests, and existing enterprise-detail unit coverage run through Sail
**Target Platform**: Laravel web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: Laravel monolith web application
**Performance Goals**: Keep canonical run detail DB-only at render and poll time, preserve 510 second operator scanability above the fold, avoid additional render-time data loading or external calls, and keep type-specific sections progressively reachable without layout thrash
**Constraints**: No new tables or domain models, no route changes, no RBAC drift, no Graph/render side effects, no semantic collapse between execution status and artifact truth, no duplicate primary next-step surface, and no new global Filament assets
**Scale/Scope**: One canonical operator-facing detail page with existing type-specific extensions, a small set of builder and Blade seams, and focused regression coverage across completed, partial, failed, stale or reconciled, and artifact-limited runs
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Status | Notes |
|-----------|--------|-------|
| Inventory-first | Pass | No inventory or snapshot ownership semantics change; this is a presentation hardening slice only |
| Read/write separation | Pass | No new mutation path is introduced; the surface remains read-only |
| Graph contract path | Pass | No new Graph calls or registry changes; render stays DB-only |
| Deterministic capabilities | Pass | No new capability derivation or role logic |
| RBAC-UX planes and 404 vs 403 | Pass | Canonical `/admin/operations/{run}` behavior remains policy-driven and tenant-safe |
| Workspace isolation | Pass | No workspace-context broadening; canonical viewer stays workspace-member gated |
| Tenant isolation | Pass | Tenant-linked related context and follow-up links remain entitlement-checked |
| Destructive confirmation | Pass | No new destructive action; any existing destructive-like action remains under existing confirmation rules |
| Global search safety | Pass | No searchability or global-search behavior changes |
| Run observability | Pass | Existing `OperationRun` lifecycle, monitoring role, and DB-only render contract remain unchanged |
| Ops-UX 3-surface feedback | Pass | No toast, progress, or terminal notification behavior changes |
| Ops-UX lifecycle ownership | Pass | `status` and `outcome` remain service-owned; this feature is display-only |
| Ops-UX summary counts | Pass | Counts continue to come from `OperationSummaryKeys::all()`-backed normalization; display dedupe only |
| Ops-UX guards | Pass | Existing lifecycle guards remain intact; new tests focus on decision-surface regressions |
| Data minimization | Pass | No new raw payload exposure; diagnostics remain secondary and redaction behavior stays in place |
| Badge semantics (BADGE-001) | Pass | Status, outcome, trust, and artifact-truth badges remain centralized through existing badge systems |
| UI naming (UI-NAMING-001) | Pass | Domain-first vocabulary remains `Outcome`, `Artifact truth`, `Result trust`, `Next step`, and related operator wording |
| Operator surfaces (OPSURF-001) | Pass | The feature explicitly strengthens operator-first default-visible content and pushes diagnostics later |
| Filament Action Surface Contract | Pass | No action inventory expansion; only hierarchy and grouping change on an existing detail page |
| Filament UX-001 | Pass with documented variance | The page remains a custom enterprise detail view rather than a stock infolist layout, but still satisfies the sectioned, operator-first intent |
| Filament v5 / Livewire v4 compliance | Pass | The work remains inside the current Filament v5 + Livewire v4 stack |
| Provider registration location | Pass | No panel/provider changes; Laravel 11+ provider registration stays in `bootstrap/providers.php` |
| Global-search hard rule | Pass | No resource search changes are proposed |
| Asset strategy | Pass | No new panel assets or published views required; existing Blade/Tailwind/Filament primitives are sufficient |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/164-run-detail-hardening/research.md`.
Key decisions:
- Introduce an explicit decision-zone section rather than trying to repurpose the header or the existing sidebar as the sole operator summary.
- Derive one canonical primary next step from existing explanation and guidance sources, with a defined precedence and explicit secondary-guidance fallback.
- Centralize counts into one primary presentation and move any extra count detail into diagnostics-only or purposefully different surfaces.
- Treat lifecycle attention as contextual supporting information or a banner-level exception, not as a duplicated fact across multiple equal-priority zones.
- Preserve type-specific sections, but force them below the generic decision and supporting layers.
- Keep raw payloads, JSON, detailed failures, and reconciliation evidence available through technical or diagnostic sections without deleting them.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/164-run-detail-hardening/`:
- `data-model.md`: derived run-detail view model and section-order contract
- `contracts/operation-run-detail-page.openapi.yaml`: internal page-contract schema for the canonical run detail surface and its structured payload
- `quickstart.md`: focused verification workflow for manual and automated validation
Design decisions:
- No schema migration is required; the design uses existing `OperationRun`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `EnterpriseDetail` data.
- The main implementation seam remains `OperationRunResource::enterpriseDetailPage()` plus the `EnterpriseDetail` Blade layout and supporting-card rendering.
- The design introduces a first-class decision zone and explicit supporting groups, rather than relying on a single dense supporting facts card.
- The design formalizes one primary next-step slot, one primary count slot, and a diagnostics-last ordering contract.
- Regression coverage expands around hierarchy, duplication, special-state clarity, and coexistence with baseline-compare and other type-specific detail.
## Project Structure
### Documentation (this feature)
```text
specs/164-run-detail-hardening/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── operation-run-detail-page.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ └── Operations/
│ │ └── TenantlessOperationRunViewer.php
│ └── Resources/
│ └── OperationRunResource.php
├── Support/
│ ├── OpsUx/
│ │ ├── OperationUxPresenter.php
│ │ └── SummaryCountsNormalizer.php
│ └── Ui/
│ ├── EnterpriseDetail/
│ │ ├── EnterpriseDetailBuilder.php
│ │ ├── EnterpriseDetailPageData.php
│ │ └── EnterpriseDetailSectionFactory.php
│ └── GovernanceArtifactTruth/
│ └── ArtifactTruthPresenter.php
resources/
└── views/
└── filament/
├── infolists/
│ └── entries/
│ ├── enterprise-detail/
│ │ ├── header.blade.php
│ │ ├── layout.blade.php
│ │ ├── section-items.blade.php
│ │ ├── supporting-card.blade.php
│ │ └── technical-detail.blade.php
│ └── governance-artifact-truth.blade.php
└── pages/
└── operations/
└── tenantless-operation-run-viewer.blade.php
tests/
├── Feature/
│ ├── Filament/
│ │ ├── OperationRunBaselineTruthSurfaceTest.php
│ │ └── OperationRunEnterpriseDetailPageTest.php
│ └── Operations/
│ └── TenantlessOperationRunViewerTest.php
└── Unit/
└── Support/
└── Ui/
└── EnterpriseDetail/
└── EnterpriseDetailBuilderTest.php
```
**Structure Decision**: Standard Laravel monolith. The change is concentrated in one canonical page composer, one viewer wrapper, a small enterprise-detail presentation layer, and focused Pest coverage. No new base directories or architectural layers are required.
## Implementation Strategy
### Phase A — Introduce A First-Class Decision Zone
**Goal**: Add an explicit, top-priority decision summary that answers outcome, artifact truth, trust, and the one primary next step without relying on the dense supporting card.
| Step | File | Change |
|------|------|--------|
| A.1 | `app/Filament/Resources/OperationRunResource.php` | Refactor `enterpriseDetailPage()` to assemble a first-class decision-zone payload before generic sections and supporting cards |
| A.2 | `app/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilder.php` and `app/Support/Ui/EnterpriseDetail/EnterpriseDetailPageData.php` | Extend the enterprise-detail payload contract so the builder can carry an explicit decision zone and grouped supporting content end-to-end |
| A.3 | `app/Support/Ui/EnterpriseDetail/EnterpriseDetailSectionFactory.php` | Add or refine helpers needed to construct decision-zone and grouped-support payloads without page-local array drift |
| A.4 | `resources/views/filament/infolists/entries/enterprise-detail/layout.blade.php` | Render the decision zone immediately after the header and before the main grid |
| A.5 | `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php` or a new dedicated decision-zone partial | Reuse existing artifact-truth semantics without duplicating the same top-level meaning elsewhere |
### Phase B — Canonicalize Primary Next Step And Count Placement
**Goal**: Remove semantically duplicated next-step and count presentations while preserving deeper diagnostic value.
| Step | File | Change |
|------|------|--------|
| B.1 | `app/Filament/Resources/OperationRunResource.php` | Define a single primary-next-step resolution order across operator explanation, artifact truth, blocked guidance, and `OperationUxPresenter` |
| B.2 | `app/Filament/Resources/OperationRunResource.php` | Remove duplicate count blocks and keep one primary count surface plus optional diagnostics-only detail |
| B.3 | `app/Support/OpsUx/OperationUxPresenter.php` and existing helpers if needed | Keep guidance sourcing centralized rather than inventing page-local text rules |
| B.4 | Relevant enterprise-detail partials | Ensure the same next-step statement is not rendered in both decision and supporting zones |
### Phase C — Rebuild Supporting Context As Semantic Groups
**Goal**: Replace the current fact-dump sidebar with grouped supporting context for guidance, lifecycle caveats, timing, and secondary metadata.
| Step | File | Change |
|------|------|--------|
| C.1 | `app/Filament/Resources/OperationRunResource.php` | Split the existing `Current state` card into semantically grouped cards or grouped payload sections |
| C.2 | `resources/views/filament/infolists/entries/enterprise-detail/supporting-card.blade.php` | Support richer grouping or presentation treatment if the current generic fact card is insufficient |
| C.3 | `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Keep mismatch, blocked, and lifecycle banners as contextual attention surfaces only where they add non-duplicative value |
| C.4 | `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` | Preserve banner-before-detail ordering while avoiding redundant repetition of the same lifecycle message inside neutral summary facts |
### Phase D — Preserve Type-Specific Detail And Diagnostics-Last Ordering
**Goal**: Keep baseline compare, baseline capture, verification, failure, reconciliation, and raw context sections available but clearly lower in the reading order.
| Step | File | Change |
|------|------|--------|
| D.1 | `app/Filament/Resources/OperationRunResource.php` | Reorder type-specific sections below the canonical decision and supporting layers |
| D.2 | `resources/views/filament/infolists/entries/enterprise-detail/technical-detail.blade.php` | Keep technical detail progressive and collapsed where appropriate |
| D.3 | Existing type-specific partials and JSON sections | Preserve data availability while ensuring raw payloads do not compete with the primary decision zone |
### Phase E — Regression Protection And Focused Validation
**Goal**: Lock the new hierarchy into tests and preserve route, RBAC, and semantic non-regression.
| Step | File | Change |
|------|------|--------|
| E.1 | `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` | Add hierarchy, count-deduplication, next-step singularity, diagnostics-order, and required scenario-matrix assertions |
| E.2 | `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php` | Protect the distinction between outcome, artifact truth, trust, and next action on artifact-heavy runs, including summary-versus-expansion non-duplication |
| E.3 | `tests/Feature/Operations/TenantlessOperationRunViewerTest.php` | Preserve banner behavior, canonical view accessibility, and positive or negative tenant-safe related-context and diagnostic-visibility semantics |
| E.4 | `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php` | Extend payload-shape assertions if the builder gains decision-zone or grouped-supporting semantics |
| E.5 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Required formatting and targeted verification before task completion |
## Key Design Decisions
### D-001 — Decision summary must be explicit, not inferred from header plus sidebar
The current page spreads the meaning across header badges, a run-summary section, artifact truth, and the dense `Current state` card. A dedicated decision zone creates one predictable operator reading path without changing the underlying domain semantics.
### D-002 — One primary next step needs a deterministic precedence rule
The current page can expose `Artifact next step`, `Next step`, and lifecycle guidance at the same level. The design resolves one primary next-step slot first and demotes all other guidance to secondary context.
### D-003 — Counts should exist once as a decision aid and optionally again only as diagnostics
The current page can show counts in the main sections and in the supporting card. The design keeps one operator-facing count summary and treats any deeper count grid as diagnostics-only when genuinely useful.
### D-004 — Lifecycle attention belongs in contextual emphasis, not in repeated fact rows
Blocked, stale, and reconciled states are important, but they should appear as clear contextual attention surfaces or grouped caveats instead of as duplicate headline facts in multiple page regions.
### D-005 — Type-specific sections remain important, but only after the generic decision layer
Baseline compare, baseline capture, verification, and similar operation-type detail should deepen the operator understanding after the page has already answered the generic triage questions.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Decision-zone refactor unintentionally hides useful diagnostics | Medium | Medium | Use progressive disclosure and keep raw sections intact below the primary summary |
| Next-step consolidation oversimplifies special cases | Medium | Medium | Use explicit precedence plus preserved secondary guidance rather than dropping alternative guidance |
| Count deduplication removes meaningful drill-down context | Medium | Low | Keep one diagnostics-only detailed count surface when it has a different purpose |
| Artifact truth and outcome become visually merged | High | Low | Preserve separate labels, badges, and assertions in both the plan and tests |
| Type-specific sections drift upward and reintroduce hierarchy noise | Medium | Medium | Lock ordering in feature tests and keep type-specific sections appended after canonical summary layers |
## Test Strategy
- Extend existing canonical run-detail feature coverage instead of creating a brand-new test architecture.
- Add scenario coverage for completed success, completed with follow-up or partial, failed, blocked, stale or reconciled, and artifact-limited runs.
- Add explicit assertions for no duplicate next-step statement at equal priority and no duplicate main-content count block.
- Add explicit assertions that the decision-zone artifact-truth summary is not simply restated by a later artifact-truth section.
- Preserve canonical-view non-regression: tenant-safe access, positive or negative related-context and diagnostic-visibility semantics, 404 versus 403 behavior, and no render-time external calls.
- Keep Livewire v4-compatible page tests for the canonical viewer and Pest feature tests for rendered ordering and section presence.
## Complexity Tracking
No constitution violations or justified complexity exceptions were identified.

View File

@ -0,0 +1,114 @@
# Quickstart: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
## Goal
Validate that the canonical operation-run detail page now behaves as a decision-first surface without changing route semantics, authorization, or underlying `OperationRun` meaning.
## Prerequisites
1. Start Sail if it is not already running.
2. Ensure the workspace has representative `OperationRun` fixtures for:
- completed success
- completed partial or follow-up required
- failed
- blocked
- stale or reconciled
- artifact-heavy baseline capture or baseline compare
3. Ensure the acting user is a valid workspace member and, for tenant-bound runs, entitled to the referenced tenant.
## Focused Automated Verification
Run only the tests that guard this surface first:
```bash
vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php
vendor/bin/sail artisan test --compact tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php
```
If new spec-scoped tests are added during implementation, run them alongside the existing files with `--filter` or direct file paths.
## Manual Validation Pass
For each representative run, open the canonical detail page and check the following in order.
### 1. First-screen decision quality
Confirm the first visible page height answers:
- what run this is
- how it ended
- whether the visible result is trustworthy enough to use
- what the operator should do next
### 2. Next-step singularity
Confirm there is exactly one leading next-step statement in the decision zone.
Confirm that:
- the same next-step text is not repeated in a sidebar card
- the same next-step text is not repeated in a run summary section
- any extra guidance is visibly secondary
### 3. Count deduplication
Confirm there is no duplicate count block in main content.
Allowed:
- one primary count summary
- one clearly diagnostic count breakdown with a different purpose
Not allowed:
- the same normalized counts shown twice as equal-priority summary content
### 4. Semantic separation
For artifact-heavy runs, confirm the page still distinguishes:
- execution status
- outcome
- artifact truth
- result trust
- next action
A successful execution outcome must still be able to coexist with limited trust or weak artifact usability.
### 5. Special-state behavior
For blocked, stale, or reconciled runs, confirm:
- the exceptional state is visible
- the explanation is understandable without opening JSON
- the same lifecycle warning is not repeated as equal-priority content across banner, decision zone, and supporting facts
### 6. Type-specific coexistence
For baseline compare, baseline capture, or verification-heavy runs, confirm:
- the generic decision zone appears first
- type-specific detail appears afterward
- raw payloads and JSON remain secondary
## Non-Regression Checks
Confirm the feature did not change:
- canonical route identity for `/admin/operations/{run}`
- workspace or tenant authorization behavior
- 404 versus 403 semantics
- DB-only render behavior on the page
- existing related-context links and canonical navigation patterns
## Formatting And Final Verification
Before finalizing implementation work:
```bash
vendor/bin/sail bin pint --dirty --format agent
```
Then rerun the smallest affected test set. If the user wants a broader confidence pass afterward, offer the full suite.

View File

@ -0,0 +1,57 @@
# Research: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
## Decision 1: Introduce an explicit primary decision zone above the current main/supporting split
- Decision: Add a first-class decision zone immediately below the page header and before the current main grid, instead of relying on the header, `Run summary`, and `Current state` card together to communicate the operator judgment.
- Rationale: The current page spreads the decisive meaning across multiple regions. The rendering analysis shows that header badges, `Run summary`, `Artifact truth`, and the dense supporting card currently compete with each other. A dedicated decision zone gives the operator one predictable place to read the outcome, artifact truth, trust meaning, and next step in one pass.
- Alternatives considered:
- Keep the current header and only simplify the sidebar. Rejected because it leaves the main decision meaning fragmented across header, main content, and sidebar.
- Push all decision meaning into the header. Rejected because the current header payload is designed for identity and key facts, not a deeper semantic summary with artifact truth and trust nuance.
## Decision 2: Resolve exactly one primary next step from existing guidance sources
- Decision: Define a precedence order for the leading next step and surface only that one in the decision zone. Secondary guidance remains available in supporting or diagnostic areas only when it adds context.
- Rationale: The current page can show `Artifact next step`, `Next step`, blocked guidance, and lifecycle guidance at the same weight. That dilutes operator confidence. The source data already exists in `ArtifactTruthPresenter`, operator explanation payloads, `OperationUxPresenter`, and blocked/lifecycle helpers, so the right solution is selection and prioritization, not new copy systems.
- Alternatives considered:
- Continue rendering all next-step variants and rely on wording differences. Rejected because the same operator question still gets multiple simultaneous answers.
- Remove artifact-specific guidance entirely in favor of generic guidance. Rejected because artifact-heavy runs need artifact-truth-specific follow-up to remain accurate.
## Decision 3: Centralize count presentation and treat additional count detail as diagnostics-only
- Decision: Keep one primary count representation and allow a second count representation only when it serves a different purpose, such as a detailed breakdown in diagnostics.
- Rationale: The current page renders count meaning in both the supporting facts card and a dedicated `Counts` section. That redundancy adds noise without improving understanding. The summary-count system is already normalized through `SummaryCountsNormalizer`, so the issue is presentation duplication rather than source inconsistency.
- Alternatives considered:
- Remove all count detail from the top half of the page. Rejected because some runs still need a compact quantitative summary to judge scale or completeness.
- Keep both current count surfaces. Rejected because the spec explicitly forbids identical or effectively identical duplicate count blocks.
## Decision 4: Treat lifecycle and reconciliation states as contextual attention, not as equal-priority summary duplication
- Decision: Keep mismatch, blocked, stale, and reconciled signals visible through banners or grouped lifecycle context, but stop repeating the same meaning inside neutral summary facts at the same priority level.
- Rationale: The viewer already has banner helpers in `TenantlessOperationRunViewer` and lifecycle facts inside the supporting card. The issue is not lack of visibility but repeated visibility at the same weight. Contextual attention should sharpen the decision without producing three competing truth surfaces.
- Alternatives considered:
- Remove banners and show everything as facts in the supporting area. Rejected because exceptional states deserve a stronger visual emphasis than ordinary metadata.
- Keep banner, supporting facts, and diagnostic section all equally explicit. Rejected because this is the duplication problem the spec is trying to solve.
## Decision 5: Preserve artifact truth and trust as distinct semantics inside the decision zone
- Decision: Keep `Outcome`, `Artifact truth`, `Result meaning`, and `Result trust` as separate but adjacent decision signals instead of collapsing them into one generic health message.
- Rationale: The underlying domain is already strong precisely because execution lifecycle and result usability are distinct. The artifact-truth partial and operator explanation payload already model those dimensions separately, and the spec explicitly forbids semantic collapse.
- Alternatives considered:
- Replace the four dimensions with one overall success or warning headline. Rejected because it would hide whether the run completed successfully but produced a weak or unusable artifact.
- Move artifact truth fully out of the primary decision zone. Rejected because artifact-heavy runs depend on that signal for safe operator action.
## Decision 6: Keep type-specific detail after the canonical decision zone and supporting context
- Decision: Preserve type-specific sections such as baseline compare evidence, baseline capture evidence, verification report, failures, and reconciliation details, but reorder them below the generic decision-first hierarchy.
- Rationale: The current `OperationRunResource::enterpriseDetailPage()` already appends type-specific sections after generic sections. The feature should strengthen that ordering contract and ensure future refactors do not let type-specific content dominate the first screen height.
- Alternatives considered:
- Fold type-specific detail into the decision zone. Rejected because it would make the canonical top-level summary too wide and operation-type-dependent.
- Hide type-specific detail behind separate navigation. Rejected because operators still need one canonical page with deeper evidence available in-place.
## Decision 7: Extend existing feature and Livewire tests rather than introducing a new UI test harness
- Decision: Build on `OperationRunEnterpriseDetailPageTest`, `OperationRunBaselineTruthSurfaceTest`, `TenantlessOperationRunViewerTest`, and the enterprise-detail builder unit tests for regression coverage.
- Rationale: The relevant seams already have useful test coverage for hierarchy, artifact truth, tenant-safe viewing, and DB-only render behavior. The hardening work is a refinement of the existing canonical page, so extending those tests is lower-risk and keeps the assertions close to the current architecture.
- Alternatives considered:
- Add only manual visual verification. Rejected because this spec is explicitly about preventing hierarchy and duplication regressions.
- Create a separate browser-test suite as the primary guard. Rejected because the current repo already has faster, targeted feature coverage around this page structure, and the required assertions are mostly structural and textual.

View File

@ -0,0 +1,203 @@
# Feature Specification: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
**Feature Branch**: `164-run-detail-hardening`
**Created**: 2026-03-26
**Status**: Draft
**Input**: User description: "Spec 164 — Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening"
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- `/admin/operations/{run}`
- Existing in-product deep links that resolve to the canonical operation-run detail page
- **Data Ownership**:
- `OperationRun` remains the canonical workspace-owned execution record even when it references a tenant or related artifact
- Related artifacts, tenant context, and operation-type-specific evidence remain owned by their existing domain records
- This feature changes presentation hierarchy and operator guidance only; it does not change ownership, persistence, or route identity
- **RBAC**:
- Existing workspace membership remains required to reach the canonical run detail page
- Existing tenant entitlement rules continue to govern tenant-linked related context and any tenant-bound follow-up links
- Existing operation history or monitoring capabilities remain authoritative for viewing the run detail experience
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Remembered tenant context may continue to influence the origin surface or back-navigation convenience, but it must not change the legitimacy, hierarchy, or visible decision contract of the canonical run detail page itself.
- **Explicit entitlement checks preventing cross-tenant leakage**: The page must continue to resolve from the run and authorized related records only. Non-members or actors without tenant entitlement for tenant-linked context must receive deny-as-not-found behavior for inaccessible targets, and the detail surface must not leak inaccessible related records, counts, or hints through summary, diagnostics, or navigation.
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|
| Canonical operation-run detail | Workspace operator or entitled tenant operator | Canonical detail | What happened in this run, is the result trustworthy enough to use, and what should I do next? | Run identity, execution state, outcome, artifact truth, result meaning or trust, one primary next step, high-signal lifecycle caveats, compact supporting facts | Detailed count breakdowns, raw failure fragments, large context blocks, JSON payloads, technical reason evidence, low-level reconciliation detail | execution status, outcome, artifact truth, trust or confidence, lifecycle or freshness, next-action readiness | Read-only surface; any contextual links continue to use their existing mutation-scope messaging outside this spec | View run, follow the primary next step, open related context, refresh the page | None introduced by this spec |
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Triage a run in one scan (Priority: P1)
As an operator opening a single operation run, I want the first visible area of the page to tell me what happened, whether the result is usable, and what I should do next, so that I do not need to assemble the answer from multiple sections.
**Why this priority**: The page is the canonical decision surface for a run. If the operator cannot answer the core triage questions quickly, the surface fails even when the domain model is correct.
**Independent Test**: Can be fully tested by opening completed, partial, and failed runs and verifying that execution state, outcome, artifact truth, trust meaning, and one primary next step are visible before diagnostic sections.
**Acceptance Scenarios**:
1. **Given** a completed run with follow-up required, **When** an operator opens the canonical run detail page, **Then** the first visible zone shows the run result, its trustworthiness, and exactly one primary next step without requiring scrolling.
2. **Given** a completed run with a trustworthy result and no urgent follow-up, **When** an operator opens the page, **Then** the first visible zone communicates that the result is usable and does not bury that message beneath counts or raw detail.
3. **Given** a failed or blocked run, **When** an operator opens the page, **Then** the first visible zone explains the failure character and the most sensible follow-up action before diagnostic evidence.
---
### User Story 2 - Understand special-state caveats without confusion (Priority: P2)
As an operator reviewing a stale, reconciled, partial, or artifact-limited run, I want the page to explain why the result needs caution without repeating the same warning in multiple places, so that I can trust the page's interpretation.
**Why this priority**: Special states are operationally important, but they should sharpen the decision rather than flood the page with duplicate warnings.
**Independent Test**: Can be fully tested by opening runs with stale, reconciled, blocked, and limited-confidence states and verifying that caveats are visible, contextual, and not rendered as competing top-level truths.
**Acceptance Scenarios**:
1. **Given** a stale or lifecycle-reconciled run, **When** the page renders, **Then** the operator sees why the lifecycle is unusual and what follow-up is appropriate without the page repeating that state as equal-priority summary facts, banners, and duplicate cards.
2. **Given** a run whose artifact exists but has limited trust or incomplete evidence, **When** the page renders, **Then** artifact truth remains distinct from outcome and the operator can see the caution without opening diagnostics.
3. **Given** a run with no artifact or no trustworthy result to use, **When** the page renders, **Then** the page makes that limitation explicit and points the operator to the next reasonable action.
---
### User Story 3 - Keep deep detail without losing the decision hierarchy (Priority: P3)
As an operator or support engineer, I want type-specific detail and diagnostics to remain available after the main decision summary, so that I can continue from triage into investigation without losing the canonical page structure.
**Why this priority**: The feature must improve scanability without flattening the strong domain depth that makes the page useful for investigation.
**Independent Test**: Can be fully tested by opening runs with baseline-compare or other type-specific sections and confirming that those sections still render, but only after the canonical decision and supporting context.
**Acceptance Scenarios**:
1. **Given** a run with type-specific evidence or findings, **When** the operator opens the page, **Then** the generic decision summary appears first and the type-specific detail appears afterwards as deeper context.
2. **Given** a run with extensive counts, reasons, or raw context, **When** the operator opens the page, **Then** diagnostics remain available but do not appear at the same priority as the decision summary.
3. **Given** a run with both high-signal summary facts and technical evidence, **When** the page renders, **Then** the page preserves a clear reading order of decision first, supporting detail second, diagnostics third.
### Edge Cases
- A completed run may have an apparently successful execution outcome but an artifact-truth or trust statement that makes the result unsafe to use; the page must show both meanings without collapsing one into the other.
- A stale or lifecycle-reconciled run may require attention even when it is not technically failed; the page must explain the situation without duplicating the same message across multiple equal-priority sections.
- A run may contain large count sets, failure summaries, or raw payload context; the page must keep those details available without letting them crowd out the primary decision zone.
- A run may have no meaningful counts at all; the page must still present a coherent decision surface instead of reserving high-value space for empty or low-signal sections.
- A run may include operation-type-specific detail such as baseline compare evidence, restore outcomes, or artifact-heavy context; those details must remain usable without displacing the canonical summary-first hierarchy.
- A run may be opened from a tenant-related entry point while still being a canonical workspace run; the hierarchy and visible meaning must remain stable and permission-safe.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new change workflow, and no new long-running job type. It hardens the presentation and operator guidance of an existing canonical monitoring record. Existing `OperationRun` creation, audit behavior, and safe-execution rules for any already-existing follow-up actions remain authoritative and unchanged.
**Constitution alignment (OPS-UX):** This feature reuses existing `OperationRun` records as a read surface only. The Ops-UX 3-surface feedback contract remains unchanged. `OperationRun.status` and `OperationRun.outcome` remain service-owned through the existing run service. `summary_counts` continues to honor `OperationSummaryKeys::all()` and numeric-only normalization. Scheduled or system-run behavior does not change. Regression coverage for this feature must protect decision hierarchy, next-step singularity, count deduplication, special-state clarity, type-specific coexistence, and navigation or authorization non-regression.
**Constitution alignment (RBAC-UX):** This feature stays in the admin canonical-view plane and may display tenant-linked context. Non-members or actors lacking tenant entitlement for tenant-linked context remain deny-as-not-found. Members who are otherwise in scope but lack the capability to inspect the run remain forbidden. Authorization continues to be enforced server-side through existing policies, Gates, and canonical capability registries. No raw capability strings or role-string shortcuts may be introduced. Global search behavior remains non-member-safe. This feature introduces no new destructive action; any existing destructive-like action exposed from the page must continue to require confirmation and existing authorization.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. Monitoring and canonical run detail remain outside any `/auth/*` exception path.
**Constitution alignment (BADGE-001):** Any status-like badges shown or elevated by this feature must continue to come from centralized badge semantics. Execution status, outcome, artifact truth, trust or confidence, and lifecycle or freshness meaning must not be mapped ad hoc on the page.
**Constitution alignment (UI-NAMING-001):** The target object is the operation run. Primary operator verbs remain consistent with the existing vocabulary, including `View run` and other established follow-up language. The page must preserve domain terms such as `Outcome`, `Artifact truth`, `Trust`, `Follow-up`, and `Next step` where they carry distinct meaning, and must avoid replacing them with vague implementation-first or oversimplified labels.
**Constitution alignment (OPSURF-001):** The default-visible content must remain operator-first and answer the decision questions before raw implementation detail. Diagnostics must be explicitly secondary. The page must continue to show execution outcome, artifact truth, trust or confidence, and lifecycle or freshness as separate status dimensions because operators need each dimension for sound decisions. No new mutation is introduced by this spec. Workspace and tenant context remain explicit in page meaning and related navigation. The surface contract is defined above for the canonical run detail page.
**Constitution alignment (Filament Action Surfaces):** This feature materially refactors an existing Filament-backed detail surface. The Action Surface Contract remains satisfied because the refactor changes hierarchy, grouping, and repetition rather than expanding the action inventory. The UI Action Matrix below documents the affected surface. No exemption is required.
**Constitution alignment (UX-001 — Layout & Information Architecture):** The canonical run detail page remains a structured view surface, not a disabled edit form. The page must keep information inside clearly named sections or cards, keep badges centralized, and use progressive disclosure for diagnostics. Because this is an existing custom enterprise detail view rather than a standard resource infolist, the current custom-view composition remains an explicit exemption from the default view-page pattern, but the feature must still satisfy the operator-first hierarchy intent of UX-001.
### Functional Requirements
- **FR-164-001**: The system MUST give the canonical run detail page one explicit primary decision zone in the immediately visible upper portion of the page.
- **FR-164-002**: The primary decision zone MUST present execution or run state, outcome, artifact truth, result meaning or trust, and exactly one primary next step in one coherent reading path.
- **FR-164-003**: The primary decision zone MUST answer, without scrolling, what happened in the run, whether the result is usable, and what the operator should do next.
- **FR-164-004**: The page MUST show exactly one leading next-step statement as the authoritative operator action, and any additional guidance MUST appear only as secondary or context-specific guidance.
- **FR-164-005**: The page MUST NOT repeat the same next-step statement in multiple equal-priority sections, cards, or grids.
- **FR-164-006**: The page MUST provide one primary count presentation when `summary_counts` adds decision value, and any secondary count display MUST serve a clearly different purpose such as compact hinting or deeper diagnostics.
- **FR-164-007**: The page MUST NOT render identical or effectively identical count blocks more than once in the main content hierarchy.
- **FR-164-008**: Any run-summary section that remains on the page MUST add information not already adequately conveyed by the header and primary decision zone; otherwise that section MUST be removed or absorbed into another structure.
- **FR-164-009**: The supporting area MUST be reorganized from a flat fact dump into semantically grouped supporting information so that guidance, lifecycle caveats, and secondary metadata are distinguishable at a glance.
- **FR-164-010**: Artifact truth MUST remain a distinct first-class statement whenever result usability depends on it, and it MUST NOT be collapsed into outcome, execution lifecycle, or generic success or failure wording.
- **FR-164-011**: Artifact truth MAY be summarized in the primary decision zone and expanded later in the page, but the same artifact-truth meaning MUST NOT be repeated in multiple equal-priority areas.
- **FR-164-012**: Stale, reconciled, blocked, partial, or otherwise special lifecycle conditions MUST remain visible and must explain why the run landed in that state and what follow-up is sensible.
- **FR-164-013**: Special lifecycle conditions MUST be presented contextually and MUST NOT dominate the normal page layout through redundant repetition across banner, sidebar, and summary zones.
- **FR-164-014**: The page MUST follow a stable reading order of decision first, supporting operational detail second, and diagnostics or raw context third.
- **FR-164-015**: Large context blocks, raw payloads, JSON fragments, detailed reason evidence, and other low-frequency technical information MUST remain secondary to decision content and MUST be explicitly disclosed or placed later on the page.
- **FR-164-016**: The first visible page height MUST allow an operator to understand the run identity, how it ended, whether the visible result is trustworthy enough to use, and whether immediate action is needed within a short scan.
- **FR-164-017**: The page MUST preserve the semantic distinction between execution status, outcome, artifact truth, trust or confidence, and next action even while simplifying the visible hierarchy.
- **FR-164-018**: The page MUST continue to support operation-type-specific detail sections, but those sections MUST appear after the canonical decision and supporting summary layers.
- **FR-164-019**: The page MUST preserve the existing canonical run-detail route, deep-link behavior, and workspace-versus-tenant meaning of the record.
- **FR-164-020**: The page MUST preserve existing RBAC and visibility semantics and MUST NOT widen access through summary, diagnostics, or related-context presentation.
- **FR-164-021**: The page MUST use progressive disclosure or lower-priority placement for support and investigation detail rather than removing information that operators or support staff still need.
### Non-Functional Requirements
- **NFR-164-001**: The feature SHOULD be deliverable without new tables, new domain entities, or required persistence-model changes.
- **NFR-164-002**: Operator-facing copy may become shorter or clearer, but it MUST NOT flatten or blur the existing domain semantics around follow-up, artifact truth, trust, or limited confidence.
- **NFR-164-003**: Existing canonical route identity and existing deep links to the page MUST remain stable.
- **NFR-164-004**: Existing RBAC and view-permission behavior MUST remain intact.
- **NFR-164-005**: De-prioritized technical content MUST remain available for investigation through lower-priority sections or progressive disclosure.
### Non-Goals
- Redesigning the operations list, queue view, or monitoring filter logic
- Changing notification copy, toast behavior, or persistent notification flows
- Reworking dashboard widgets, floating progress surfaces, or global monitoring shells
- Introducing new outcome enums, execution-status enums, or new artifact-governance models
- Changing `OperationRun` persistence, record ownership, or the underlying domain state model
- Performing a broad Filament design-system overhaul beyond this single decision surface
### Assumptions
- The current `OperationRun` domain model, outcome semantics, artifact-truth logic, and operator explanation layer are already strong enough that the primary need is surface hardening rather than conceptual redesign.
- The canonical run detail page already contains the necessary information for a stronger decision hierarchy, but currently presents too many facts at the same visual weight.
- Existing type-specific sections, related context, and diagnostic payloads remain valuable and should be preserved behind a better primary reading order.
- Existing navigation, canonical links, and authorization behavior remain the source of truth and should not be redefined by this feature.
### Dependencies
- Existing canonical `OperationRun` model and its separation of execution status, outcome, and context
- Existing artifact-truth, trust or confidence, and operator-explanation semantics already available to the run detail experience
- Existing canonical operation-run navigation and tenant-safe related-context resolution
- Existing RBAC and visibility rules for workspace membership, tenant entitlement, and run inspection
- Existing type-specific operation detail content that must continue to coexist with the canonical page hierarchy
### Risks
- The page could become calmer but less useful if diagnostic depth is removed instead of deliberately de-prioritized.
- A layout-driven simplification could blur the distinction between outcome, artifact truth, and trust, which would weaken operator judgment.
- A stronger generic decision zone could accidentally crowd out type-specific detail for artifact-heavy or evidence-heavy runs if the hierarchy is not enforced carefully.
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Canonical operation-run detail | Existing canonical operation-run detail page | Existing page-level navigation and non-destructive follow-up actions remain; no new destructive header action is introduced by this spec | Existing `View run` affordances from operations and related surfaces remain the entry point | `View run` remains the relevant row action on upstream list surfaces | None added by this spec | Not applicable on the detail page | Existing view-page actions remain, but the page must expose one primary next-step statement and demote secondary guidance | Not applicable | No new audit event introduced by this spec | Action Surface Contract satisfied. Any already-existing destructive or run-triggering follow-up action remains governed by existing confirmation, authorization, and audit rules outside this hierarchy-hardening change. |
### Key Entities *(include if feature involves data)*
- **Operation Run**: The canonical execution record whose detail page is the authoritative operator view for what happened, how it ended, and what to do next.
- **Primary Decision Zone**: The first visible summary area that expresses execution state, outcome, artifact truth, trust meaning, and one primary next step.
- **Supporting Operational Context**: Secondary page content that explains lifecycle caveats, timing, freshness, reconciliation, and compact supporting facts without competing with the primary decision.
- **Diagnostic Surface**: Lower-priority detail such as raw payloads, detailed counts, failure evidence, and technical context used for investigation after the initial decision is made.
- **Type-Specific Detail Block**: Operation-specific content that remains important for deeper understanding, but must sit below the canonical decision and supporting layers.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-164-001**: In acceptance review, operators can identify the run result, its trustworthiness, and the primary next step from the first visible page height for every covered scenario in 10 seconds or less.
- **SC-164-002**: In covered scenarios, the page presents exactly one primary next-step statement and does not repeat the same next-step text in multiple equal-priority regions.
- **SC-164-003**: In covered scenarios, the main content hierarchy contains no duplicate or effectively duplicate count block.
- **SC-164-004**: In covered completed, partial, failed, stale or reconciled, and artifact-limited scenarios, operators can tell whether immediate action is needed without opening diagnostic sections.
- **SC-164-005**: Type-specific operation detail remains available for covered run types, but always appears after the canonical decision and supporting summary layers.
- **SC-164-006**: Navigation, RBAC behavior, and the semantic separation between execution status, outcome, artifact truth, trust, and next action remain unchanged in regression review.

View File

@ -0,0 +1,224 @@
# Tasks: Operation Run Detail Hierarchy, Deduplication, and Decision Guidance Hardening
**Input**: Design documents from `/specs/164-run-detail-hardening/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`
**Tests**: Tests are REQUIRED for this feature. Use Pest coverage in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`.
**Operations**: This feature reuses existing `OperationRun` records as a read-only canonical surface. No new run creation, lifecycle transition, notification, or `summary_counts` producer work is introduced.
**RBAC**: Existing canonical run-view authorization and 404 vs 403 semantics must remain unchanged. Tests must prove no regression for workspace membership, tenant entitlement, and capability enforcement on `/admin/operations/{run}`.
**Operator Surfaces**: The canonical operation-run detail page must remain operator-first, with one primary decision zone, grouped supporting context, and diagnostics later.
**Filament UI Action Surfaces**: No new actions are added. Existing view-page actions, row inspection affordances, and confirmation behavior must remain intact while the page hierarchy changes.
**Filament UI UX-001**: The page remains a custom enterprise detail view with an explicit operator-first hierarchy; diagnostics and JSON stay secondary.
**Badges**: Execution status, outcome, artifact truth, trust, and lifecycle badges must continue to use centralized badge semantics.
**Organization**: Tasks are grouped by user story so each story can be implemented and tested as an independent increment after the shared presentation scaffolding is in place.
## Phase 1: Setup (Shared Presentation Contract)
**Purpose**: Add the shared enterprise-detail primitives needed by all user stories.
- [X] T001 Extend `EnterpriseDetailBuilder` to carry `decisionZone` and `supportingGroups` in `app/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilder.php`
- [X] T002 [P] Extend the enterprise-detail payload contract for `decisionZone` and `supportingGroups` in `app/Support/Ui/EnterpriseDetail/EnterpriseDetailPageData.php`
- [X] T003 [P] Add or refine enterprise-detail helpers for decision-zone and grouped-support payload assembly in `app/Support/Ui/EnterpriseDetail/EnterpriseDetailSectionFactory.php`
- [X] T004 [P] Create the decision-zone rendering partial in `resources/views/filament/infolists/entries/enterprise-detail/decision-zone.blade.php`
- [X] T005 [P] Extend payload-shape coverage for the new enterprise-detail contract in `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`
---
## Phase 2: Foundational (Blocking Layout Prerequisites)
**Purpose**: Wire the new shared page shape into the reusable enterprise-detail layout before story-specific behavior changes.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T006 Update enterprise-detail rendering order to `header -> decision zone -> supporting groups -> main sections -> technical sections` in `resources/views/filament/infolists/entries/enterprise-detail/layout.blade.php`
- [X] T007 [P] Update grouped supporting-card rendering in `resources/views/filament/infolists/entries/enterprise-detail/supporting-card.blade.php`
- [X] T008 [P] Adjust fact-grid rendering so summary and diagnostic presentations can diverge cleanly in `resources/views/filament/infolists/entries/enterprise-detail/section-items.blade.php`
**Checkpoint**: The reusable enterprise-detail shell can now host a first-class decision zone and semantically grouped supporting content.
---
## Phase 3: User Story 1 - Triage a Run in One Scan (Priority: P1) 🎯 MVP
**Goal**: Make the first visible page area answer what happened, whether the result is usable, and what the operator should do next.
**Independent Test**: Open completed-success, partial or completed-with-follow-up, failed, and blocked runs and verify that execution state, outcome, artifact truth, trust meaning, and exactly one primary next step appear before diagnostic sections.
### Tests for User Story 1
- [X] T009 [P] [US1] Extend first-screen hierarchy assertions for completed success, partial or completed-with-follow-up, failed, and blocked runs plus count deduplication in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T010 [P] [US1] Extend artifact-truth versus outcome versus trust assertions and artifact-truth summary-versus-expansion non-duplication coverage in `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
### Implementation for User Story 1
- [X] T011 [US1] Refactor `enterpriseDetailPage()` to assemble the primary decision-zone payload from status, outcome, artifact truth, and trust sources in `app/Filament/Resources/OperationRunResource.php`
- [X] T012 [US1] Remove or absorb redundant run-summary and equal-priority next-step duplication in `app/Filament/Resources/OperationRunResource.php`
- [X] T013 [US1] Centralize one primary count presentation and one diagnostics-only fallback in `app/Filament/Resources/OperationRunResource.php`
- [X] T014 [US1] Render execution state, outcome, artifact truth, result meaning, result trust, and one primary next step in `resources/views/filament/infolists/entries/enterprise-detail/decision-zone.blade.php`
- [X] T015 [US1] Ensure later artifact-truth rendering adds deeper explanation instead of restating the decision-zone summary in `resources/views/filament/infolists/entries/governance-artifact-truth.blade.php` and `app/Filament/Resources/OperationRunResource.php`
- [X] T016 [US1] Run the focused P1 regression pack in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`
**Checkpoint**: The canonical run detail page is now a usable decision surface for ordinary completed, partial, failed, and blocked runs.
---
## Phase 4: User Story 2 - Understand Special-State Caveats Without Confusion (Priority: P2)
**Goal**: Make stale, reconciled, blocked, and artifact-limited states visible and understandable without duplicating the same warning across multiple equal-priority regions.
**Independent Test**: Open stale, reconciled, blocked, and artifact-limited runs and verify that caveats are clear, contextual, and do not appear as competing top-level truths.
### Tests for User Story 2
- [X] T017 [P] [US2] Add stale, reconciled, blocked, and artifact-limited hierarchy assertions in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T018 [P] [US2] Add positive and negative tenant-linked related-context, diagnostic-visibility, and canonical deep-link entry regressions for special-state runs in `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
### Implementation for User Story 2
- [X] T019 [US2] Split current-state facts into semantic `guidance`, `lifecycle`, `timing`, and `metadata` supporting groups in `app/Filament/Resources/OperationRunResource.php`
- [X] T020 [US2] Keep mismatch, blocked, and lifecycle attention contextual and non-duplicative in `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- [X] T021 [US2] Update special-state banner and grouped-support copy treatment in `resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php` and `resources/views/filament/infolists/entries/enterprise-detail/supporting-card.blade.php`
- [X] T022 [US2] Run the focused P2 regression pack in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
**Checkpoint**: Exceptional lifecycle and artifact-confidence states are visible, decision-grade, and no longer over-repeated.
---
## Phase 5: User Story 3 - Keep Deep Detail Without Losing the Decision Hierarchy (Priority: P3)
**Goal**: Preserve type-specific detail and diagnostics while forcing them below the canonical decision and supporting layers.
**Independent Test**: Open baseline-compare, baseline-capture, verification-heavy, and diagnostic-heavy runs and confirm that type-specific sections remain available only after the decision zone and supporting context.
### Tests for User Story 3
- [X] T023 [P] [US3] Add type-specific ordering, diagnostics-last, and no-duplicate-main-count assertions for baseline-compare, baseline-capture, and verification-heavy runs in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`
- [X] T024 [P] [US3] Extend enterprise-detail payload and ordering assertions for `decisionZone` and `supportingGroups` in `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`
### Implementation for User Story 3
- [X] T025 [US3] Reorder failures, reconciliation, baseline compare, baseline capture, verification, and context sections after the canonical summary layers in `app/Filament/Resources/OperationRunResource.php`
- [X] T026 [US3] Keep technical and raw JSON sections progressively disclosed in `resources/views/filament/infolists/entries/enterprise-detail/technical-detail.blade.php` and `resources/views/filament/infolists/entries/snapshot-json.blade.php`
- [X] T027 [US3] Preserve type-specific coexistence and authorized related-context behavior beneath the decision zone in `resources/views/filament/infolists/entries/enterprise-detail/layout.blade.php` and `resources/views/filament/infolists/entries/related-context.blade.php`
- [X] T028 [US3] Run the focused P3 regression pack in `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`
**Checkpoint**: Type-specific detail and investigation depth remain intact, but they no longer compete with the top-level operator decision.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final consistency, formatting, and focused verification across all stories.
- [X] T029 [P] Review and align operator-facing decision-surface copy in `app/Filament/Resources/OperationRunResource.php` and `app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
- [X] T030 Run formatting for touched implementation files using `vendor/bin/sail bin pint --dirty --format agent` guided by `specs/164-run-detail-hardening/quickstart.md`
- [X] T031 Run the final focused verification pack from `specs/164-run-detail-hardening/quickstart.md`, including canonical deep-link entry checks, against `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, and `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`
---
## Phase 7: Visual Consistency & Enterprise Polish
**Purpose**: Address remaining visual and UX inconsistencies identified during browser review. Aligns with FR-164-005, FR-164-007, FR-164-009, FR-164-014, FR-164-016.
- [X] T032 Remove identical reason-card description text (`evidence_gap_bucket_help`) and replace with per-reason contextual descriptions in `lang/en/baseline-compare.php` and `resources/views/filament/infolists/entries/evidence-gap-subjects.blade.php`
- [X] T033 [P] Remove monospace font from `subject_key` column in `app/Livewire/BaselineCompareEvidenceGapTable.php` — values are human-readable labels, not code identifiers
- [X] T034 [P] Add conditional color treatment to stat-grid items (danger for failed > 0, success for errors = 0) in `resources/views/filament/infolists/entries/enterprise-detail/section-items.blade.php` and `app/Support/Ui/EnterpriseDetail/EnterpriseDetailSectionFactory.php`
- [X] T035 [P] Standardize grid column count — supporting groups always 2-col, stat grids inside decision zone always 2-col, artifact truth detail 4-col, count diagnostics 4-col — in `resources/views/filament/infolists/entries/enterprise-detail/section-items.blade.php`
- [X] T036 Add Filament `groups()` to the evidence-gap table to group rows by reason in `app/Livewire/BaselineCompareEvidenceGapTable.php`
- [X] T037 Run formatting with `vendor/bin/sail bin pint --dirty --format agent`
- [X] T038 Run focused regression pack against `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, and `tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and establishes the shared page payload contract.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until the layout can render the new page shape.
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP decision surface.
- **User Story 2 (Phase 4)**: Starts after User Story 1 because it refines the decision zone with special-state caveats.
- **User Story 3 (Phase 5)**: Starts after User Story 1; it can overlap with User Story 2 once the decision-zone contract is stable.
- **Polish (Phase 6)**: Starts after the desired user stories are complete.
- **Visual Consistency (Phase 7)**: Starts after Phase 6; addresses browser-identified visual inconsistencies.
### User Story Dependencies
- **US1**: Depends only on Setup and Foundational work.
- **US2**: Depends on the decision-zone contract from US1 and then focuses on special-state grouping and banner discipline.
- **US3**: Depends on the decision-zone contract from US1 and then focuses on ordering and coexistence of type-specific detail.
### Within Each User Story
- Tests should be updated before or alongside the relevant implementation tasks and must fail before the behavior change is considered complete.
- Resource composer changes in `app/Filament/Resources/OperationRunResource.php` should land before Blade partial cleanup for the same story.
- Focused story-level test runs should complete before moving on to the next story.
### Parallel Opportunities
- `T002`, `T003`, `T004`, and `T005` can run in parallel once the builder extension target is clear.
- `T007` and `T008` can run in parallel after the main layout order is defined.
- `T009` and `T010` can run in parallel for US1.
- `T017` and `T018` can run in parallel for US2.
- `T023` and `T024` can run in parallel for US3.
- `T029` can run in parallel with final regression execution once all code changes are finished.
---
## Parallel Example: User Story 1
```bash
# Story 1 tests in parallel:
Task: T009 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Task: T010 tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
# Story 1 implementation split after test expectations are clear:
Task: T011 app/Filament/Resources/OperationRunResource.php
Task: T014 resources/views/filament/infolists/entries/enterprise-detail/decision-zone.blade.php
```
## Parallel Example: User Story 2
```bash
# Story 2 test coverage in parallel:
Task: T017 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Task: T018 tests/Feature/Operations/TenantlessOperationRunViewerTest.php
# Story 2 implementation split after banner rules are locked:
Task: T019 app/Filament/Resources/OperationRunResource.php
Task: T021 resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php
```
## Parallel Example: User Story 3
```bash
# Story 3 regression work in parallel:
Task: T023 tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Task: T024 tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php
# Story 3 implementation split after ordering assertions are defined:
Task: T025 app/Filament/Resources/OperationRunResource.php
Task: T026 resources/views/filament/infolists/entries/enterprise-detail/technical-detail.blade.php
```
---
## Implementation Strategy
### MVP First
- Complete Phase 1 and Phase 2.
- Deliver User Story 1 as the MVP.
- Validate that the canonical page now answers the main operator questions within the first visible page height.
### Incremental Delivery
- Add User Story 2 next to harden stale, reconciled, blocked, and artifact-limited states without duplicate warnings.
- Add User Story 3 last to preserve deep diagnostic and type-specific detail beneath the new decision hierarchy.
### Verification Finish
- Run Pint on touched files.
- Run the focused regression pack from `quickstart.md`.
- If broader confidence is needed after focused verification, run the wider suite separately.

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Models\BaselineProfile;
use App\Models\InventoryItem;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\OperationRunType;
use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps;
it('classifies structural foundation capture gaps separately from missing local policy records', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 50);
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: [
'deviceConfiguration' => 'succeeded',
'roleScopeTag' => 'succeeded',
],
foundationTypes: ['roleScopeTag'],
);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-missing-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Missing Policy Subject',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'etag-policy-missing'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'scope-tag-1',
'policy_type' => 'roleScopeTag',
'display_name' => 'Structural Foundation Subject',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.roleScopeTag', 'etag' => 'etag-scope-tag'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$run = app(OperationRunService::class)->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
'truthful_types' => ['deviceConfiguration', 'roleScopeTag'],
],
'capture_mode' => BaselineCaptureMode::FullContent->value,
],
initiator: $user,
);
(new CaptureBaselineSnapshotJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
$run->refresh();
expect(data_get($run->context, 'baseline_capture.gaps.by_reason.policy_not_found'))->toBeNull()
->and(data_get($run->context, 'baseline_capture.gaps.by_reason.policy_record_missing'))->toBe(1)
->and(data_get($run->context, 'baseline_capture.gaps.by_reason.foundation_not_policy_backed'))->toBe(1);
$subjects = data_get($run->context, 'baseline_capture.gaps.subjects');
expect($subjects)->toBeArray();
AssertsStructuredBaselineGaps::assertStructuredSubjects($subjects);
$subjectsByType = collect($subjects)->keyBy('policy_type');
expect(data_get($subjectsByType['deviceConfiguration'], 'subject_class'))->toBe('policy_backed')
->and(data_get($subjectsByType['deviceConfiguration'], 'resolution_outcome'))->toBe('policy_record_missing')
->and(data_get($subjectsByType['deviceConfiguration'], 'reason_code'))->toBe('policy_record_missing')
->and(data_get($subjectsByType['deviceConfiguration'], 'structural'))->toBeFalse();
expect(data_get($subjectsByType['roleScopeTag'], 'subject_class'))->toBe('foundation_backed')
->and(data_get($subjectsByType['roleScopeTag'], 'resolution_outcome'))->toBe('foundation_inventory_only')
->and(data_get($subjectsByType['roleScopeTag'], 'reason_code'))->toBe('foundation_not_policy_backed')
->and(data_get($subjectsByType['roleScopeTag'], 'structural'))->toBeTrue();
});

View File

@ -12,6 +12,7 @@
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps;
it('treats duplicate subject_key matches as an evidence gap and suppresses findings', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -116,9 +117,15 @@
expect(data_get($context, 'baseline_compare.evidence_gaps.by_reason.ambiguous_match'))->toBe(1);
$gapSubjects = data_get($context, 'baseline_compare.evidence_gaps.subjects');
expect($gapSubjects)->toBeArray()
->and($gapSubjects)->toHaveKey('ambiguous_match')
->and($gapSubjects['ambiguous_match'])->toBeArray()
->and($gapSubjects['ambiguous_match'])->toHaveCount(1)
->and($gapSubjects['ambiguous_match'][0])->toContain('deviceConfiguration|');
expect($gapSubjects)->toBeArray();
AssertsStructuredBaselineGaps::assertStructuredSubjects($gapSubjects);
$ambiguousSubject = collect($gapSubjects)->firstWhere('reason_code', 'ambiguous_match');
expect($ambiguousSubject)->toBeArray()
->and(data_get($ambiguousSubject, 'policy_type'))->toBe('deviceConfiguration')
->and(data_get($ambiguousSubject, 'subject_class'))->toBe('policy_backed')
->and(data_get($ambiguousSubject, 'resolution_outcome'))->toBe('ambiguous_match')
->and(data_get($ambiguousSubject, 'operator_action_category'))->toBe('inspect_subject_mapping')
->and(data_get($ambiguousSubject, 'subject_key'))->toContain('duplicate policy');
});

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps;
it('classifies compare capture-path gaps as structural or missing-local-data without using policy_not_found', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 50);
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$policySubjectKey = BaselineSubjectKey::fromDisplayName('Missing Compare Policy');
$foundationSubjectKey = BaselineSubjectKey::fromDisplayName('Structural Compare Foundation');
expect($policySubjectKey)->not->toBeNull()
->and($foundationSubjectKey)->not->toBeNull();
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $policySubjectKey),
'subject_key' => (string) $policySubjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'baseline-policy'),
'meta_jsonb' => ['display_name' => 'Missing Compare Policy'],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('roleScopeTag', (string) $foundationSubjectKey),
'subject_key' => (string) $foundationSubjectKey,
'policy_type' => 'roleScopeTag',
'baseline_hash' => hash('sha256', 'baseline-foundation'),
'meta_jsonb' => ['display_name' => 'Structural Compare Foundation'],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: [
'deviceConfiguration' => 'succeeded',
'roleScopeTag' => 'succeeded',
],
foundationTypes: ['roleScopeTag'],
);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'compare-missing-policy',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Missing Compare Policy',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'etag-compare-policy'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'compare-scope-tag',
'policy_type' => 'roleScopeTag',
'display_name' => 'Structural Compare Foundation',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.roleScopeTag', 'etag' => 'etag-compare-foundation'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$run = app(OperationRunService::class)->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
'truthful_types' => ['deviceConfiguration', 'roleScopeTag'],
],
'capture_mode' => BaselineCaptureMode::FullContent->value,
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
$run->refresh();
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBeNull()
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1)
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.foundation_not_policy_backed'))->toBe(1);
$subjects = data_get($run->context, 'baseline_compare.evidence_gaps.subjects');
expect($subjects)->toBeArray();
AssertsStructuredBaselineGaps::assertStructuredSubjects($subjects);
$subjectsByType = collect($subjects)->keyBy('policy_type');
expect(data_get($subjectsByType['deviceConfiguration'], 'resolution_outcome'))->toBe('policy_record_missing')
->and(data_get($subjectsByType['deviceConfiguration'], 'reason_code'))->toBe('policy_record_missing')
->and(data_get($subjectsByType['deviceConfiguration'], 'operator_action_category'))->toBe('run_policy_sync_or_backup');
expect(data_get($subjectsByType['roleScopeTag'], 'resolution_outcome'))->toBe('foundation_inventory_only')
->and(data_get($subjectsByType['roleScopeTag'], 'reason_code'))->toBe('foundation_not_policy_backed')
->and(data_get($subjectsByType['roleScopeTag'], 'operator_action_category'))->toBe('product_follow_up');
});

View File

@ -17,6 +17,7 @@
use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunType;
use Tests\Feature\Baselines\Support\AssertsStructuredBaselineGaps;
it('records a resume token when full-content compare cannot capture all subjects within budget', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
@ -171,7 +172,7 @@ public function capture(
expect($state['offset'])->toBe(1);
});
it('stores capture-phase gap subjects for policy_not_found evidence gaps', function (): void {
it('stores capture-phase gap subjects for policy-record-missing evidence gaps', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 10);
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
@ -272,8 +273,19 @@ public function capture(
$run->refresh();
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBe(1)
->and(data_get($run->context, 'baseline_compare.evidence_gaps.subjects.policy_not_found'))->toBe([
'deviceConfiguration|missing-capture-policy',
]);
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1);
$subjects = data_get($run->context, 'baseline_compare.evidence_gaps.subjects');
expect($subjects)->toBeArray();
AssertsStructuredBaselineGaps::assertStructuredSubjects($subjects);
$missingSubject = collect($subjects)->firstWhere('reason_code', 'policy_record_missing');
expect($missingSubject)->toBeArray()
->and(data_get($missingSubject, 'policy_type'))->toBe('deviceConfiguration')
->and(data_get($missingSubject, 'subject_key'))->toBe('missing capture policy')
->and(data_get($missingSubject, 'subject_external_id'))->toBe('missing-capture-policy')
->and(data_get($missingSubject, 'resolution_outcome'))->toBe('policy_record_missing')
->and(data_get($missingSubject, 'operator_action_category'))->toBe('run_policy_sync_or_backup');
});

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
uses(RefreshDatabase::class);
it('reports legacy compare and capture runs in dry-run mode without deleting them', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$legacyCompare = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => [
'baseline_compare' => [
'evidence_gaps' => [
'count' => 1,
'by_reason' => ['policy_not_found' => 1],
'subjects' => [
'policy_not_found' => [
'deviceConfiguration|legacy-policy-gap',
],
],
],
],
],
]);
$legacyCapture = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => [
'baseline_capture' => [
'gaps' => [
'count' => 1,
'by_reason' => ['policy_not_found' => 1],
'subjects' => [
'policy_not_found' => [
'deviceConfiguration|legacy-capture-gap',
],
],
],
],
],
]);
$structuredCompare = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap(),
]),
]);
expect($legacyCompare->hasLegacyBaselineGapPayload())->toBeTrue()
->and($legacyCapture->hasLegacyBaselineGapPayload())->toBeTrue()
->and($structuredCompare->hasStructuredBaselineGapPayload())->toBeTrue()
->and($structuredCompare->hasLegacyBaselineGapPayload())->toBeFalse();
$this->artisan('tenantpilot:baselines:purge-legacy-gap-runs')
->expectsOutputToContain('Dry run: matched 2 legacy baseline run(s). Re-run with --force to delete them.')
->assertSuccessful();
expect(OperationRun::query()->whereKey($legacyCompare->getKey())->exists())->toBeTrue()
->and(OperationRun::query()->whereKey($legacyCapture->getKey())->exists())->toBeTrue()
->and(OperationRun::query()->whereKey($structuredCompare->getKey())->exists())->toBeTrue();
});
it('deletes only legacy baseline gap runs when forced', function (): void {
[, $tenant] = createUserWithTenant(role: 'owner');
$legacyCompare = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => [
'baseline_compare' => [
'evidence_gaps' => [
'count' => 1,
'by_reason' => ['policy_not_found' => 1],
'subjects' => [
'policy_not_found' => [
'deviceConfiguration|legacy-policy-gap',
],
],
],
],
],
]);
$structuredCompare = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap(),
]),
]);
$structuredCapture = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => BaselineSubjectResolutionFixtures::captureContext([
BaselineSubjectResolutionFixtures::structuredGap(),
]),
]);
$this->artisan('tenantpilot:baselines:purge-legacy-gap-runs', ['--force' => true])
->expectsOutputToContain('Deleted 1 legacy baseline run(s).')
->assertSuccessful();
expect(OperationRun::query()->whereKey($legacyCompare->getKey())->exists())->toBeFalse()
->and(OperationRun::query()->whereKey($structuredCompare->getKey())->exists())->toBeTrue()
->and(OperationRun::query()->whereKey($structuredCapture->getKey())->exists())->toBeTrue();
});

View File

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
it('persists the same structured gap subjects for unchanged capture and compare inputs', function (): void {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
config()->set('tenantpilot.baselines.full_content_capture.max_items_per_run', 50);
config()->set('tenantpilot.baselines.full_content_capture.max_concurrency', 1);
config()->set('tenantpilot.baselines.full_content_capture.max_retries', 0);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::FullContent->value,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
'captured_at' => now()->subMinute(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
$policySubjectKey = BaselineSubjectKey::fromDisplayName('Deterministic Missing Policy');
$foundationSubjectKey = BaselineSubjectKey::fromDisplayName('Deterministic Foundation');
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $policySubjectKey),
'subject_key' => (string) $policySubjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => hash('sha256', 'deterministic-policy'),
'meta_jsonb' => ['display_name' => 'Deterministic Missing Policy'],
]);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => BaselineSubjectKey::workspaceSafeSubjectExternalId('roleScopeTag', (string) $foundationSubjectKey),
'subject_key' => (string) $foundationSubjectKey,
'policy_type' => 'roleScopeTag',
'baseline_hash' => hash('sha256', 'deterministic-foundation'),
'meta_jsonb' => ['display_name' => 'Deterministic Foundation'],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: [
'deviceConfiguration' => 'succeeded',
'roleScopeTag' => 'succeeded',
],
foundationTypes: ['roleScopeTag'],
);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'deterministic-policy',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Deterministic Missing Policy',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'etag-deterministic-policy'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'deterministic-foundation',
'policy_type' => 'roleScopeTag',
'display_name' => 'Deterministic Foundation',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.roleScopeTag', 'etag' => 'etag-deterministic-foundation'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$captureRunA = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
'truthful_types' => ['deviceConfiguration', 'roleScopeTag'],
],
'capture_mode' => BaselineCaptureMode::FullContent->value,
],
'user_id' => (int) $user->getKey(),
]);
$captureRunB = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => $captureRunA->context,
'user_id' => (int) $user->getKey(),
]);
(new CaptureBaselineSnapshotJob($captureRunA))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
(new CaptureBaselineSnapshotJob($captureRunB))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
$compareRunA = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag'],
'truthful_types' => ['deviceConfiguration', 'roleScopeTag'],
],
'capture_mode' => BaselineCaptureMode::FullContent->value,
],
'user_id' => (int) $user->getKey(),
]);
$compareRunB = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => $compareRunA->context,
'user_id' => (int) $user->getKey(),
]);
(new CompareBaselineToTenantJob($compareRunA))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
(new CompareBaselineToTenantJob($compareRunB))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
app(OperationRunService::class),
);
$captureSubjectsA = collect(data_get($captureRunA->fresh()->context, 'baseline_capture.gaps.subjects', []))->sortBy('policy_type')->values()->all();
$captureSubjectsB = collect(data_get($captureRunB->fresh()->context, 'baseline_capture.gaps.subjects', []))->sortBy('policy_type')->values()->all();
$compareSubjectsA = collect(data_get($compareRunA->fresh()->context, 'baseline_compare.evidence_gaps.subjects', []))->sortBy('policy_type')->values()->all();
$compareSubjectsB = collect(data_get($compareRunB->fresh()->context, 'baseline_compare.evidence_gaps.subjects', []))->sortBy('policy_type')->values()->all();
expect($captureSubjectsA)->toBe($captureSubjectsB)
->and($compareSubjectsA)->toBe($compareSubjectsB);
});

View File

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
use App\Jobs\CaptureBaselineSnapshotJob;
use App\Jobs\CompareBaselineToTenantJob;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineCaptureMode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
uses(RefreshDatabase::class);
function appendBrokenFoundationSupportConfig(): void
{
$foundationTypes = is_array(config('tenantpilot.foundation_types')) ? config('tenantpilot.foundation_types') : [];
$foundationTypes[] = [
'type' => 'brokenFoundation',
'label' => 'Broken Foundation',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'external_id',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'inventory',
],
],
];
config()->set('tenantpilot.foundation_types', $foundationTypes);
}
it('persists truthful compare scope capability decisions before dispatching compare work', function (): void {
Bus::fake();
appendBrokenFoundationSupportConfig();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag', 'brokenFoundation', 'unknownFoundation'],
],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
$result = app(BaselineCompareService::class)->startCompare($tenant, $user);
expect($result['ok'])->toBeTrue();
$run = $result['run'];
$scope = data_get($run->context, 'effective_scope');
expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag'])
->and(data_get($scope, 'limited_types'))->toBe(['roleScopeTag'])
->and(data_get($scope, 'all_types'))->toBe(['brokenFoundation', 'deviceConfiguration', 'roleScopeTag'])
->and(data_get($scope, 'unsupported_types'))->toBe(['brokenFoundation'])
->and(data_get($scope, 'invalid_support_types'))->toBe(['brokenFoundation'])
->and(data_get($scope, 'capabilities.deviceConfiguration.support_mode'))->toBe('supported')
->and(data_get($scope, 'capabilities.roleScopeTag.support_mode'))->toBe('limited')
->and(data_get($scope, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config')
->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull();
Bus::assertDispatched(CompareBaselineToTenantJob::class);
});
it('persists the same truthful scope capability decisions before dispatching capture work', function (): void {
Bus::fake();
appendBrokenFoundationSupportConfig();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
'scope_jsonb' => [
'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['roleScopeTag', 'brokenFoundation', 'unknownFoundation'],
],
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeTrue();
$run = $result['run'];
$scope = data_get($run->context, 'effective_scope');
expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag'])
->and(data_get($scope, 'limited_types'))->toBe(['roleScopeTag'])
->and(data_get($scope, 'all_types'))->toBe(['brokenFoundation', 'deviceConfiguration', 'roleScopeTag'])
->and(data_get($scope, 'unsupported_types'))->toBe(['brokenFoundation'])
->and(data_get($scope, 'invalid_support_types'))->toBe(['brokenFoundation'])
->and(data_get($scope, 'capabilities.deviceConfiguration.support_mode'))->toBe('supported')
->and(data_get($scope, 'capabilities.roleScopeTag.support_mode'))->toBe('limited')
->and(data_get($scope, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config')
->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull();
Bus::assertDispatched(CaptureBaselineSnapshotJob::class);
});

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Baselines\Support;
use PHPUnit\Framework\Assert;
final class AssertsStructuredBaselineGaps
{
/**
* @param array<string, mixed> $subject
*/
public static function assertStructuredSubject(array $subject): void
{
foreach ([
'policy_type',
'subject_key',
'subject_class',
'resolution_path',
'resolution_outcome',
'reason_code',
'operator_action_category',
'structural',
'retryable',
] as $key) {
Assert::assertArrayHasKey($key, $subject);
}
}
/**
* @param list<array<string, mixed>> $subjects
*/
public static function assertStructuredSubjects(array $subjects): void
{
Assert::assertNotEmpty($subjects);
foreach ($subjects as $subject) {
Assert::assertIsArray($subject);
self::assertStructuredSubject($subject);
}
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Baselines\Support;
use App\Support\Baselines\OperatorActionCategory;
use App\Support\Baselines\ResolutionOutcome;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
final class BaselineSubjectResolutionFixtures
{
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
public static function structuredGap(array $overrides = []): array
{
return array_replace([
'policy_type' => 'deviceConfiguration',
'subject_external_id' => 'subject-1',
'subject_key' => 'deviceconfiguration|subject-1',
'subject_class' => SubjectClass::PolicyBacked->value,
'resolution_path' => ResolutionPath::Policy->value,
'resolution_outcome' => ResolutionOutcome::PolicyRecordMissing->value,
'reason_code' => 'policy_record_missing',
'operator_action_category' => OperatorActionCategory::RunPolicySyncOrBackup->value,
'structural' => false,
'retryable' => false,
'source_model_expected' => 'policy',
'source_model_found' => null,
], $overrides);
}
/**
* @param list<array<string, mixed>> $subjects
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
public static function compareContext(array $subjects, array $overrides = []): array
{
$byReason = [];
foreach ($subjects as $subject) {
$reasonCode = is_string($subject['reason_code'] ?? null) ? $subject['reason_code'] : 'unknown';
$byReason[$reasonCode] = ($byReason[$reasonCode] ?? 0) + 1;
}
return array_replace_recursive([
'baseline_compare' => [
'evidence_gaps' => [
'count' => count($subjects),
'by_reason' => $byReason,
'subjects' => $subjects,
],
],
], $overrides);
}
/**
* @param list<array<string, mixed>> $subjects
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
public static function captureContext(array $subjects, array $overrides = []): array
{
$byReason = [];
foreach ($subjects as $subject) {
$reasonCode = is_string($subject['reason_code'] ?? null) ? $subject['reason_code'] : 'unknown';
$byReason[$reasonCode] = ($byReason[$reasonCode] ?? 0) + 1;
}
return array_replace_recursive([
'baseline_capture' => [
'gaps' => [
'count' => count($subjects),
'by_reason' => $byReason,
'subjects' => $subjects,
],
],
], $overrides);
}
}

View File

@ -10,6 +10,7 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
uses(RefreshDatabase::class);
@ -23,28 +24,49 @@ function baselineCompareEvidenceGapTable(Testable $component): Table
*/
function baselineCompareEvidenceGapBuckets(): array
{
return BaselineCompareEvidenceGapDetails::fromContext([
'baseline_compare' => [
'evidence_gaps' => [
'count' => 5,
'by_reason' => [
'ambiguous_match' => 3,
'policy_not_found' => 2,
],
'subjects' => [
'ambiguous_match' => [
'deviceConfiguration|WiFi-Corp-Profile',
'deviceConfiguration|VPN-Always-On',
'deviceCompliancePolicy|Windows-Encryption-Required',
],
'policy_not_found' => [
'deviceConfiguration|Deleted-Policy-ABC',
'deviceCompliancePolicy|Retired-Compliance-Policy',
],
],
],
],
])['buckets'];
return BaselineCompareEvidenceGapDetails::fromContext(BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'WiFi-Corp-Profile',
'subject_class' => 'policy_backed',
'resolution_path' => 'policy',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'VPN-Always-On',
'subject_class' => 'policy_backed',
'resolution_path' => 'policy',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'Windows-Encryption-Required',
'subject_class' => 'policy_backed',
'resolution_path' => 'policy',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'Deleted-Policy-ABC',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceCompliancePolicy',
'subject_key' => 'Retired-Compliance-Policy',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
]))['buckets'];
}
it('uses a Filament table for evidence-gap rows with searchable visible columns', function (): void {
@ -59,6 +81,9 @@ function baselineCompareEvidenceGapBuckets(): array
expect($table->getDefaultSortColumn())->toBe('reason_label');
expect($table->getColumn('reason_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('policy_type')?->isSearchable())->toBeTrue();
expect($table->getColumn('subject_class_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('resolution_outcome_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('operator_action_category_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('subject_key')?->isSearchable())->toBeTrue();
$component
@ -66,7 +91,13 @@ function baselineCompareEvidenceGapBuckets(): array
->assertSee('Deleted-Policy-ABC')
->assertSee('Reason')
->assertSee('Policy type')
->assertSee('Subject class')
->assertSee('Outcome')
->assertSee('Next action')
->assertSee('Subject key')
->assertSee('Policy-backed')
->assertSee('Policy record missing')
->assertSee('Run policy sync or backup')
->assertSee(TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceConfiguration')->label)
->assertSee(TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceCompliancePolicy')->label);
});
@ -84,12 +115,16 @@ function baselineCompareEvidenceGapBuckets(): array
'buckets' => baselineCompareEvidenceGapBuckets(),
'context' => 'tenant-landing-filters',
])
->filterTable('reason_code', 'policy_not_found')
->filterTable('reason_code', 'policy_record_missing')
->assertSee('Retired-Compliance-Policy')
->assertDontSee('VPN-Always-On')
->filterTable('policy_type', 'deviceCompliancePolicy')
->assertSee('Retired-Compliance-Policy')
->assertDontSee('Deleted-Policy-ABC');
->assertDontSee('Deleted-Policy-ABC')
->filterTable('operator_action_category', 'run_policy_sync_or_backup')
->assertSee('Run policy sync or backup')
->filterTable('subject_class', 'policy_backed')
->assertSee('Policy-backed');
});
it('shows an explicit empty state when only missing-detail buckets exist', function (): void {
@ -98,7 +133,7 @@ function baselineCompareEvidenceGapBuckets(): array
'evidence_gaps' => [
'count' => 2,
'by_reason' => [
'policy_not_found' => 2,
'policy_record_missing' => 2,
],
'subjects' => [],
],

View File

@ -14,6 +14,7 @@
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
uses(RefreshDatabase::class);
@ -22,7 +23,29 @@
*/
function baselineCompareLandingGapContext(): array
{
return [
return array_replace_recursive(BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'WiFi-Corp-Profile',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'VPN-Always-On',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'Deleted-Policy-ABC',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
]), [
'baseline_compare' => [
'subjects_total' => 50,
'reason_code' => 'evidence_capture_incomplete',
@ -40,26 +63,8 @@ function baselineCompareLandingGapContext(): array
'failed' => 3,
'throttled' => 0,
],
'evidence_gaps' => [
'count' => 5,
'by_reason' => [
'ambiguous_match' => 3,
'policy_not_found' => 2,
],
'ambiguous_match' => 3,
'policy_not_found' => 2,
'subjects' => [
'ambiguous_match' => [
'deviceConfiguration|WiFi-Corp-Profile',
'deviceConfiguration|VPN-Always-On',
],
'policy_not_found' => [
'deviceConfiguration|Deleted-Policy-ABC',
],
],
],
],
];
]);
}
function seedBaselineCompareLandingGapRun(\App\Models\Tenant $tenant): OperationRun
@ -120,11 +125,15 @@ function seedBaselineCompareLandingGapRun(\App\Models\Tenant $tenant): Operation
Livewire::test(BaselineCompareLanding::class)
->assertSee('Evidence gap details')
->assertSee('Search gap details')
->assertSee('Search by reason, policy type, or subject key')
->assertSee('Search by reason, type, class, outcome, action, or subject key')
->assertSee('Reason')
->assertSee('Ambiguous inventory match')
->assertSee('Policy not found')
->assertSee('Policy record missing')
->assertSee('Subject class')
->assertSee('Outcome')
->assertSee('Next action')
->assertSee('WiFi-Corp-Profile')
->assertSee('Inspect subject mapping')
->assertSee('Baseline compare evidence');
});

View File

@ -61,8 +61,9 @@
Livewire::test(BaselineCompareLanding::class)
->assertSee(__('baseline-compare.duplicate_warning_title'))
->assertSee('share the same display name')
->assertSee('cannot match them to the baseline');
->assertSee('share generic display names')
->assertSee('resulting in 1 ambiguous subject')
->assertSee('cannot match them safely to the baseline');
});
it('does not show the duplicate-name warning for stale rows outside the latest inventory sync', function (): void {
@ -140,6 +141,6 @@
Livewire::test(BaselineCompareLanding::class)
->assertDontSee(__('baseline-compare.duplicate_warning_title'))
->assertDontSee('share the same display name')
->assertDontSee('cannot match them to the baseline');
->assertDontSee('share generic display names')
->assertDontSee('cannot match them safely to the baseline');
});

View File

@ -109,7 +109,7 @@
'evidence_gaps' => [
'count' => 2,
'by_reason' => [
'policy_not_found' => 2,
'policy_record_missing' => 2,
],
],
],

View File

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\BaselineCompareLanding;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
uses(RefreshDatabase::class);
/**
* @return array<string, mixed>
*/
function structuredGapSurfaceContext(): array
{
return array_replace_recursive(BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'WiFi-Corp-Profile',
'subject_class' => 'policy_backed',
'resolution_path' => 'policy',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'roleScopeTag',
'subject_key' => 'scope-tag-finance',
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_inventory',
'resolution_outcome' => 'foundation_inventory_only',
'reason_code' => 'foundation_not_policy_backed',
'operator_action_category' => 'product_follow_up',
'structural' => true,
]),
]), [
'baseline_compare' => [
'reason_code' => 'evidence_capture_incomplete',
'coverage' => [
'proof' => true,
'covered_types' => ['deviceConfiguration', 'roleScopeTag'],
'uncovered_types' => [],
'effective_types' => ['deviceConfiguration', 'roleScopeTag'],
],
'fidelity' => 'meta',
],
]);
}
it('renders canonical run detail gap semantics from persisted db context only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
bindFailHardGraphClient();
Filament::setTenant(null, true);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => structuredGapSurfaceContext(),
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Evidence gap details')
->assertSee('Policy-backed')
->assertSee('Foundation-backed')
->assertSee('Policy record missing')
->assertSee('Foundation not policy-backed')
->assertSee('Run policy sync or backup')
->assertSee('Product follow-up')
->assertSee('WiFi-Corp-Profile')
->assertSee('scope-tag-finance');
});
it('renders tenant landing gap semantics from persisted db context only', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
bindFailHardGraphClient();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'context' => structuredGapSurfaceContext(),
'completed_at' => now(),
]);
Livewire::test(BaselineCompareLanding::class)
->assertSee('Evidence gap details')
->assertSee('Subject class')
->assertSee('Outcome')
->assertSee('Next action')
->assertSee('Foundation-backed')
->assertSee('Foundation not policy-backed')
->assertSee('Product follow-up')
->assertSee('scope-tag-finance');
});

View File

@ -91,5 +91,5 @@
$this->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSeeInOrder(['Policy sync', 'Run summary', 'Context']);
->assertSeeInOrder(['Policy sync', 'Decision', 'Count diagnostics', 'Context']);
});

View File

@ -10,8 +10,20 @@
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
function visibleLivewireText(Testable $component): string
{
$html = $component->html();
$html = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html) ?? $html;
$html = preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $html) ?? $html;
$html = preg_replace('/\s+wire:snapshot="[^"]*"/', '', $html) ?? $html;
$html = preg_replace('/\s+wire:effects="[^"]*"/', '', $html) ?? $html;
return trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
}
it('shows run outcome and baseline artifact truth as separate facts on the run detail page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -52,8 +64,9 @@
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
$component = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Decision')
->assertSee('Outcome')
->assertSee('Artifact truth')
->assertSee('Execution failed')
@ -61,8 +74,16 @@
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee('Artifact not usable')
->assertSee('Artifact next step')
->assertSee('Inspect the related capture diagnostics before using this snapshot');
->assertSee('Primary next step')
->assertSee('Artifact truth details')
->assertSee('Inspect the related capture diagnostics before using this snapshot')
->assertDontSee('Artifact next step');
$pageText = visibleLivewireText($component);
expect(mb_substr_count($pageText, 'Primary next step'))->toBe(1)
->and(mb_substr_count($pageText, 'Inspect the related capture diagnostics before using this snapshot'))->toBe(1)
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});
it('shows operator explanation facts for baseline compare runs with nested compare reason context', function (): void {
@ -100,16 +121,59 @@
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
$component = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Decision')
->assertSee('Artifact truth')
->assertSee('Result meaning')
->assertSee('Result trust')
->assertSee('Artifact next step')
->assertSee('Primary next step')
->assertSee('Artifact truth details')
->assertSee($explanation?->headline ?? '')
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee($explanation?->nextActionText ?? '')
->assertSee('The run completed, but normal output was intentionally suppressed.')
->assertSee('Resume or rerun evidence capture before relying on this compare result.');
->assertSee('Resume or rerun evidence capture before relying on this compare result.')
->assertDontSee('Artifact next step');
$pageText = visibleLivewireText($component);
expect(mb_substr_count($pageText, 'Primary next step'))->toBe(1)
->and(mb_substr_count($pageText, 'Resume or rerun evidence capture before relying on this compare result.'))->toBe(1)
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});
it('deduplicates repeated artifact truth explanation text for follow-up runs without a usable artifact', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'summary_counts' => [
'total' => 50,
'processed' => 47,
'failed' => 3,
],
'context' => [],
'completed_at' => now(),
]);
Filament::setTenant(null, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
$component = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Decision')
->assertSee('Artifact truth details')
->assertSee('The run finished without a usable artifact result.');
$pageText = visibleLivewireText($component);
expect(mb_substr_count($pageText, 'The run finished without a usable artifact result.'))->toBe(1)
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});

View File

@ -13,6 +13,7 @@
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Testing\TestResponse;
use Tests\Feature\Baselines\Support\BaselineSubjectResolutionFixtures;
function visiblePageText(TestResponse $response): string
{
@ -31,7 +32,43 @@ function visiblePageText(TestResponse $response): string
*/
function baselineCompareGapContext(array $overrides = []): array
{
return array_replace_recursive([
return array_replace_recursive(BaselineSubjectResolutionFixtures::compareContext([
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'WiFi-Corp-Profile',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'VPN-Always-On',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'Email-Exchange-Config',
'resolution_outcome' => 'ambiguous_match',
'reason_code' => 'ambiguous_match',
'operator_action_category' => 'inspect_subject_mapping',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'Deleted-Policy-ABC',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
BaselineSubjectResolutionFixtures::structuredGap([
'policy_type' => 'deviceConfiguration',
'subject_key' => 'Removed-Config-XYZ',
'resolution_outcome' => 'policy_record_missing',
'reason_code' => 'policy_record_missing',
'operator_action_category' => 'run_policy_sync_or_backup',
]),
]), [
'baseline_compare' => [
'subjects_total' => 50,
'reason_code' => 'evidence_capture_incomplete',
@ -49,31 +86,11 @@ function baselineCompareGapContext(array $overrides = []): array
'failed' => 3,
'throttled' => 0,
],
'evidence_gaps' => [
'count' => 5,
'by_reason' => [
'ambiguous_match' => 3,
'policy_not_found' => 2,
],
'ambiguous_match' => 3,
'policy_not_found' => 2,
'subjects' => [
'ambiguous_match' => [
'deviceConfiguration|WiFi-Corp-Profile',
'deviceConfiguration|VPN-Always-On',
'deviceConfiguration|Email-Exchange-Config',
],
'policy_not_found' => [
'deviceConfiguration|Deleted-Policy-ABC',
'deviceConfiguration|Removed-Config-XYZ',
],
],
],
],
], $overrides);
}
it('renders operation runs with summary content before counts and technical context', function (): void {
it('renders decision-first hierarchy before main sections and technical diagnostics', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
@ -102,31 +119,42 @@ function baselineCompareGapContext(array $overrides = []): array
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Current state')
->assertSee('Decision')
->assertSee('Timing')
->assertSee('Metadata')
->assertSee('Count diagnostics')
->assertSee('Contoso');
$pageText = visiblePageText($response);
$policySyncPosition = mb_strpos($pageText, 'Policy sync');
$runSummaryPosition = mb_strpos($pageText, 'Run summary');
$decisionPosition = mb_strpos($pageText, 'Decision');
$timingPosition = mb_strpos($pageText, 'Timing');
$metadataPosition = mb_strpos($pageText, 'Metadata');
$relatedContextPosition = mb_strpos($pageText, 'Related context');
$countsPosition = mb_strpos($pageText, 'Counts');
$countDiagnosticsPosition = mb_strpos($pageText, 'Count diagnostics');
$identityHashPosition = mb_strpos($pageText, 'Identity hash');
expect($policySyncPosition)->not->toBeFalse()
->and($runSummaryPosition)->not->toBeFalse()
->and($decisionPosition)->not->toBeFalse()
->and($timingPosition)->not->toBeFalse()
->and($metadataPosition)->not->toBeFalse()
->and($relatedContextPosition)->not->toBeFalse()
->and($countsPosition)->not->toBeFalse()
->and($countDiagnosticsPosition)->not->toBeFalse()
->and($identityHashPosition)->not->toBeFalse()
->and($policySyncPosition)->toBeLessThan($runSummaryPosition)
->and($runSummaryPosition)->toBeLessThan($relatedContextPosition)
->and($relatedContextPosition)->toBeLessThan($countsPosition)
->and($countsPosition)->toBeLessThan($identityHashPosition);
->and($policySyncPosition)->toBeLessThan($decisionPosition)
->and($decisionPosition)->toBeLessThan($timingPosition)
->and($timingPosition)->toBeLessThan($metadataPosition)
->and($metadataPosition)->toBeLessThan($relatedContextPosition)
->and($relatedContextPosition)->toBeLessThan($countDiagnosticsPosition)
->and($countDiagnosticsPosition)->toBeLessThan($identityHashPosition);
expect((string) $response->getContent())
->toMatch('/fi-section-header-heading[^>]*>\s*Current state\s*</')
->toMatch('/fi-section-header-heading[^>]*>\s*Decision\s*</')
->toMatch('/fi-section-header-heading[^>]*>\s*Timing\s*</');
$response->assertDontSee('Current state')
->assertDontSee('Run summary');
});
it('keeps header navigation and related context visible for tenant-bound operation runs', function (): void {
@ -191,17 +219,17 @@ function baselineCompareGapContext(array $overrides = []): array
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Current tenant context differs from this run')
->assertSee('Run summary')
->assertSee('Decision')
->assertSee('Related context');
$pageText = visiblePageText($response);
$bannerPosition = mb_strpos($pageText, 'Current tenant context differs from this run');
$summaryPosition = mb_strpos($pageText, 'Run summary');
$decisionPosition = mb_strpos($pageText, 'Decision');
expect($bannerPosition)->not->toBeFalse()
->and($summaryPosition)->not->toBeFalse()
->and($bannerPosition)->toBeLessThan($summaryPosition);
->and($decisionPosition)->not->toBeFalse()
->and($bannerPosition)->toBeLessThan($decisionPosition);
});
it('renders explicit sparse-data fallbacks for operation runs', function (): void {
@ -285,6 +313,11 @@ function baselineCompareGapContext(array $overrides = []): array
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'summary_counts' => [
'total' => 50,
'processed' => 47,
'failed' => 3,
],
'context' => baselineCompareGapContext(),
'completed_at' => now(),
]);
@ -293,18 +326,42 @@ function baselineCompareGapContext(array $overrides = []): array
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Decision')
->assertSee('Primary next step')
->assertSee('Count diagnostics')
->assertSee('Evidence gap details')
->assertSee('Search gap details')
->assertSee('Search by reason, policy type, or subject key')
->assertSee('Search by reason, type, class, outcome, action, or subject key')
->assertSee('Reason')
->assertSee('Ambiguous inventory match')
->assertSee('Policy not found')
->assertSee('Policy record missing')
->assertSee('3 affected')
->assertSee('2 affected')
->assertSee('WiFi-Corp-Profile')
->assertSee('Deleted-Policy-ABC')
->assertSee('Policy type')
->assertSee('Subject class')
->assertSee('Outcome')
->assertSee('Next action')
->assertSee('Subject key');
$pageText = visiblePageText($response);
$decisionPosition = mb_strpos($pageText, 'Decision');
$timingPosition = mb_strpos($pageText, 'Timing');
$searchGapDetailsPosition = mb_strpos($pageText, 'Search gap details');
$gapDetailsPosition = mb_strpos($pageText, 'Evidence gap details');
$countDiagnosticsPosition = mb_strpos($pageText, 'Count diagnostics');
expect($decisionPosition)->not->toBeFalse()
->and($timingPosition)->not->toBeFalse()
->and($searchGapDetailsPosition)->not->toBeFalse()
->and($gapDetailsPosition)->not->toBeFalse()
->and($countDiagnosticsPosition)->not->toBeFalse()
->and($decisionPosition)->toBeLessThan($timingPosition)
->and($timingPosition)->toBeLessThan($gapDetailsPosition)
->and($gapDetailsPosition)->toBeLessThan($searchGapDetailsPosition)
->and($gapDetailsPosition)->toBeLessThan($countDiagnosticsPosition);
});
it('renders baseline compare evidence-gap details without invoking graph during canonical run detail render', function (): void {

View File

@ -255,6 +255,17 @@
->assertSee('Automatically reconciled')
->assertSee('Infrastructure ended the run')
->assertSee('Review worker health and logs before retrying this operation.');
$response = $this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertSuccessful();
$pageText = trim((string) preg_replace('/\s+/', ' ', strip_tags((string) $response->getContent())));
expect(mb_substr_count($pageText, 'Automatically reconciled'))->toBe(1);
});
it('keeps a canonical run viewer accessible when the remembered tenant differs from the run tenant', function (): void {

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\Baselines\OperatorActionCategory;
use App\Support\Baselines\ResolutionOutcome;
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
use App\Support\Baselines\SubjectResolver;
it('derives truthful runtime capability and descriptors for supported policy and foundation types', function (): void {
$resolver = app(SubjectResolver::class);
$policyDescriptor = $resolver->describeForCompare('deviceConfiguration', 'policy-1', 'deviceconfiguration|policy-1');
$foundationDescriptor = $resolver->describeForCapture('roleScopeTag', 'scope-tag-1', 'rolescopetag|baseline');
$rbacDescriptor = $resolver->describeForCompare('intuneRoleDefinition', 'role-def-1', 'rbac-role');
expect($policyDescriptor->subjectClass)->toBe(SubjectClass::PolicyBacked)
->and($policyDescriptor->resolutionPath)->toBe(ResolutionPath::Policy)
->and($policyDescriptor->supportMode)->toBe('supported')
->and($policyDescriptor->sourceModelExpected)->toBe('policy');
expect($foundationDescriptor->subjectClass)->toBe(SubjectClass::FoundationBacked)
->and($foundationDescriptor->resolutionPath)->toBe(ResolutionPath::FoundationInventory)
->and($foundationDescriptor->supportMode)->toBe('limited')
->and($foundationDescriptor->sourceModelExpected)->toBe('inventory');
expect($rbacDescriptor->subjectClass)->toBe(SubjectClass::FoundationBacked)
->and($rbacDescriptor->resolutionPath)->toBe(ResolutionPath::FoundationPolicy)
->and($rbacDescriptor->supportMode)->toBe('supported')
->and($rbacDescriptor->sourceModelExpected)->toBe('policy');
});
it('maps structural and operational outcomes without flattening them into policy_not_found', function (): void {
$resolver = app(SubjectResolver::class);
$foundationDescriptor = $resolver->describeForCapture('notificationMessageTemplate', 'template-1', 'template-subject');
$policyDescriptor = $resolver->describeForCompare('deviceConfiguration', 'policy-1', 'policy-subject');
$structuralOutcome = $resolver->structuralInventoryOnly($foundationDescriptor);
$missingPolicyOutcome = $resolver->missingExpectedRecord($policyDescriptor);
$throttledOutcome = $resolver->throttled($policyDescriptor);
expect($structuralOutcome->resolutionOutcome)->toBe(ResolutionOutcome::FoundationInventoryOnly)
->and($structuralOutcome->reasonCode)->toBe('foundation_not_policy_backed')
->and($structuralOutcome->operatorActionCategory)->toBe(OperatorActionCategory::ProductFollowUp)
->and($structuralOutcome->structural)->toBeTrue();
expect($missingPolicyOutcome->resolutionOutcome)->toBe(ResolutionOutcome::PolicyRecordMissing)
->and($missingPolicyOutcome->reasonCode)->toBe('policy_record_missing')
->and($missingPolicyOutcome->operatorActionCategory)->toBe(OperatorActionCategory::RunPolicySyncOrBackup)
->and($missingPolicyOutcome->structural)->toBeFalse();
expect($throttledOutcome->resolutionOutcome)->toBe(ResolutionOutcome::Throttled)
->and($throttledOutcome->retryable)->toBeTrue()
->and($throttledOutcome->operatorActionCategory)->toBe(OperatorActionCategory::Retry);
});
it('guards unsupported or invalid support declarations before runtime work starts', function (): void {
$guard = app(BaselineSupportCapabilityGuard::class);
config()->set('tenantpilot.foundation_types', [
[
'type' => 'intuneRoleAssignment',
'label' => 'Intune RBAC Role Assignment',
'baseline_compare' => [
'supported' => false,
'identity_strategy' => 'external_id',
],
],
[
'type' => 'brokenFoundation',
'label' => 'Broken Foundation',
'baseline_compare' => [
'supported' => true,
'resolution' => [
'subject_class' => SubjectClass::FoundationBacked->value,
'resolution_path' => 'broken',
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'inventory',
],
],
],
]);
$result = $guard->guardTypes(['intuneRoleAssignment', 'brokenFoundation'], 'compare');
expect($result['allowed_types'])->toBe([])
->and($result['unsupported_types'])->toBe(['brokenFoundation', 'intuneRoleAssignment'])
->and($result['invalid_support_types'])->toBe(['brokenFoundation'])
->and(data_get($result, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config')
->and(data_get($result, 'capabilities.intuneRoleAssignment.support_mode'))->toBe('excluded');
});

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use App\Support\Inventory\InventoryPolicyTypeMeta;
it('keeps display-name foundation support truthful as limited inventory-backed capability', function (): void {
$contract = InventoryPolicyTypeMeta::baselineSupportContract('roleScopeTag');
expect($contract['config_supported'])->toBeTrue()
->and($contract['runtime_valid'])->toBeTrue()
->and($contract['subject_class'])->toBe('foundation_backed')
->and($contract['resolution_path'])->toBe('foundation_inventory')
->and($contract['compare_capability'])->toBe('limited')
->and($contract['capture_capability'])->toBe('limited')
->and($contract['source_model_expected'])->toBe('inventory');
});
it('treats unknown baseline types as derived and excluded from support promises', function (): void {
$contract = InventoryPolicyTypeMeta::baselineSupportContract('unknownFoundation');
expect($contract['config_supported'])->toBeFalse()
->and($contract['runtime_valid'])->toBeTrue()
->and($contract['subject_class'])->toBe('derived')
->and($contract['resolution_path'])->toBe('derived')
->and($contract['compare_capability'])->toBe('unsupported')
->and($contract['capture_capability'])->toBe('unsupported')
->and($contract['source_model_expected'])->toBe('derived');
});
it('downgrades malformed baseline support declarations before they can overpromise runtime capability', function (): void {
$foundationTypes = is_array(config('tenantpilot.foundation_types')) ? config('tenantpilot.foundation_types') : [];
$foundationTypes[] = [
'type' => 'brokenFoundation',
'label' => 'Broken Foundation',
'baseline_compare' => [
'supported' => true,
'identity_strategy' => 'external_id',
'resolution' => [
'subject_class' => 'foundation_backed',
'resolution_path' => 'foundation_policy',
'compare_capability' => 'supported',
'capture_capability' => 'supported',
'source_model_expected' => 'inventory',
],
],
];
config()->set('tenantpilot.foundation_types', $foundationTypes);
$contract = InventoryPolicyTypeMeta::baselineSupportContract('brokenFoundation');
expect($contract['config_supported'])->toBeTrue()
->and($contract['runtime_valid'])->toBeFalse()
->and($contract['subject_class'])->toBe('foundation_backed')
->and($contract['resolution_path'])->toBe('foundation_policy')
->and($contract['compare_capability'])->toBe('unsupported')
->and($contract['capture_capability'])->toBe('unsupported')
->and($contract['source_model_expected'])->toBe('inventory');
});

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use App\Support\Baselines\ResolutionPath;
use App\Support\Baselines\SubjectClass;
use App\Support\Inventory\InventoryPolicyTypeMeta;
it('derives baseline subject resolution defaults for supported policy types', function (): void {
$contract = InventoryPolicyTypeMeta::baselineSupportContract('deviceConfiguration');
expect($contract['config_supported'])->toBeTrue()
->and($contract['runtime_valid'])->toBeTrue()
->and($contract['subject_class'])->toBe(SubjectClass::PolicyBacked->value)
->and($contract['resolution_path'])->toBe(ResolutionPath::Policy->value)
->and($contract['compare_capability'])->toBe('supported')
->and($contract['capture_capability'])->toBe('supported')
->and($contract['source_model_expected'])->toBe('policy');
});
it('derives limited inventory-backed foundation support from canonical metadata', function (): void {
$contract = InventoryPolicyTypeMeta::baselineSupportContract('roleScopeTag');
expect($contract['config_supported'])->toBeTrue()
->and($contract['runtime_valid'])->toBeTrue()
->and($contract['subject_class'])->toBe(SubjectClass::FoundationBacked->value)
->and($contract['resolution_path'])->toBe(ResolutionPath::FoundationInventory->value)
->and($contract['compare_capability'])->toBe('limited')
->and($contract['capture_capability'])->toBe('limited')
->and($contract['source_model_expected'])->toBe('inventory');
});
it('derives supported foundation policy resolution for intune role definitions', function (): void {
$contract = InventoryPolicyTypeMeta::baselineSupportContract('intuneRoleDefinition');
expect($contract['config_supported'])->toBeTrue()
->and($contract['runtime_valid'])->toBeTrue()
->and($contract['subject_class'])->toBe(SubjectClass::FoundationBacked->value)
->and($contract['resolution_path'])->toBe(ResolutionPath::FoundationPolicy->value)
->and($contract['compare_capability'])->toBe('supported')
->and($contract['capture_capability'])->toBe('supported')
->and($contract['source_model_expected'])->toBe('policy');
});
it('marks unsupported and malformed contracts deterministically', function (): void {
config()->set('tenantpilot.foundation_types', [
[
'type' => 'intuneRoleAssignment',
'label' => 'Intune RBAC Role Assignment',
'baseline_compare' => [
'supported' => false,
'identity_strategy' => 'external_id',
],
],
[
'type' => 'brokenFoundation',
'label' => 'Broken Foundation',
'baseline_compare' => [
'supported' => true,
'resolution' => [
'resolution_path' => 'broken',
],
],
],
]);
$unsupported = InventoryPolicyTypeMeta::baselineSupportContract('intuneRoleAssignment');
$invalid = InventoryPolicyTypeMeta::baselineSupportContract('brokenFoundation');
expect($unsupported['config_supported'])->toBeFalse()
->and($unsupported['compare_capability'])->toBe('unsupported')
->and($unsupported['capture_capability'])->toBe('unsupported');
expect($invalid['config_supported'])->toBeTrue()
->and($invalid['runtime_valid'])->toBeFalse()
->and($invalid['compare_capability'])->toBe('unsupported')
->and($invalid['capture_capability'])->toBe('unsupported');
});

View File

@ -26,6 +26,11 @@
new PageActionData(label: 'Hidden', url: '/admin/operations/hidden', visible: false),
],
))
->decisionZone([
'title' => 'Decision',
'facts' => [['label' => 'Outcome', 'value' => 'Succeeded']],
'primaryNextStep' => ['text' => 'No action needed.', 'source' => 'none_required'],
])
->addSection(
new DetailSectionData(
id: 'counts',
@ -72,10 +77,12 @@
->toArray();
expect($page['header']['primaryActions'])->toHaveCount(1)
->and($page['decisionZone'])->not->toBeNull()
->and($page['decisionZone']['title'])->toBe('Decision')
->and($page['mainSections'])->toHaveCount(1)
->and($page['mainSections'][0]['title'])->toBe('Counts')
->and($page['supportingCards'])->toHaveCount(1)
->and($page['supportingCards'][0]['title'])->toBe('Timing')
->and($page['supportingGroups'])->toHaveCount(1)
->and($page['supportingGroups'][0]['title'])->toBe('Timing')
->and($page['technicalSections'])->toHaveCount(1)
->and($page['technicalSections'][0]['title'])->toBe('Context');
});