feat: normalize operator outcome taxonomy #186
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -96,6 +96,7 @@ ## Active Technologies
|
||||
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
|
||||
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
||||
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
||||
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -115,8 +116,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 156-operator-outcome-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
- 155-tenant-review-layer: Added PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService`
|
||||
- 001-finding-risk-acceptance: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns
|
||||
- 153-evidence-domain-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -3,12 +3,17 @@
|
||||
|
||||
- Version change: 1.11.0 → 1.12.0
|
||||
- Modified principles:
|
||||
- Scope & Ownership Clarification (SCOPE-001)
|
||||
- Added sections:
|
||||
- None
|
||||
- Added sections:
|
||||
- Operator Surface Principles (OPSURF-001)
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- None
|
||||
- ✅ .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
|
||||
- Follow-up TODOs:
|
||||
- None.
|
||||
-->
|
||||
@ -330,6 +335,65 @@ ### Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||
- The visible run label for that action MUST be `Policy sync`.
|
||||
- The audit prose for that action MUST be `{actor} queued policy sync`.
|
||||
|
||||
### Operator Surface Principles (OPSURF-001)
|
||||
|
||||
Goal: operator-facing surfaces MUST optimize for the primary working audience rather than raw implementation visibility.
|
||||
|
||||
Operator-first default surfaces
|
||||
- `/admin` is operator-first.
|
||||
- Default-visible content MUST use operator-facing language, clear scope, and actionable status communication.
|
||||
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
|
||||
|
||||
Progressive disclosure for diagnostics
|
||||
- Diagnostic detail MAY be available where needed, but it MUST be secondary and explicitly revealed.
|
||||
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces, drawers, tabs, accordions, or modals rather than primary content.
|
||||
- A surface MUST NOT require operators to parse raw JSON or provider-specific field names to understand the primary state or next action.
|
||||
|
||||
Distinct status dimensions
|
||||
- Operator-facing surfaces MUST distinguish at least the following dimensions when they all exist in the domain:
|
||||
- execution outcome
|
||||
- data completeness
|
||||
- governance result
|
||||
- lifecycle or readiness state
|
||||
- These dimensions MUST NOT be collapsed into a single ambiguous status model.
|
||||
- If a surface summarizes multiple status dimensions, the default-visible presentation MUST label each dimension explicitly.
|
||||
|
||||
Explicit mutation scope
|
||||
- Every action that changes state MUST communicate before execution whether it affects:
|
||||
- TenantPilot only
|
||||
- the Microsoft tenant
|
||||
- simulation only
|
||||
- Mutation scope MUST be understandable from the action label, helper text, confirmation copy, preview, or nearby status copy before the operator commits.
|
||||
- A mutating action MUST NOT rely on hidden implementation knowledge to communicate its blast radius.
|
||||
|
||||
Safe execution for dangerous actions
|
||||
- Dangerous actions MUST follow a consistent safe-execution pattern:
|
||||
- configuration
|
||||
- safety checks or simulation
|
||||
- preview
|
||||
- hard confirmation where required
|
||||
- execute
|
||||
- One-click destructive actions are not acceptable for high-blast-radius operations.
|
||||
- When a full multi-step flow is not feasible, the spec MUST document the explicit exemption and the replacement safeguards.
|
||||
|
||||
Explicit workspace and tenant context
|
||||
- Workspace and tenant scope MUST remain explicit in navigation, actions, and page semantics.
|
||||
- Tenant-scoped surfaces MUST NOT silently expose workspace-wide actions.
|
||||
- Canonical workspace views that reference tenant-owned records MUST make the workspace and tenant context legible before the operator acts.
|
||||
|
||||
Page contract requirement
|
||||
- Every new or materially refactored operator-facing page MUST define:
|
||||
- primary persona
|
||||
- surface type
|
||||
- primary operator question
|
||||
- default-visible information
|
||||
- diagnostics-only information
|
||||
- status dimensions used
|
||||
- mutation scope
|
||||
- primary actions
|
||||
- dangerous actions
|
||||
- This page contract MUST be recorded in the governing spec and kept in sync when the page semantics materially change.
|
||||
|
||||
Spec Scope Fields (SCOPE-002)
|
||||
|
||||
- Every feature spec MUST declare:
|
||||
@ -387,4 +451,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 1.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-10
|
||||
**Version**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21
|
||||
|
||||
@ -50,6 +50,12 @@ ## Constitution Check
|
||||
- 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
|
||||
- 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
|
||||
- Operator surfaces (OPSURF-001): every mutating action communicates whether it changes TenantPilot only, the Microsoft tenant, or simulation only before execution
|
||||
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
|
||||
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
||||
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||
## Project Structure
|
||||
|
||||
@ -17,6 +17,14 @@ ## Spec Scope Fields *(mandatory)*
|
||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
@ -127,6 +135,15 @@ ## Requirements *(mandatory)*
|
||||
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
||||
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
||||
- which diagnostics are secondary and how they are explicitly revealed,
|
||||
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
||||
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
||||
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
||||
- and the page contract for each new or materially refactored operator-facing page.
|
||||
|
||||
**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.
|
||||
|
||||
@ -38,6 +38,13 @@ # Tasks: [FEATURE NAME]
|
||||
- using source/domain terms only where same-screen disambiguation is required,
|
||||
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||
- removing implementation-first wording from primary operator-facing copy.
|
||||
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
||||
- filling the spec’s Operator Surface Contract for every affected page,
|
||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
|
||||
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
||||
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||
|
||||
@ -172,6 +172,9 @@ private function applyActiveTab(Builder $query): Builder
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
]),
|
||||
'blocked' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Blocked->value),
|
||||
'succeeded' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Succeeded->value),
|
||||
|
||||
@ -177,13 +177,13 @@ public function blockedExecutionBanner(): ?array
|
||||
}
|
||||
|
||||
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
|
||||
$message = $this->run->failure_summary[0]['message'] ?? null;
|
||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : 'The queued run was refused before side effects could begin.';
|
||||
$message = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.';
|
||||
$guidance = OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.';
|
||||
|
||||
return [
|
||||
'tone' => 'amber',
|
||||
'title' => 'Execution blocked',
|
||||
'body' => sprintf('Reason code: %s. %s', $reasonCode, $message),
|
||||
'title' => 'Blocked by prerequisite',
|
||||
'body' => sprintf('%s Reason code: %s. %s', $message, $reasonCode, $guidance),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -11,11 +11,13 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -148,12 +150,7 @@ public function table(Table $table): Table
|
||||
]),
|
||||
SelectFilter::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||
SelectFilter::make('published_state')
|
||||
->label('Published state')
|
||||
->options([
|
||||
|
||||
@ -9,7 +9,10 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
@ -173,7 +176,9 @@ public static function table(Table $table): Table
|
||||
->label('State')
|
||||
->badge()
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
||||
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
|
||||
->color(static fn (BaselineSnapshot $record): string => self::gapSpec($record)->color)
|
||||
->icon(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->icon)
|
||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::gapSpec($record)->iconColor),
|
||||
])
|
||||
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
@ -249,10 +254,7 @@ private static function baselineProfileOptions(): array
|
||||
*/
|
||||
private static function snapshotStateOptions(): array
|
||||
{
|
||||
return [
|
||||
'complete' => 'Complete',
|
||||
'with_gaps' => 'Captured with gaps',
|
||||
];
|
||||
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotGapStatus, ['clear', 'gaps_present']);
|
||||
}
|
||||
|
||||
public static function resolveWorkspace(): ?Workspace
|
||||
@ -290,7 +292,13 @@ private static function fidelitySummary(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
$counts = self::fidelityCounts($snapshot);
|
||||
|
||||
return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0));
|
||||
return sprintf(
|
||||
'%s %d, %s %d',
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::Full->value)->label,
|
||||
(int) ($counts['content'] ?? 0),
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::ReferenceOnly->value)->label,
|
||||
(int) ($counts['meta'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
private static function gapsCount(BaselineSnapshot $snapshot): int
|
||||
@ -298,6 +306,17 @@ private static function gapsCount(BaselineSnapshot $snapshot): int
|
||||
$summary = self::summary($snapshot);
|
||||
$gaps = $summary['gaps'] ?? null;
|
||||
$gaps = is_array($gaps) ? $gaps : [];
|
||||
$byReason = is_array($gaps['by_reason'] ?? null) ? $gaps['by_reason'] : [];
|
||||
|
||||
if ($byReason !== []) {
|
||||
return array_sum(array_map(
|
||||
static fn (mixed $count, string $reason): int => in_array($reason, ['meta_fallback'], true) || ! is_numeric($count)
|
||||
? 0
|
||||
: (int) $count,
|
||||
$byReason,
|
||||
array_keys($byReason),
|
||||
));
|
||||
}
|
||||
|
||||
$count = $gaps['count'] ?? 0;
|
||||
|
||||
@ -311,7 +330,7 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
||||
|
||||
private static function stateLabel(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete';
|
||||
return self::gapSpec($snapshot)->label;
|
||||
}
|
||||
|
||||
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder
|
||||
@ -323,8 +342,8 @@ private static function applySnapshotStateFilter(Builder $query, mixed $value):
|
||||
$gapCountExpression = self::gapCountExpression($query);
|
||||
|
||||
return match ($value) {
|
||||
'complete' => $query->whereRaw("{$gapCountExpression} = 0"),
|
||||
'with_gaps' => $query->whereRaw("{$gapCountExpression} > 0"),
|
||||
'clear' => $query->whereRaw("{$gapCountExpression} = 0"),
|
||||
'gaps_present' => $query->whereRaw("{$gapCountExpression} > 0"),
|
||||
default => $query,
|
||||
};
|
||||
}
|
||||
@ -332,9 +351,17 @@ private static function applySnapshotStateFilter(Builder $query, mixed $value):
|
||||
private static function gapCountExpression(Builder $query): string
|
||||
{
|
||||
return match ($query->getConnection()->getDriverName()) {
|
||||
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0)",
|
||||
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0)",
|
||||
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS UNSIGNED), 0)",
|
||||
'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)",
|
||||
};
|
||||
}
|
||||
|
||||
private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
||||
{
|
||||
return BadgeCatalog::spec(
|
||||
BadgeDomain::BaselineSnapshotGapStatus,
|
||||
self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,8 +13,10 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -163,8 +165,8 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
||||
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
||||
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
||||
TextEntry::make('summary.missing_dimensions')->label('Missing dimensions')->placeholder('—'),
|
||||
TextEntry::make('summary.stale_dimensions')->label('Stale dimensions')->placeholder('—'),
|
||||
TextEntry::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value))->placeholder('—'),
|
||||
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Evidence dimensions')
|
||||
@ -222,25 +224,13 @@ public static function table(Table $table): Table
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label('Missing'),
|
||||
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options([
|
||||
'queued' => 'Queued',
|
||||
'generating' => 'Generating',
|
||||
'active' => 'Active',
|
||||
'superseded' => 'Superseded',
|
||||
'expired' => 'Expired',
|
||||
'failed' => 'Failed',
|
||||
]),
|
||||
->options(BadgeCatalog::options(BadgeDomain::EvidenceSnapshotStatus, EvidenceSnapshotStatus::values())),
|
||||
Tables\Filters\SelectFilter::make('completeness_state')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('view_snapshot')
|
||||
@ -418,13 +408,16 @@ private static function operationsSummaryPresentation(array $payload): array
|
||||
$failedCount = (int) ($payload['failed_count'] ?? 0);
|
||||
$partialCount = (int) ($payload['partial_count'] ?? 0);
|
||||
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
||||
$actionSummary = $failedCount === 0 && $partialCount === 0
|
||||
? 'No action needed.'
|
||||
: sprintf('%d execution failures, %d need follow-up.', $failedCount, $partialCount);
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d operations in the last 30 days, %d failed, %d partial.', $operationCount, $failedCount, $partialCount),
|
||||
'summary' => sprintf('%d operations in the last 30 days. %s', $operationCount, $actionSummary),
|
||||
'highlights' => [
|
||||
['label' => 'Operations', 'value' => (string) $operationCount],
|
||||
['label' => 'Failed operations', 'value' => (string) $failedCount],
|
||||
['label' => 'Partial operations', 'value' => (string) $partialCount],
|
||||
['label' => 'Execution failures', 'value' => (string) $failedCount],
|
||||
['label' => 'Needs follow-up', 'value' => (string) $partialCount],
|
||||
],
|
||||
'items' => collect($entries)
|
||||
->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null)
|
||||
@ -564,20 +557,37 @@ private static function operationEntryStateLabel(array $entry): ?string
|
||||
$outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null;
|
||||
|
||||
return match ($status) {
|
||||
OperationRunStatus::Queued->value => 'Queued',
|
||||
OperationRunStatus::Running->value => 'Running',
|
||||
OperationRunStatus::Queued->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||
OperationRunStatus::Running->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||
OperationRunStatus::Completed->value => match ($outcome) {
|
||||
OperationRunOutcome::Succeeded->value => 'Completed',
|
||||
OperationRunOutcome::PartiallySucceeded->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::PartiallySucceeded->value],
|
||||
OperationRunOutcome::Blocked->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Blocked->value],
|
||||
OperationRunOutcome::Failed->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Failed->value],
|
||||
OperationRunOutcome::Cancelled->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Cancelled->value],
|
||||
default => 'Completed',
|
||||
OperationRunOutcome::Pending->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
OperationRunOutcome::Blocked->value,
|
||||
OperationRunOutcome::Failed->value,
|
||||
OperationRunOutcome::Cancelled->value => static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome),
|
||||
default => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
||||
},
|
||||
default => $outcome !== null ? (OperationRunOutcome::uiLabels(true)[$outcome] ?? null) : null,
|
||||
default => $outcome !== null ? static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function evidenceCompletenessCountLabel(string $state): string
|
||||
{
|
||||
return BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $state)->label;
|
||||
}
|
||||
|
||||
private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
|
||||
{
|
||||
if ($state === null || trim($state) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$label = BadgeCatalog::spec($domain, $state)->label;
|
||||
|
||||
return $label === 'Unknown' ? null : $label;
|
||||
}
|
||||
|
||||
private static function stringifySummaryValue(mixed $value): string
|
||||
{
|
||||
return match (true) {
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
@ -21,7 +22,9 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\RunDurationInsights;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
@ -152,7 +155,8 @@ public static function table(Table $table): Table
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('tenant_id')
|
||||
@ -205,13 +209,9 @@ public static function table(Table $table): Table
|
||||
return FilterOptionCatalog::operationTypes(array_keys($types));
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options([
|
||||
OperationRunStatus::Queued->value => 'Queued',
|
||||
OperationRunStatus::Running->value => 'Running',
|
||||
OperationRunStatus::Completed->value => 'Completed',
|
||||
]),
|
||||
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
|
||||
Tables\Filters\SelectFilter::make('outcome')
|
||||
->options(OperationRunOutcome::uiLabels(includeReserved: false)),
|
||||
->options(BadgeCatalog::options(BadgeDomain::OperationRunOutcome, OperationRunOutcome::values(includeReserved: false))),
|
||||
Tables\Filters\SelectFilter::make('initiator_name')
|
||||
->label('Initiator')
|
||||
->options(function (): array {
|
||||
@ -322,6 +322,9 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$referencedTenantLifecycle?->contextNote !== null
|
||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||
: 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))
|
||||
@ -454,7 +457,7 @@ 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(ucfirst(str_replace('_', ' ', $key)), $value),
|
||||
static fn (string $key, int $value): array => $factory->keyFact(SummaryCountsNormalizer::label($key), $value),
|
||||
array_keys($counts),
|
||||
array_values($counts),
|
||||
);
|
||||
|
||||
@ -824,10 +824,10 @@ public static function table(Table $table): Table
|
||||
->label('Total')
|
||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
||||
Tables\Columns\TextColumn::make('summary_succeeded')
|
||||
->label('Succeeded')
|
||||
->label('Applied')
|
||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
|
||||
Tables\Columns\TextColumn::make('summary_failed')
|
||||
->label('Failed')
|
||||
->label('Failed items')
|
||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
|
||||
Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(),
|
||||
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(),
|
||||
@ -1261,7 +1261,7 @@ public static function infolist(Schema $schema): Schema
|
||||
$succeeded = (int) ($meta['succeeded'] ?? 0);
|
||||
$failed = (int) ($meta['failed'] ?? 0);
|
||||
|
||||
return sprintf('Total: %d • Succeeded: %d • Failed: %d', $total, $succeeded, $failed);
|
||||
return sprintf('Total: %d • Applied: %d • Failed items: %d', $total, $succeeded, $failed);
|
||||
}),
|
||||
Infolists\Components\TextEntry::make('is_dry_run')
|
||||
->label('Dry-run')
|
||||
|
||||
@ -15,12 +15,14 @@
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
@ -248,7 +250,7 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label('Missing'),
|
||||
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
|
||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
||||
->label('Export')
|
||||
->boolean(),
|
||||
@ -262,12 +264,7 @@ public static function table(Table $table): Table
|
||||
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
||||
->all()),
|
||||
Tables\Filters\SelectFilter::make('completeness_state')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||
])
|
||||
->actions([
|
||||
@ -503,13 +500,18 @@ private static function evidenceSnapshotOptions(): array
|
||||
(string) $snapshot->getKey() => sprintf(
|
||||
'#%d · %s · %s',
|
||||
(int) $snapshot->getKey(),
|
||||
Str::headline((string) $snapshot->completeness_state),
|
||||
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
|
||||
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
||||
),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function reviewCompletenessCountLabel(string $state): string
|
||||
{
|
||||
return BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, $state)->label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
@ -57,7 +58,8 @@ public function table(Table $table): Table
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
TextColumn::make('created_at')
|
||||
->label('Started')
|
||||
->sortable()
|
||||
|
||||
@ -23,6 +23,7 @@ class WorkspaceRecentOperations extends Widget
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* url: string
|
||||
* }>
|
||||
@ -48,6 +49,7 @@ class WorkspaceRecentOperations extends Widget
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* url: string
|
||||
* }> $operations
|
||||
|
||||
@ -49,8 +49,8 @@ public function toDatabase(object $notifiable): array
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} queued")
|
||||
->body('Queued. Monitor progress in Monitoring → Operations.')
|
||||
->warning()
|
||||
->body('Queued for execution. Open the run for progress and next steps.')
|
||||
->info()
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
@ -57,7 +58,8 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
||||
$groups,
|
||||
);
|
||||
|
||||
$overallGapCount = $this->summaryGapCount($summary);
|
||||
$overallGapSummary = $this->summaryGapSummary($summary);
|
||||
$overallGapCount = $overallGapSummary->count;
|
||||
$overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty());
|
||||
|
||||
return new RenderedSnapshot(
|
||||
@ -67,7 +69,7 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
||||
snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== ''
|
||||
? trim($snapshot->snapshot_identity_hash)
|
||||
: null,
|
||||
stateLabel: $overallGapCount > 0 ? 'Captured with gaps' : 'Complete',
|
||||
stateLabel: $this->gapStatusSpec($overallGapCount)->label,
|
||||
fidelitySummary: $this->fidelitySummary($summary),
|
||||
overallFidelity: $overallFidelity,
|
||||
overallGapCount: $overallGapCount,
|
||||
@ -97,9 +99,12 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
$rendered = $this->present($snapshot);
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
|
||||
$stateSpec = $this->gapStatusSpec($rendered->overallGapCount);
|
||||
$stateBadge = $factory->statusBadge(
|
||||
$rendered->stateLabel,
|
||||
$rendered->overallGapCount > 0 ? 'warning' : 'success',
|
||||
$stateSpec->label,
|
||||
$stateSpec->color,
|
||||
$stateSpec->icon,
|
||||
$stateSpec->iconColor,
|
||||
);
|
||||
|
||||
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
||||
@ -223,10 +228,6 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
|
||||
$renderedItems,
|
||||
));
|
||||
|
||||
if ($renderingError !== null) {
|
||||
$gapSummary = $gapSummary->withMessage($renderingError);
|
||||
}
|
||||
|
||||
$capturedAt = collect($renderedItems)
|
||||
->pluck('observedAt')
|
||||
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
@ -276,10 +277,7 @@ private function technicalPayload(Collection $items): array
|
||||
*/
|
||||
private function summaryGapCount(array $summary): int
|
||||
{
|
||||
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
|
||||
$count = $gaps['count'] ?? 0;
|
||||
|
||||
return is_numeric($count) ? (int) $count : 0;
|
||||
return $this->summaryGapSummary($summary)->count;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -294,7 +292,40 @@ private function fidelitySummary(array $summary): string
|
||||
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
|
||||
$meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 0;
|
||||
|
||||
return sprintf('Content %d, Meta %d', $content, $meta);
|
||||
return sprintf(
|
||||
'%s %d, %s %d',
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::Full->value)->label,
|
||||
$content,
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::ReferenceOnly->value)->label,
|
||||
$meta,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
*/
|
||||
private function summaryGapSummary(array $summary): GapSummary
|
||||
{
|
||||
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
|
||||
$byReason = is_array($gaps['by_reason'] ?? null) ? $gaps['by_reason'] : [];
|
||||
$gapSummary = GapSummary::fromReasonMap($byReason);
|
||||
|
||||
if ($byReason !== [] || ! is_numeric($gaps['count'] ?? null) || (int) $gaps['count'] <= 0) {
|
||||
return $gapSummary;
|
||||
}
|
||||
|
||||
return new GapSummary(
|
||||
count: (int) $gaps['count'],
|
||||
messages: ['Coverage gaps need review.'],
|
||||
);
|
||||
}
|
||||
|
||||
private function gapStatusSpec(int $gapCount): \App\Support\Badges\BadgeSpec
|
||||
{
|
||||
return BadgeRenderer::spec(
|
||||
BadgeDomain::BaselineSnapshotGapStatus,
|
||||
$gapCount > 0 ? 'gaps_present' : 'clear',
|
||||
);
|
||||
}
|
||||
|
||||
private function typeLabel(string $policyType): string
|
||||
|
||||
@ -95,9 +95,9 @@ public function coverageHint(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Full => null,
|
||||
self::Partial => 'Mixed evidence fidelity across this group.',
|
||||
self::ReferenceOnly => 'Metadata-only evidence is available.',
|
||||
self::Unsupported => 'Fallback metadata rendering is being used.',
|
||||
self::Partial => 'Mixed evidence detail is available for this group.',
|
||||
self::ReferenceOnly => 'Metadata-only evidence is available for this group.',
|
||||
self::Unsupported => 'Support is limited for this policy type. Fallback rendering is being used.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@ public static function fromItemMeta(array $meta, FidelityState $fidelity, bool $
|
||||
$messages = self::uniqueMessages($messages);
|
||||
|
||||
return new self(
|
||||
count: count($messages),
|
||||
count: 0,
|
||||
messages: $messages,
|
||||
);
|
||||
}
|
||||
@ -60,17 +60,25 @@ public static function fromItemMeta(array $meta, FidelityState $fidelity, bool $
|
||||
public static function fromReasonMap(array $reasons): self
|
||||
{
|
||||
$messages = [];
|
||||
$primaryCount = 0;
|
||||
|
||||
foreach ($reasons as $reason => $count) {
|
||||
if (! is_string($reason) || ! is_numeric($count) || (int) $count <= 0) {
|
||||
foreach ($reasons as $reason => $reasonCount) {
|
||||
if (! is_string($reason) || ! is_numeric($reasonCount) || (int) $reasonCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$messages[] = sprintf('%s (%d)', self::humanizeReason($reason), (int) $count);
|
||||
if (self::isDiagnosticReason($reason)) {
|
||||
$messages[] = sprintf('%s (%d)', self::diagnosticMessageForReason($reason), (int) $reasonCount);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$messages[] = sprintf('%s (%d)', self::humanizeReason($reason), (int) $reasonCount);
|
||||
$primaryCount += (int) $reasonCount;
|
||||
}
|
||||
|
||||
return new self(
|
||||
count: array_sum(array_map(static fn (mixed $value): int => is_numeric($value) ? (int) $value : 0, $reasons)),
|
||||
count: $primaryCount,
|
||||
messages: self::uniqueMessages($messages),
|
||||
);
|
||||
}
|
||||
@ -90,10 +98,6 @@ public static function merge(array $summaries): self
|
||||
|
||||
$messages = self::uniqueMessages($messages);
|
||||
|
||||
if ($count === 0) {
|
||||
$count = count($messages);
|
||||
}
|
||||
|
||||
return new self(
|
||||
count: $count,
|
||||
messages: $messages,
|
||||
@ -111,14 +115,14 @@ public function withMessage(string $message): self
|
||||
$messages = self::uniqueMessages([...$this->messages, $message]);
|
||||
|
||||
return new self(
|
||||
count: max($this->count, count($messages)),
|
||||
count: $this->count,
|
||||
messages: $messages,
|
||||
);
|
||||
}
|
||||
|
||||
public function hasGaps(): bool
|
||||
{
|
||||
return $this->count > 0 || $this->messages !== [];
|
||||
return $this->count > 0;
|
||||
}
|
||||
|
||||
public function badgeState(): string
|
||||
@ -158,4 +162,17 @@ private static function humanizeReason(string $reason): string
|
||||
->headline()
|
||||
->toString();
|
||||
}
|
||||
|
||||
private static function isDiagnosticReason(string $reason): bool
|
||||
{
|
||||
return in_array($reason, ['meta_fallback'], true);
|
||||
}
|
||||
|
||||
private static function diagnosticMessageForReason(string $reason): string
|
||||
{
|
||||
return match ($reason) {
|
||||
'meta_fallback' => 'Metadata-only evidence was used for some items.',
|
||||
default => self::humanizeReason($reason),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,6 +93,27 @@ public static function mapper(BadgeDomain $domain): ?BadgeMapper
|
||||
return $mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed> $values
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function options(BadgeDomain $domain, iterable $values): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
$normalized = self::normalizeState($value);
|
||||
|
||||
if ($normalized === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[$normalized] = self::spec($domain, $value)->label;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public static function normalizeState(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
|
||||
@ -23,6 +23,15 @@ public function __construct(
|
||||
public readonly string $color,
|
||||
public readonly ?string $icon = null,
|
||||
public readonly ?string $iconColor = null,
|
||||
public readonly ?OperatorSemanticAxis $semanticAxis = null,
|
||||
public readonly ?OperatorStateClassification $classification = null,
|
||||
public readonly ?OperatorNextActionPolicy $nextActionPolicy = null,
|
||||
public readonly ?string $diagnosticLabel = null,
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public readonly array $legacyAliases = [],
|
||||
public readonly ?string $notes = null,
|
||||
) {
|
||||
if (trim($this->label) === '') {
|
||||
throw new InvalidArgumentException('BadgeSpec label must be a non-empty string.');
|
||||
@ -39,6 +48,41 @@ public function __construct(
|
||||
if ($this->iconColor !== null && ! in_array($this->iconColor, self::ALLOWED_COLORS, true)) {
|
||||
throw new InvalidArgumentException('BadgeSpec iconColor must be null or one of: '.implode(', ', self::ALLOWED_COLORS));
|
||||
}
|
||||
|
||||
$hasTaxonomyMetadata = $this->semanticAxis !== null
|
||||
|| $this->classification !== null
|
||||
|| $this->nextActionPolicy !== null
|
||||
|| $this->diagnosticLabel !== null
|
||||
|| $this->legacyAliases !== []
|
||||
|| $this->notes !== null;
|
||||
|
||||
if ($hasTaxonomyMetadata && ($this->semanticAxis === null || $this->classification === null || $this->nextActionPolicy === null)) {
|
||||
throw new InvalidArgumentException('BadgeSpec taxonomy metadata requires semanticAxis, classification, and nextActionPolicy together.');
|
||||
}
|
||||
|
||||
if ($this->diagnosticLabel !== null && trim($this->diagnosticLabel) === '') {
|
||||
throw new InvalidArgumentException('BadgeSpec diagnosticLabel must be null or a non-empty string.');
|
||||
}
|
||||
|
||||
foreach ($this->legacyAliases as $legacyAlias) {
|
||||
if (! is_string($legacyAlias) || trim($legacyAlias) === '') {
|
||||
throw new InvalidArgumentException('BadgeSpec legacyAliases must contain only non-empty strings.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->notes !== null && trim($this->notes) === '') {
|
||||
throw new InvalidArgumentException('BadgeSpec notes must be null or a non-empty string.');
|
||||
}
|
||||
|
||||
if ($this->classification === OperatorStateClassification::Diagnostic && in_array($this->color, ['warning', 'danger'], true)) {
|
||||
throw new InvalidArgumentException('Diagnostic badge specs cannot use warning or danger colors.');
|
||||
}
|
||||
|
||||
if ($this->classification === OperatorStateClassification::Primary
|
||||
&& in_array($this->color, ['warning', 'danger'], true)
|
||||
&& $this->nextActionPolicy === OperatorNextActionPolicy::None) {
|
||||
throw new InvalidArgumentException('Primary warning or danger badge specs must declare an operator next-action policy.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -6,8 +6,10 @@
|
||||
|
||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class BaselineSnapshotFidelityBadge implements BadgeMapper
|
||||
{
|
||||
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
FidelityState::Full->value => new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'),
|
||||
FidelityState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
FidelityState::ReferenceOnly->value => new BadgeSpec('Reference only', 'info', 'heroicon-m-document-text'),
|
||||
FidelityState::Unsupported->value => new BadgeSpec('Unsupported', 'gray', 'heroicon-m-question-mark-circle'),
|
||||
FidelityState::Full->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-check-circle'),
|
||||
FidelityState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-document-magnifying-glass'),
|
||||
FidelityState::ReferenceOnly->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-document-text'),
|
||||
FidelityState::Unsupported->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotFidelity, $state, 'heroicon-m-question-mark-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class BaselineSnapshotGapStatusBadge implements BadgeMapper
|
||||
{
|
||||
@ -15,9 +17,9 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'clear' => new BadgeSpec('No gaps', 'success', 'heroicon-m-check-circle'),
|
||||
'gaps_present' => new BadgeSpec('Gaps present', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'clear' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotGapStatus, $state, 'heroicon-m-check-circle'),
|
||||
'gaps_present' => OperatorOutcomeTaxonomy::spec(BadgeDomain::BaselineSnapshotGapStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class EvidenceCompletenessBadge implements BadgeMapper
|
||||
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
EvidenceCompletenessState::Complete->value => new BadgeSpec('Complete', 'success', 'heroicon-m-check-badge'),
|
||||
EvidenceCompletenessState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
EvidenceCompletenessState::Missing->value => new BadgeSpec('Missing', 'danger', 'heroicon-m-x-circle'),
|
||||
EvidenceCompletenessState::Stale->value => new BadgeSpec('Stale', 'gray', 'heroicon-m-clock'),
|
||||
EvidenceCompletenessState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-check-badge'),
|
||||
EvidenceCompletenessState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-exclamation-triangle'),
|
||||
EvidenceCompletenessState::Missing->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-clock'),
|
||||
EvidenceCompletenessState::Stale->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::EvidenceCompleteness, $state, 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\OperationRunOutcome;
|
||||
|
||||
final class OperationRunOutcomeBadge implements BadgeMapper
|
||||
@ -14,13 +16,13 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
OperationRunOutcome::Pending->value => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
|
||||
OperationRunOutcome::Succeeded->value => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
|
||||
OperationRunOutcome::PartiallySucceeded->value => new BadgeSpec('Partially succeeded', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
OperationRunOutcome::Blocked->value, 'operation.blocked' => new BadgeSpec('Blocked', 'warning', 'heroicon-m-no-symbol'),
|
||||
OperationRunOutcome::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
OperationRunOutcome::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
||||
OperationRunOutcome::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-clock'),
|
||||
OperationRunOutcome::Succeeded->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-check-circle'),
|
||||
OperationRunOutcome::PartiallySucceeded->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-exclamation-triangle'),
|
||||
OperationRunOutcome::Blocked->value, 'operation.blocked' => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-no-symbol'),
|
||||
OperationRunOutcome::Failed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-x-circle'),
|
||||
OperationRunOutcome::Cancelled->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunOutcome, $state, 'heroicon-m-minus-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
final class OperationRunStatusBadge implements BadgeMapper
|
||||
@ -14,9 +16,9 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
OperationRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
||||
OperationRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
OperationRunStatus::Completed->value => new BadgeSpec('Completed', 'gray', 'heroicon-m-check-circle'),
|
||||
OperationRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-clock'),
|
||||
OperationRunStatus::Running->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-arrow-path'),
|
||||
OperationRunStatus::Completed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::OperationRunStatus, $state, 'heroicon-m-check-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class RestoreCheckSeverityBadge implements BadgeMapper
|
||||
{
|
||||
@ -13,10 +15,10 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'blocking' => new BadgeSpec('Blocking', 'danger', 'heroicon-m-x-circle'),
|
||||
'warning' => new BadgeSpec('Warning', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'safe' => new BadgeSpec('Safe', 'success', 'heroicon-m-check-circle'),
|
||||
'blocking' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-x-circle'),
|
||||
'warning' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-exclamation-triangle'),
|
||||
'safe' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreCheckSeverity, $state, 'heroicon-m-check-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class RestorePreviewDecisionBadge implements BadgeMapper
|
||||
{
|
||||
@ -13,12 +15,12 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'created' => new BadgeSpec('Created', 'success', 'heroicon-m-check-circle'),
|
||||
'created_copy' => new BadgeSpec('Created copy', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'mapped_existing' => new BadgeSpec('Mapped existing', 'info', 'heroicon-m-arrow-path'),
|
||||
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
|
||||
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
'created' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-plus-circle'),
|
||||
'created_copy' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-document-duplicate'),
|
||||
'mapped_existing' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-arrow-right-circle'),
|
||||
'skipped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-minus-circle'),
|
||||
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestorePreviewDecision, $state, 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
|
||||
final class RestoreResultStatusBadge implements BadgeMapper
|
||||
{
|
||||
@ -13,14 +15,14 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'applied' => new BadgeSpec('Applied', 'success', 'heroicon-m-check-circle'),
|
||||
'dry_run' => new BadgeSpec('Dry run', 'info', 'heroicon-m-arrow-path'),
|
||||
'mapped' => new BadgeSpec('Mapped', 'success', 'heroicon-m-check-circle'),
|
||||
'skipped' => new BadgeSpec('Skipped', 'warning', 'heroicon-m-minus-circle'),
|
||||
'partial' => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'manual_required' => new BadgeSpec('Manual required', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'failed' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
'applied' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-check-circle'),
|
||||
'dry_run' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-eye'),
|
||||
'mapped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-arrow-right-circle'),
|
||||
'skipped' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-minus-circle'),
|
||||
'partial' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||
'manual_required' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-wrench-screwdriver'),
|
||||
'failed' => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreResultStatus, $state, 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\RestoreRunStatus;
|
||||
|
||||
final class RestoreRunStatusBadge implements BadgeMapper
|
||||
@ -14,20 +16,20 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
RestoreRunStatus::Draft->value => new BadgeSpec('Draft', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Scoped->value => new BadgeSpec('Scoped', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Checked->value => new BadgeSpec('Checked', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Previewed->value => new BadgeSpec('Previewed', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Pending->value => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
|
||||
RestoreRunStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
||||
RestoreRunStatus::Running->value => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
RestoreRunStatus::Completed->value => new BadgeSpec('Completed', 'success', 'heroicon-m-check-circle'),
|
||||
RestoreRunStatus::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
RestoreRunStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
RestoreRunStatus::Cancelled->value => new BadgeSpec('Cancelled', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Aborted->value => new BadgeSpec('Aborted', 'gray', 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::CompletedWithErrors->value => new BadgeSpec('Completed with errors', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
RestoreRunStatus::Draft->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Scoped->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-funnel'),
|
||||
RestoreRunStatus::Checked->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-shield-check'),
|
||||
RestoreRunStatus::Previewed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-eye'),
|
||||
RestoreRunStatus::Pending->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-clock'),
|
||||
RestoreRunStatus::Queued->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-queue-list'),
|
||||
RestoreRunStatus::Running->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-arrow-path'),
|
||||
RestoreRunStatus::Completed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-check-circle'),
|
||||
RestoreRunStatus::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||
RestoreRunStatus::Failed->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-x-circle'),
|
||||
RestoreRunStatus::Cancelled->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-minus-circle'),
|
||||
RestoreRunStatus::Aborted->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-stop-circle'),
|
||||
RestoreRunStatus::CompletedWithErrors->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::RestoreRunStatus, $state, 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,10 @@
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
|
||||
final class TenantReviewCompletenessStateBadge implements BadgeMapper
|
||||
@ -16,11 +18,11 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
TenantReviewCompletenessState::Complete->value => new BadgeSpec('Complete', 'success', 'heroicon-m-check-circle'),
|
||||
TenantReviewCompletenessState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
TenantReviewCompletenessState::Missing->value => new BadgeSpec('Missing', 'danger', 'heroicon-m-x-circle'),
|
||||
TenantReviewCompletenessState::Stale->value => new BadgeSpec('Stale', 'gray', 'heroicon-m-clock'),
|
||||
TenantReviewCompletenessState::Complete->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-check-circle'),
|
||||
TenantReviewCompletenessState::Partial->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-exclamation-triangle'),
|
||||
TenantReviewCompletenessState::Missing->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-clock'),
|
||||
TenantReviewCompletenessState::Stale->value => OperatorOutcomeTaxonomy::spec(BadgeDomain::TenantReviewCompleteness, $state, 'heroicon-m-arrow-path'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
} ?? BadgeSpec::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
17
app/Support/Badges/OperatorNextActionPolicy.php
Normal file
17
app/Support/Badges/OperatorNextActionPolicy.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
enum OperatorNextActionPolicy: string
|
||||
{
|
||||
case Required = 'required';
|
||||
case Optional = 'optional';
|
||||
case None = 'none';
|
||||
|
||||
public function requiresExplanation(): bool
|
||||
{
|
||||
return $this !== self::None;
|
||||
}
|
||||
}
|
||||
646
app/Support/Badges/OperatorOutcomeTaxonomy.php
Normal file
646
app/Support/Badges/OperatorOutcomeTaxonomy.php
Normal file
@ -0,0 +1,646 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class OperatorOutcomeTaxonomy
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<string, array{
|
||||
* axis: string,
|
||||
* label: string,
|
||||
* color: string,
|
||||
* classification: string,
|
||||
* next_action_policy: string,
|
||||
* legacy_aliases: list<string>,
|
||||
* diagnostic_label?: string|null,
|
||||
* notes: string
|
||||
* }>>
|
||||
*/
|
||||
private const ENTRIES = [
|
||||
'operation_run_status' => [
|
||||
'queued' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Queued for execution',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Queued'],
|
||||
'notes' => 'Execution is waiting for a worker to start the run.',
|
||||
],
|
||||
'running' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'In progress',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Running'],
|
||||
'notes' => 'Execution is currently running.',
|
||||
],
|
||||
'completed' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Run finished',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Completed'],
|
||||
'notes' => 'Execution has reached a terminal state and the outcome badge carries the primary meaning.',
|
||||
],
|
||||
],
|
||||
'operation_run_outcome' => [
|
||||
'pending' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Awaiting result',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Pending'],
|
||||
'notes' => 'Execution has not produced a terminal outcome yet.',
|
||||
],
|
||||
'succeeded' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Completed successfully',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Succeeded'],
|
||||
'notes' => 'The run finished without operator follow-up.',
|
||||
],
|
||||
'partially_succeeded' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Completed with follow-up',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Partially succeeded', 'Partial'],
|
||||
'notes' => 'The run finished but needs operator review or cleanup.',
|
||||
],
|
||||
'blocked' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Blocked by prerequisite',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Blocked'],
|
||||
'notes' => 'Execution could not start or continue until a prerequisite is fixed.',
|
||||
],
|
||||
'failed' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Execution failed',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Failed'],
|
||||
'notes' => 'Execution ended unsuccessfully and needs operator attention.',
|
||||
],
|
||||
'cancelled' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Cancelled',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Cancelled'],
|
||||
'notes' => 'Execution was intentionally stopped.',
|
||||
],
|
||||
],
|
||||
'evidence_completeness' => [
|
||||
'complete' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Coverage ready',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Complete'],
|
||||
'notes' => 'Required evidence is present.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Coverage incomplete',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Partial'],
|
||||
'notes' => 'Some required evidence dimensions are still missing.',
|
||||
],
|
||||
'missing' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Not collected yet',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Missing'],
|
||||
'notes' => 'No evidence has been captured for this slice yet. This is not a failure by itself.',
|
||||
],
|
||||
'stale' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Refresh recommended',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Stale'],
|
||||
'notes' => 'Evidence exists but is old enough that the operator should refresh it before relying on it.',
|
||||
],
|
||||
],
|
||||
'tenant_review_completeness' => [
|
||||
'complete' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Review inputs ready',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Complete'],
|
||||
'notes' => 'The review has the evidence inputs it needs.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Review inputs incomplete',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Partial'],
|
||||
'notes' => 'Some review sections still need inputs.',
|
||||
],
|
||||
'missing' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Review input pending',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Missing'],
|
||||
'notes' => 'The review has not been anchored to usable evidence yet.',
|
||||
],
|
||||
'stale' => [
|
||||
'axis' => 'data_freshness',
|
||||
'label' => 'Refresh review inputs',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Stale'],
|
||||
'notes' => 'The review input exists but should be refreshed before stakeholder use.',
|
||||
],
|
||||
],
|
||||
'baseline_snapshot_fidelity' => [
|
||||
'full' => [
|
||||
'axis' => 'evidence_depth',
|
||||
'label' => 'Detailed evidence',
|
||||
'color' => 'success',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Full'],
|
||||
'notes' => 'Full structured evidence detail is available.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'evidence_depth',
|
||||
'label' => 'Mixed evidence detail',
|
||||
'color' => 'info',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Partial'],
|
||||
'notes' => 'Some items have full detail while others are metadata-only.',
|
||||
],
|
||||
'reference_only' => [
|
||||
'axis' => 'evidence_depth',
|
||||
'label' => 'Metadata only',
|
||||
'color' => 'info',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Reference only'],
|
||||
'notes' => 'Only reference metadata is available for this capture.',
|
||||
],
|
||||
'unsupported' => [
|
||||
'axis' => 'product_support_maturity',
|
||||
'label' => 'Support limited',
|
||||
'color' => 'gray',
|
||||
'classification' => 'diagnostic',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Unsupported'],
|
||||
'diagnostic_label' => 'Fallback renderer',
|
||||
'notes' => 'The renderer fell back to a lower-fidelity representation. This is diagnostic context, not a governance gap.',
|
||||
],
|
||||
],
|
||||
'baseline_snapshot_gap_status' => [
|
||||
'clear' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'No follow-up needed',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['No gaps'],
|
||||
'notes' => 'The captured group does not contain unresolved coverage gaps.',
|
||||
],
|
||||
'gaps_present' => [
|
||||
'axis' => 'data_coverage',
|
||||
'label' => 'Coverage gaps need review',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Gaps present'],
|
||||
'notes' => 'The captured group has unresolved gaps that should be reviewed.',
|
||||
],
|
||||
],
|
||||
'restore_run_status' => [
|
||||
'draft' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Draft',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Draft'],
|
||||
'notes' => 'The restore run has not been prepared yet.',
|
||||
],
|
||||
'scoped' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Scope selected',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Scoped'],
|
||||
'notes' => 'Items were selected for restore.',
|
||||
],
|
||||
'checked' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Checks complete',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Checked'],
|
||||
'notes' => 'Safety checks were completed for this run.',
|
||||
],
|
||||
'previewed' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Preview ready',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Previewed'],
|
||||
'notes' => 'A dry-run preview is available for review.',
|
||||
],
|
||||
'pending' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Pending execution',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Pending'],
|
||||
'notes' => 'Execution has not been queued yet.',
|
||||
],
|
||||
'queued' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Queued for execution',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Queued'],
|
||||
'notes' => 'Execution is queued and waiting for a worker.',
|
||||
],
|
||||
'running' => [
|
||||
'axis' => 'execution_lifecycle',
|
||||
'label' => 'Applying restore',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Running'],
|
||||
'notes' => 'Execution is currently applying restore work.',
|
||||
],
|
||||
'completed' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Applied successfully',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Completed'],
|
||||
'notes' => 'The restore run finished successfully.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Applied with follow-up',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Partial'],
|
||||
'notes' => 'The restore run finished but needs follow-up on a subset of items.',
|
||||
],
|
||||
'failed' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Restore failed',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Failed'],
|
||||
'notes' => 'The restore run did not complete successfully.',
|
||||
],
|
||||
'cancelled' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Cancelled',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Cancelled'],
|
||||
'notes' => 'Execution was intentionally cancelled.',
|
||||
],
|
||||
'aborted' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Stopped early',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Aborted'],
|
||||
'notes' => 'Execution stopped before the normal terminal path completed.',
|
||||
],
|
||||
'completed_with_errors' => [
|
||||
'axis' => 'execution_outcome',
|
||||
'label' => 'Applied with follow-up',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Completed with errors'],
|
||||
'notes' => 'Execution completed but still needs follow-up on failed items.',
|
||||
],
|
||||
],
|
||||
'restore_result_status' => [
|
||||
'applied' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Applied',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Applied'],
|
||||
'notes' => 'The item was applied successfully.',
|
||||
],
|
||||
'dry_run' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Preview only',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Dry run'],
|
||||
'notes' => 'The item was only simulated and not applied.',
|
||||
],
|
||||
'mapped' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Mapped to existing item',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Mapped'],
|
||||
'notes' => 'The source item mapped to an existing target.',
|
||||
],
|
||||
'skipped' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Not applied',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Skipped'],
|
||||
'notes' => 'The item was intentionally not applied.',
|
||||
],
|
||||
'partial' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Partially applied',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Partial'],
|
||||
'notes' => 'The item only applied in part and needs review.',
|
||||
],
|
||||
'manual_required' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Manual follow-up needed',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Manual required'],
|
||||
'notes' => 'The operator must handle this item manually.',
|
||||
],
|
||||
'failed' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Apply failed',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Failed'],
|
||||
'notes' => 'The item failed to apply.',
|
||||
],
|
||||
],
|
||||
'restore_preview_decision' => [
|
||||
'created' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Will create',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Created'],
|
||||
'notes' => 'The preview plans to create a new target item.',
|
||||
],
|
||||
'created_copy' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Will create copy',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Created copy'],
|
||||
'notes' => 'The preview plans to create a copy and should be reviewed before execution.',
|
||||
],
|
||||
'mapped_existing' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Will map existing',
|
||||
'color' => 'info',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Mapped existing'],
|
||||
'notes' => 'The preview plans to map this item to an existing target.',
|
||||
],
|
||||
'skipped' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Will skip',
|
||||
'color' => 'gray',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Skipped'],
|
||||
'notes' => 'The preview plans to skip this item.',
|
||||
],
|
||||
'failed' => [
|
||||
'axis' => 'item_result',
|
||||
'label' => 'Cannot apply',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Failed'],
|
||||
'notes' => 'The preview could not produce a viable action for this item.',
|
||||
],
|
||||
],
|
||||
'restore_check_severity' => [
|
||||
'blocking' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Fix before running',
|
||||
'color' => 'danger',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'required',
|
||||
'legacy_aliases' => ['Blocking'],
|
||||
'notes' => 'Execution should not proceed until this check is fixed.',
|
||||
],
|
||||
'warning' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Review before running',
|
||||
'color' => 'warning',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'optional',
|
||||
'legacy_aliases' => ['Warning'],
|
||||
'notes' => 'Execution may proceed, but the operator should review the warning first.',
|
||||
],
|
||||
'safe' => [
|
||||
'axis' => 'operator_actionability',
|
||||
'label' => 'Ready to continue',
|
||||
'color' => 'success',
|
||||
'classification' => 'primary',
|
||||
'next_action_policy' => 'none',
|
||||
'legacy_aliases' => ['Safe'],
|
||||
'notes' => 'No blocking issue was found for this check.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* axis: OperatorSemanticAxis,
|
||||
* label: string,
|
||||
* color: string,
|
||||
* classification: OperatorStateClassification,
|
||||
* next_action_policy: OperatorNextActionPolicy,
|
||||
* legacy_aliases: list<string>,
|
||||
* diagnostic_label: ?string,
|
||||
* notes: string
|
||||
* }|null
|
||||
*/
|
||||
public static function entry(BadgeDomain $domain, mixed $value): ?array
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
if ($state === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($domain === BadgeDomain::OperationRunOutcome && $state === 'operation.blocked') {
|
||||
$state = 'blocked';
|
||||
}
|
||||
|
||||
$entry = self::ENTRIES[$domain->value][$state] ?? null;
|
||||
|
||||
if (! is_array($entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'axis' => self::axisFrom($entry['axis']),
|
||||
'label' => $entry['label'],
|
||||
'color' => $entry['color'],
|
||||
'classification' => self::classificationFrom($entry['classification']),
|
||||
'next_action_policy' => self::nextActionPolicyFrom($entry['next_action_policy']),
|
||||
'legacy_aliases' => $entry['legacy_aliases'],
|
||||
'diagnostic_label' => $entry['diagnostic_label'] ?? null,
|
||||
'notes' => $entry['notes'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, array{
|
||||
* axis: OperatorSemanticAxis,
|
||||
* label: string,
|
||||
* color: string,
|
||||
* classification: OperatorStateClassification,
|
||||
* next_action_policy: OperatorNextActionPolicy,
|
||||
* legacy_aliases: list<string>,
|
||||
* diagnostic_label: ?string,
|
||||
* notes: string
|
||||
* }>>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach (self::ENTRIES as $domain => $mappings) {
|
||||
foreach ($mappings as $state => $entry) {
|
||||
$entries[$domain][$state] = [
|
||||
'axis' => self::axisFrom($entry['axis']),
|
||||
'label' => $entry['label'],
|
||||
'color' => $entry['color'],
|
||||
'classification' => self::classificationFrom($entry['classification']),
|
||||
'next_action_policy' => self::nextActionPolicyFrom($entry['next_action_policy']),
|
||||
'legacy_aliases' => $entry['legacy_aliases'],
|
||||
'diagnostic_label' => $entry['diagnostic_label'] ?? null,
|
||||
'notes' => $entry['notes'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{name: string, domain: BadgeDomain, raw_value: string}>
|
||||
*/
|
||||
public static function curatedExamples(): array
|
||||
{
|
||||
return [
|
||||
['name' => 'Operation blocked by missing prerequisite', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'blocked'],
|
||||
['name' => 'Operation completed with follow-up', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'partially_succeeded'],
|
||||
['name' => 'Operation completed successfully', 'domain' => BadgeDomain::OperationRunOutcome, 'raw_value' => 'succeeded'],
|
||||
['name' => 'Evidence not collected yet', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'missing'],
|
||||
['name' => 'Evidence refresh recommended', 'domain' => BadgeDomain::EvidenceCompleteness, 'raw_value' => 'stale'],
|
||||
['name' => 'Review input pending', 'domain' => BadgeDomain::TenantReviewCompleteness, 'raw_value' => 'missing'],
|
||||
['name' => 'Mixed evidence detail stays diagnostic', 'domain' => BadgeDomain::BaselineSnapshotFidelity, 'raw_value' => 'partial'],
|
||||
['name' => 'Support limited stays diagnostic', 'domain' => BadgeDomain::BaselineSnapshotFidelity, 'raw_value' => 'unsupported'],
|
||||
['name' => 'Coverage gaps need review', 'domain' => BadgeDomain::BaselineSnapshotGapStatus, 'raw_value' => 'gaps_present'],
|
||||
['name' => 'Restore preview blocked by a check', 'domain' => BadgeDomain::RestoreCheckSeverity, 'raw_value' => 'blocking'],
|
||||
['name' => 'Restore run applied with follow-up', 'domain' => BadgeDomain::RestoreRunStatus, 'raw_value' => 'completed_with_errors'],
|
||||
['name' => 'Restore item requires manual follow-up', 'domain' => BadgeDomain::RestoreResultStatus, 'raw_value' => 'manual_required'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function spec(
|
||||
BadgeDomain $domain,
|
||||
mixed $value,
|
||||
?string $icon = null,
|
||||
?string $iconColor = null,
|
||||
): ?BadgeSpec {
|
||||
$entry = self::entry($domain, $value);
|
||||
|
||||
if ($entry === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BadgeSpec(
|
||||
label: $entry['label'],
|
||||
color: $entry['color'],
|
||||
icon: $icon,
|
||||
iconColor: $iconColor,
|
||||
semanticAxis: $entry['axis'],
|
||||
classification: $entry['classification'],
|
||||
nextActionPolicy: $entry['next_action_policy'],
|
||||
diagnosticLabel: $entry['diagnostic_label'],
|
||||
legacyAliases: $entry['legacy_aliases'],
|
||||
notes: $entry['notes'],
|
||||
);
|
||||
}
|
||||
|
||||
private static function axisFrom(string $value): OperatorSemanticAxis
|
||||
{
|
||||
return OperatorSemanticAxis::tryFrom($value)
|
||||
?? throw new InvalidArgumentException("Unknown operator semantic axis [{$value}].");
|
||||
}
|
||||
|
||||
private static function classificationFrom(string $value): OperatorStateClassification
|
||||
{
|
||||
return OperatorStateClassification::tryFrom($value)
|
||||
?? throw new InvalidArgumentException("Unknown operator state classification [{$value}].");
|
||||
}
|
||||
|
||||
private static function nextActionPolicyFrom(string $value): OperatorNextActionPolicy
|
||||
{
|
||||
return OperatorNextActionPolicy::tryFrom($value)
|
||||
?? throw new InvalidArgumentException("Unknown operator next-action policy [{$value}].");
|
||||
}
|
||||
}
|
||||
51
app/Support/Badges/OperatorSemanticAxis.php
Normal file
51
app/Support/Badges/OperatorSemanticAxis.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
enum OperatorSemanticAxis: string
|
||||
{
|
||||
case ExecutionLifecycle = 'execution_lifecycle';
|
||||
case ExecutionOutcome = 'execution_outcome';
|
||||
case ItemResult = 'item_result';
|
||||
case DataCoverage = 'data_coverage';
|
||||
case EvidenceDepth = 'evidence_depth';
|
||||
case ProductSupportMaturity = 'product_support_maturity';
|
||||
case DataFreshness = 'data_freshness';
|
||||
case OperatorActionability = 'operator_actionability';
|
||||
case PublicationReadiness = 'publication_readiness';
|
||||
case GovernanceDeviation = 'governance_deviation';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ExecutionLifecycle => 'Execution lifecycle',
|
||||
self::ExecutionOutcome => 'Execution outcome',
|
||||
self::ItemResult => 'Item result',
|
||||
self::DataCoverage => 'Data coverage',
|
||||
self::EvidenceDepth => 'Evidence depth',
|
||||
self::ProductSupportMaturity => 'Product support maturity',
|
||||
self::DataFreshness => 'Data freshness',
|
||||
self::OperatorActionability => 'Operator actionability',
|
||||
self::PublicationReadiness => 'Publication readiness',
|
||||
self::GovernanceDeviation => 'Governance deviation',
|
||||
};
|
||||
}
|
||||
|
||||
public function definition(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ExecutionLifecycle => 'Where a run sits in its execution flow.',
|
||||
self::ExecutionOutcome => 'What happened when execution finished or stopped.',
|
||||
self::ItemResult => 'How one restore or preview item resolved.',
|
||||
self::DataCoverage => 'Whether the expected data or sections are present.',
|
||||
self::EvidenceDepth => 'How much structured evidence detail is available.',
|
||||
self::ProductSupportMaturity => 'Whether the product can represent the source faithfully.',
|
||||
self::DataFreshness => 'Whether the available data is still current enough to trust.',
|
||||
self::OperatorActionability => 'Whether an operator needs to do anything next.',
|
||||
self::PublicationReadiness => 'Whether the current record is ready for stakeholder delivery.',
|
||||
self::GovernanceDeviation => 'Whether the record represents a real governance problem.',
|
||||
};
|
||||
}
|
||||
}
|
||||
16
app/Support/Badges/OperatorStateClassification.php
Normal file
16
app/Support/Badges/OperatorStateClassification.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges;
|
||||
|
||||
enum OperatorStateClassification: string
|
||||
{
|
||||
case Primary = 'primary';
|
||||
case Diagnostic = 'diagnostic';
|
||||
|
||||
public function isDiagnostic(): bool
|
||||
{
|
||||
return $this === self::Diagnostic;
|
||||
}
|
||||
}
|
||||
@ -230,9 +230,9 @@ public static function platforms(?iterable $platforms = null): array
|
||||
public static function restoreRunOutcomes(): array
|
||||
{
|
||||
return [
|
||||
'succeeded' => 'Succeeded',
|
||||
'partial' => 'Partial',
|
||||
'failed' => 'Failed',
|
||||
'succeeded' => BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, RestoreRunStatus::Completed->value)->label,
|
||||
'partial' => BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, RestoreRunStatus::Partial->value)->label,
|
||||
'failed' => BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, RestoreRunStatus::Failed->value)->label,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -25,8 +25,8 @@ public static function queuedToast(string $operationType): FilamentNotification
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} queued")
|
||||
->body('Running in the background.')
|
||||
->warning()
|
||||
->body('Queued for execution. Open the run for progress and next steps.')
|
||||
->info()
|
||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ public static function alreadyQueuedToast(string $operationType): FilamentNotifi
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} already queued")
|
||||
->body('A matching run is already queued or running.')
|
||||
->body('A matching run is already queued or running. No action needed unless it stays stuck.')
|
||||
->info()
|
||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||
}
|
||||
@ -53,54 +53,33 @@ public static function alreadyQueuedToast(string $operationType): FilamentNotifi
|
||||
public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification
|
||||
{
|
||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||
$presentation = self::terminalPresentation($run);
|
||||
$bodyLines = [$presentation['body']];
|
||||
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
$failureMessage = self::surfaceFailureDetail($run);
|
||||
if ($failureMessage !== null) {
|
||||
$bodyLines[] = $failureMessage;
|
||||
}
|
||||
|
||||
$titleSuffix = match ($uxStatus) {
|
||||
'succeeded' => 'completed',
|
||||
'partial' => 'completed with warnings',
|
||||
'blocked' => 'blocked',
|
||||
default => 'failed',
|
||||
};
|
||||
|
||||
$body = match ($uxStatus) {
|
||||
'succeeded' => 'Completed successfully.',
|
||||
'partial' => 'Completed with warnings.',
|
||||
'blocked' => 'Execution was blocked.',
|
||||
default => 'Failed.',
|
||||
};
|
||||
|
||||
if (in_array($uxStatus, ['failed', 'blocked'], true)) {
|
||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||
|
||||
$failureMessage = self::sanitizeFailureMessage($failureMessage);
|
||||
|
||||
if ($failureMessage !== null) {
|
||||
$body = $body.' '.$failureMessage;
|
||||
}
|
||||
$guidance = self::surfaceGuidance($run);
|
||||
if ($guidance !== null) {
|
||||
$bodyLines[] = $guidance;
|
||||
}
|
||||
|
||||
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||
if ($summary !== null) {
|
||||
$body = $body."\n".$summary;
|
||||
$bodyLines[] = $summary;
|
||||
}
|
||||
|
||||
$integritySummary = RedactionIntegrity::noteForRun($run);
|
||||
if (is_string($integritySummary) && trim($integritySummary) !== '') {
|
||||
$body = $body."\n".trim($integritySummary);
|
||||
$bodyLines[] = trim($integritySummary);
|
||||
}
|
||||
|
||||
$status = match ($uxStatus) {
|
||||
'succeeded' => 'success',
|
||||
'partial' => 'warning',
|
||||
'blocked' => 'warning',
|
||||
default => 'danger',
|
||||
};
|
||||
|
||||
$notification = FilamentNotification::make()
|
||||
->title("{$operationLabel} {$titleSuffix}")
|
||||
->body($body)
|
||||
->status($status);
|
||||
->title("{$operationLabel} {$presentation['titleSuffix']}")
|
||||
->body(implode("\n", $bodyLines))
|
||||
->status($presentation['status']);
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$notification->actions([
|
||||
@ -113,6 +92,102 @@ public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $
|
||||
return $notification;
|
||||
}
|
||||
|
||||
public static function surfaceGuidance(OperationRun $run): ?string
|
||||
{
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
$nextStepLabel = self::firstNextStepLabel($run);
|
||||
|
||||
return match ($uxStatus) {
|
||||
'queued' => 'No action needed yet. The run is waiting for a worker.',
|
||||
'running' => 'No action needed yet. The run is currently in progress.',
|
||||
'succeeded' => 'No action needed.',
|
||||
'partial' => $nextStepLabel !== null
|
||||
? 'Next step: '.$nextStepLabel.'.'
|
||||
: (self::requiresFollowUp($run)
|
||||
? 'Review the affected items before rerunning.'
|
||||
: 'No action needed unless the recorded warnings were unexpected.'),
|
||||
'blocked' => $nextStepLabel !== null
|
||||
? 'Next step: '.$nextStepLabel.'.'
|
||||
: 'Review the blocked prerequisite before retrying.',
|
||||
default => $nextStepLabel !== null
|
||||
? 'Next step: '.$nextStepLabel.'.'
|
||||
: 'Review the run details before retrying.',
|
||||
};
|
||||
}
|
||||
|
||||
public static function surfaceFailureDetail(OperationRun $run): ?string
|
||||
{
|
||||
$failureMessage = (string) (($run->failure_summary[0]['message'] ?? '') ?? '');
|
||||
|
||||
return self::sanitizeFailureMessage($failureMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{titleSuffix: string, body: string, status: string}
|
||||
*/
|
||||
private static function terminalPresentation(OperationRun $run): array
|
||||
{
|
||||
$uxStatus = OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
|
||||
|
||||
return match ($uxStatus) {
|
||||
'succeeded' => [
|
||||
'titleSuffix' => 'completed successfully',
|
||||
'body' => 'Completed successfully.',
|
||||
'status' => 'success',
|
||||
],
|
||||
'partial' => [
|
||||
'titleSuffix' => self::requiresFollowUp($run) ? 'needs follow-up' : 'completed with review notes',
|
||||
'body' => 'Completed with follow-up.',
|
||||
'status' => 'warning',
|
||||
],
|
||||
'blocked' => [
|
||||
'titleSuffix' => 'blocked by prerequisite',
|
||||
'body' => 'Blocked by prerequisite.',
|
||||
'status' => 'warning',
|
||||
],
|
||||
default => [
|
||||
'titleSuffix' => 'execution failed',
|
||||
'body' => 'Execution failed.',
|
||||
'status' => 'danger',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static function requiresFollowUp(OperationRun $run): bool
|
||||
{
|
||||
if (self::firstNextStepLabel($run) !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$counts = SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||
|
||||
return (int) ($counts['failed'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
private static function firstNextStepLabel(OperationRun $run): ?string
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$nextSteps = $context['next_steps'] ?? null;
|
||||
|
||||
if (! is_array($nextSteps)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($nextSteps as $nextStep) {
|
||||
if (! is_array($nextStep)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = trim((string) ($nextStep['label'] ?? ''));
|
||||
|
||||
if ($label !== '') {
|
||||
return $label;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function sanitizeFailureMessage(string $failureMessage): ?string
|
||||
{
|
||||
$failureMessage = trim($failureMessage);
|
||||
|
||||
@ -69,7 +69,7 @@ public static function renderSummaryLine(array $summaryCounts): ?string
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = self::humanizeKey($key).': '.$value;
|
||||
$parts[] = self::label($key).': '.$value;
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
@ -82,11 +82,19 @@ public static function renderSummaryLine(array $summaryCounts): ?string
|
||||
/**
|
||||
* Convert a snake_case summary key to a human-readable label.
|
||||
*/
|
||||
private static function humanizeKey(string $key): string
|
||||
public static function label(string $key): string
|
||||
{
|
||||
return match ($key) {
|
||||
'total' => 'Total',
|
||||
'processed' => 'Processed',
|
||||
'succeeded' => 'Completed successfully',
|
||||
'failed' => 'Failed items',
|
||||
'skipped' => 'Skipped items',
|
||||
'items' => 'Affected items',
|
||||
'tenants' => 'Tenants',
|
||||
'created' => 'Created',
|
||||
'updated' => 'Updated',
|
||||
'deleted' => 'Deleted',
|
||||
'finding_count' => 'Findings',
|
||||
'report_count' => 'Reports',
|
||||
'operation_count' => 'Operations',
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
@ -61,6 +62,7 @@ public function __construct(
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* url: string
|
||||
* }>,
|
||||
@ -334,6 +336,7 @@ private function attentionItems(int $workspaceId, array $accessibleTenantIds, bo
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* url: string
|
||||
* }>
|
||||
@ -362,6 +365,7 @@ private function recentOperations(int $workspaceId, array $accessibleTenantIds):
|
||||
'status_color' => $statusColorSpec($run->status),
|
||||
'outcome_label' => $outcomeSpec($run->outcome),
|
||||
'outcome_color' => $outcomeColorSpec($run->outcome),
|
||||
'guidance' => OperationUxPresenter::surfaceGuidance($run),
|
||||
'started_at' => $run->created_at?->diffForHumans() ?? 'just now',
|
||||
'url' => route('admin.operations.view', ['run' => (int) $run->getKey()]),
|
||||
];
|
||||
|
||||
@ -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.10.0)
|
||||
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.12.0)
|
||||
|
||||
### Core Principles
|
||||
|
||||
@ -130,6 +130,7 @@ ### Core Principles
|
||||
4. **Deterministic Capabilities** — Backup/restore/risk flags derived from config via `CoverageCapabilitiesResolver`.
|
||||
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.
|
||||
|
||||
### RBAC-UX Rules
|
||||
|
||||
|
||||
155
docs/product/operator-semantic-taxonomy.md
Normal file
155
docs/product/operator-semantic-taxonomy.md
Normal file
@ -0,0 +1,155 @@
|
||||
# Operator Semantic Taxonomy
|
||||
|
||||
> Canonical operator-facing state reference for the first implementation slice.
|
||||
> Downstream specs and badge mappings must reuse this vocabulary instead of inventing local synonyms.
|
||||
|
||||
**Last reviewed**: 2026-03-21
|
||||
|
||||
---
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. One primary label means one thing everywhere it appears.
|
||||
2. Semantic axes stay separate. Execution outcome, coverage, freshness, evidence depth, and actionability are not interchangeable.
|
||||
3. Diagnostic-only states never use `warning` or `danger`.
|
||||
4. Primary `warning` and `danger` states must carry an operator next-action policy.
|
||||
5. Valid-empty and support-limit states are not failures unless a separate governance or execution rule says otherwise.
|
||||
|
||||
## Semantic Axes
|
||||
|
||||
| Axis | Question it answers |
|
||||
|---|---|
|
||||
| `execution_lifecycle` | Where a run sits in its execution flow |
|
||||
| `execution_outcome` | What happened when execution finished or stopped |
|
||||
| `item_result` | How one restore or preview item resolved |
|
||||
| `data_coverage` | Whether the expected data or sections are present |
|
||||
| `evidence_depth` | How much structured evidence detail is available |
|
||||
| `product_support_maturity` | Whether TenantPilot can represent the source faithfully |
|
||||
| `data_freshness` | Whether the available data is still current enough to trust |
|
||||
| `operator_actionability` | Whether an operator needs to do anything next |
|
||||
| `publication_readiness` | Whether the record is ready for stakeholder delivery |
|
||||
| `governance_deviation` | Whether the record represents a real governance problem |
|
||||
|
||||
## Severity Rules
|
||||
|
||||
| Band | Meaning |
|
||||
|---|---|
|
||||
| `danger` | Execution failure, material risk, or a stop-before-proceed condition |
|
||||
| `warning` | Operator attention or follow-up is recommended or required |
|
||||
| `info` | In-progress or context-heavy states that are not failures by themselves |
|
||||
| `success` | Intended outcome achieved with no further operator action required |
|
||||
| `gray` | Neutral, diagnostic, archived, not applicable, or intentionally non-actionable context |
|
||||
| `primary` | Reserved for badge-system compatibility only; avoid for first-slice operator states |
|
||||
|
||||
## Presentation Levels
|
||||
|
||||
| Level | Use |
|
||||
|---|---|
|
||||
| `primary` | The first label an operator uses to decide what happened and whether action is needed |
|
||||
| `diagnostic` | Secondary detail that preserves technical truth without replacing the primary meaning |
|
||||
|
||||
## Next-Action Policy
|
||||
|
||||
| Policy | Meaning |
|
||||
|---|---|
|
||||
| `required` | Operator must act before retrying or relying on the result |
|
||||
| `optional` | Review is recommended, but the state is not a hard stop |
|
||||
| `none` | No operator action is needed |
|
||||
|
||||
## Migration Guidance
|
||||
|
||||
Do not ship bare first-slice labels such as `Blocked`, `Partial`, `Missing`, `Unsupported`, `Stale`, `Warning`, or `Safe`.
|
||||
|
||||
Use these qualified replacements instead:
|
||||
|
||||
| Legacy term | Adopted replacement |
|
||||
|---|---|
|
||||
| `Blocked` | `Blocked by prerequisite` or `Fix before running` |
|
||||
| `Partial` | `Completed with follow-up`, `Coverage incomplete`, `Partially applied`, or `Mixed evidence detail` |
|
||||
| `Missing` | `Not collected yet` or `Review input pending` |
|
||||
| `Stale` | `Refresh recommended` or `Refresh review inputs` |
|
||||
| `Unsupported` | `Support limited` |
|
||||
| `Warning` | `Review before running` |
|
||||
| `Safe` | `Ready to continue` |
|
||||
|
||||
## First-Slice Canonical Mappings
|
||||
|
||||
### Operations
|
||||
|
||||
| Raw state | Primary label | Axis | Band | Next action |
|
||||
|---|---|---|---|---|
|
||||
| `queued` | `Queued for execution` | `execution_lifecycle` | `info` | `none` |
|
||||
| `running` | `In progress` | `execution_lifecycle` | `info` | `none` |
|
||||
| `succeeded` | `Completed successfully` | `execution_outcome` | `success` | `none` |
|
||||
| `partially_succeeded` | `Completed with follow-up` | `execution_outcome` | `warning` | `optional` |
|
||||
| `blocked` | `Blocked by prerequisite` | `execution_outcome` | `warning` | `required` |
|
||||
| `failed` | `Execution failed` | `execution_outcome` | `danger` | `required` |
|
||||
|
||||
### Evidence And Review Completeness
|
||||
|
||||
| Domain | Raw state | Primary label | Axis | Band | Next action |
|
||||
|---|---|---|---|---|---|
|
||||
| Evidence | `complete` | `Coverage ready` | `data_coverage` | `success` | `none` |
|
||||
| Evidence | `partial` | `Coverage incomplete` | `data_coverage` | `warning` | `required` |
|
||||
| Evidence | `missing` | `Not collected yet` | `data_coverage` | `info` | `optional` |
|
||||
| Evidence | `stale` | `Refresh recommended` | `data_freshness` | `warning` | `optional` |
|
||||
| Review | `complete` | `Review inputs ready` | `data_coverage` | `success` | `none` |
|
||||
| Review | `partial` | `Review inputs incomplete` | `data_coverage` | `warning` | `required` |
|
||||
| Review | `missing` | `Review input pending` | `data_coverage` | `info` | `optional` |
|
||||
| Review | `stale` | `Refresh review inputs` | `data_freshness` | `warning` | `optional` |
|
||||
|
||||
### Baseline Semantics
|
||||
|
||||
| Domain | Raw state | Primary label | Level | Axis | Band |
|
||||
|---|---|---|---|---|---|
|
||||
| Fidelity | `full` | `Detailed evidence` | `diagnostic` | `evidence_depth` | `success` |
|
||||
| Fidelity | `partial` | `Mixed evidence detail` | `diagnostic` | `evidence_depth` | `info` |
|
||||
| Fidelity | `reference_only` | `Metadata only` | `diagnostic` | `evidence_depth` | `info` |
|
||||
| Fidelity | `unsupported` | `Support limited` | `diagnostic` | `product_support_maturity` | `gray` |
|
||||
| Gap status | `clear` | `No follow-up needed` | `primary` | `data_coverage` | `success` |
|
||||
| Gap status | `gaps_present` | `Coverage gaps need review` | `primary` | `data_coverage` | `warning` |
|
||||
|
||||
`meta_fallback` remains diagnostic context. It does not count as a primary coverage gap by itself.
|
||||
|
||||
### Restore Semantics
|
||||
|
||||
| Domain | Raw state | Primary label | Axis | Band | Next action |
|
||||
|---|---|---|---|---|---|
|
||||
| Run | `completed` | `Applied successfully` | `execution_outcome` | `success` | `none` |
|
||||
| Run | `partial` | `Applied with follow-up` | `execution_outcome` | `warning` | `optional` |
|
||||
| Run | `completed_with_errors` | `Applied with follow-up` | `execution_outcome` | `warning` | `optional` |
|
||||
| Run | `failed` | `Restore failed` | `execution_outcome` | `danger` | `required` |
|
||||
| Run | `aborted` | `Stopped early` | `execution_outcome` | `gray` | `none` |
|
||||
| Result | `manual_required` | `Manual follow-up needed` | `operator_actionability` | `warning` | `required` |
|
||||
| Result | `mapped` | `Mapped to existing item` | `item_result` | `info` | `none` |
|
||||
| Result | `skipped` | `Not applied` | `item_result` | `gray` | `optional` |
|
||||
| Preview | `created` | `Will create` | `item_result` | `success` | `none` |
|
||||
| Preview | `created_copy` | `Will create copy` | `item_result` | `warning` | `optional` |
|
||||
| Preview | `mapped_existing` | `Will map existing` | `item_result` | `info` | `none` |
|
||||
| Preview | `skipped` | `Will skip` | `item_result` | `gray` | `optional` |
|
||||
| Check | `blocking` | `Fix before running` | `operator_actionability` | `danger` | `required` |
|
||||
| Check | `warning` | `Review before running` | `operator_actionability` | `warning` | `optional` |
|
||||
| Check | `safe` | `Ready to continue` | `operator_actionability` | `success` | `none` |
|
||||
|
||||
## Curated Examples
|
||||
|
||||
The first-slice guard rubric scores these twelve examples:
|
||||
|
||||
1. Operation blocked by missing prerequisite
|
||||
2. Operation completed with follow-up
|
||||
3. Operation completed successfully
|
||||
4. Evidence not collected yet
|
||||
5. Evidence refresh recommended
|
||||
6. Review input pending
|
||||
7. Mixed evidence detail stays diagnostic
|
||||
8. Support limited stays diagnostic
|
||||
9. Coverage gaps need review
|
||||
10. Restore preview blocked by a check
|
||||
11. Restore run applied with follow-up
|
||||
12. Restore item requires manual follow-up
|
||||
|
||||
## Cross-View Safety Rules
|
||||
|
||||
- First-slice resources that do not expose an edit or view lifecycle through global search stay out of global search.
|
||||
- Taxonomy-backed filter labels, badge counts, and summaries must be derived from already-authorized workspace and tenant scope only.
|
||||
- Canonical views may reuse the same vocabulary as tenant-context views, but they must not reveal hidden tenant names, counts, or filter hints.
|
||||
@ -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-08
|
||||
**Last reviewed**: 2026-03-21
|
||||
|
||||
---
|
||||
|
||||
@ -97,6 +97,18 @@ ### Canonical navigation and terminology
|
||||
Consistent naming, consistent routing, consistent mental model.
|
||||
No competing terms for the same concept.
|
||||
|
||||
### Operator-first surfaces
|
||||
`/admin` defaults are for operators, not raw implementation visibility.
|
||||
Primary content uses operator language, explicit scope, actionable status, and progressive disclosure for diagnostics.
|
||||
|
||||
### Distinct status and mutation semantics
|
||||
Execution outcome, data completeness, governance result, and lifecycle/readiness stay separate when they all exist.
|
||||
Every state-changing action tells the operator whether it affects TenantPilot only, the Microsoft tenant, or simulation only before execution.
|
||||
|
||||
### Page contract requirement
|
||||
Every new or materially refactored operator-facing page defines its persona, surface type, primary operator question,
|
||||
default-visible information, diagnostics-only information, status dimensions, mutation scope, primary actions, and dangerous actions.
|
||||
|
||||
---
|
||||
|
||||
## Process
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
|
||||
|
||||
**Last reviewed**: 2026-03-21 (operator semantic taxonomy, semantic-clarity domain follow-ups, and absorbed extension targets updated)
|
||||
**Last reviewed**: 2026-03-21 (operator semantic taxonomy, semantic-clarity domain follow-ups, OperationRun humanization candidate, and absorbed extension targets updated)
|
||||
|
||||
---
|
||||
|
||||
@ -494,6 +494,57 @@ ### Operations Naming Harmonization Across Run Types, Catalog, UI, and Audit
|
||||
- Concrete desired outcome without overdesigning the solution
|
||||
- Easy to promote into a full spec once operations-domain work is prioritized
|
||||
|
||||
### OperationRun Humanization & Diagnostics Boundary
|
||||
- **Type**: hardening
|
||||
- **Source**: operator surface standards adoption v1, operations UX consistency review, cross-link monitoring/alerts/dashboard review
|
||||
- **Problem**: `OperationRun` is one of TenantPilot's highest-leverage operator surfaces, but its current UX risks exposing implementation semantics instead of operator-facing clarity. The default surface can blur execution outcome, blocked reason, technical diagnostics, object and scope identity, and next-action guidance into one undifferentiated detail stack. Because `OperationRun` is the destination for monitoring links, alert deep links, dashboard drill-ins, and troubleshooting flows, weak clarity here propagates system-wide. The current risk pattern includes raw reason codes, internal IDs and hashes, raw payload context, ambiguous state labels, and insufficient separation between operator summary and diagnostic truth.
|
||||
- **Why it matters**: This is the best pilot surface for the operator UX standard because it is highly visible, cross-cutting, and already carries the product's richest execution truth. If the default run detail page does not quickly answer "What happened?", "Did it succeed, fail, or get blocked?", "What scope did it affect?", "Is action required?", and "What should I do next?", then monitoring, alerting, dashboards, and support flows all inherit the same ambiguity. A low-clarity run surface reduces trust far beyond the operations page itself.
|
||||
- **Desired outcome**: The default `OperationRun` list and detail experience should be operator-first: identity, status, scope, interpreted summary, and next action lead; diagnostics remain available but clearly secondary. Senior engineers must still be able to reach raw reason codes, IDs, payload context, and technical details without those details polluting the primary operator surface.
|
||||
- **Proposed direction**:
|
||||
- **Canonical detail-page hierarchy**: make run identity, status, top-level summary, explicit scope, and primary actions the first content block on the detail page. Raw JSON, hashes, internal IDs, and technical payload fragments move below a clearly labeled diagnostics boundary.
|
||||
- **Operator-first state interpretation**: translate blocked and failed states into human-readable labels and summaries. Distinguish execution outcome from completeness/downstream effect where relevant so operators can tell the difference between "execution succeeded" and "the intended effect is incomplete or limited."
|
||||
- **Diagnostics boundary pattern**: establish a reusable convention where diagnostic truth is available through progressive disclosure, not mixed into the primary reading path. The boundary should explicitly contain raw reason code, raw identifiers, payload context, low-level exception detail, and similar engineering-facing artifacts.
|
||||
- **Next-step guidance contract**: whenever a run is blocked or failed, the primary surface must show what the operator should do next. Next-step content should be visible without requiring expansion into raw diagnostics.
|
||||
- **Explicit scope communication**: run details and list rows should make the affected object, domain, and workspace/tenant context obvious so an operator can immediately understand impact and blast radius.
|
||||
- **Action hierarchy on run details**: establish a safe, obvious action order for follow-up paths such as retry, inspect source object, view related monitoring context, or open diagnostics. Diagnostic actions must not visually compete with the primary operator path.
|
||||
- **Reference implementation role**: use `OperationRun` as the reference implementation for future surfaces that need the same pattern: operator-first summary plus secondary diagnostics.
|
||||
- **In scope**:
|
||||
- `OperationRun` list and detail surfaces
|
||||
- operator-facing labels and summaries
|
||||
- blocked / failed state translation on these surfaces
|
||||
- diagnostics boundary and progressive disclosure pattern
|
||||
- top-level summary cards / interpreted content
|
||||
- explicit scope communication and workspace / tenant context
|
||||
- action hierarchy on run details
|
||||
- mapping technical reasons into operator-facing blocked reason and next-step guidance
|
||||
- monitoring / alert / dashboard deep-link landing clarity insofar as those links land on `OperationRun`
|
||||
- **Out of scope**:
|
||||
- broad redesign of the entire admin UI
|
||||
- full monitoring information-architecture rewrite
|
||||
- full alerts redesign
|
||||
- product-wide adoption of the standard in one pass
|
||||
- backend execution model changes that are not needed for `OperationRun` operator-surface clarity
|
||||
- broad operation naming refactors beyond what this surface strictly needs to be understandable
|
||||
- **Acceptance characteristics**:
|
||||
- default-visible labels do not expose raw internal field names
|
||||
- blocked reasons are human-readable
|
||||
- next steps are visible when the run is blocked or failed
|
||||
- diagnostics are present but clearly secondary
|
||||
- page hierarchy starts with identity, status, summary, and actions
|
||||
- raw JSON, internal hashes, and internal IDs are not primary content
|
||||
- monitoring and alert deep links land on a page that is understandable to operators without diagnostic context
|
||||
- **Why now vs later**:
|
||||
- **Why now**: high leverage; immediate trust improvement for monitoring and alert flows; first concrete adoption of the operator UX standard; creates a reusable pattern for future detail surfaces
|
||||
- **Why not later**: unresolved ambiguity on run surfaces keeps leaking into dashboards, alerts, and troubleshooting entry points; later surfaces will otherwise copy the current weak pattern
|
||||
- **Cross-cutting impact**: monitoring, alerts, dashboards, diagnostics conventions, status-taxonomy adoption, future detail-page standards, and operator-language normalization
|
||||
- **Likely follow-on candidates**: `PolicyResource` operator language and metadata isolation, baseline compare / snapshot completeness clarity, restore-run language and safe-execution standard alignment, canonical degraded / prerequisite state pattern across surfaces
|
||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary), Operator Reason Code Translation and Humanization Contract (blocked/failure labels and next steps), canonical `OperationRun` context work already explored in Specs 054, 114, and 144
|
||||
- **Boundary with Operations Naming Harmonization**: Naming harmonization owns the cross-product vocabulary for operation types, labels, and catalog mappings. This candidate owns the operator readability and diagnostics separation of the `OperationRun` surface itself. Naming alignment may be consumed here, but this candidate should not expand into a repo-wide naming refactor.
|
||||
- **Boundary with Operator Presentation & Lifecycle Action Hardening**: Presentation hardening owns shared rendering and action-visibility conventions across many surfaces. This candidate is narrower and deeper: it defines the canonical operator-versus-diagnostics hierarchy on the highest-value run detail surface and uses it as the reference implementation for future adoption.
|
||||
- **Boundary with Operator Reason Code Translation**: Reason code translation defines how internal codes become human-readable labels and next-step envelopes. This candidate consumes that translation on the `OperationRun` surface and defines where translated content stops and raw diagnostics begin.
|
||||
- **Strategic sequencing**: Best tackled after the outcome taxonomy and in parallel with or immediately after reason-code translation. It is a strong pilot implementation candidate before broader operator-surface rollout because it validates the standards on the most cross-linked execution surface first.
|
||||
- **Priority**: high
|
||||
|
||||
### Operator Presentation & Lifecycle Action Hardening
|
||||
- **Type**: hardening
|
||||
- **Source**: Evidence Snapshot / Ops-UX review 2026-03-19
|
||||
|
||||
@ -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-09
|
||||
**Last reviewed**: 2026-03-21
|
||||
|
||||
---
|
||||
|
||||
@ -42,7 +42,7 @@ ## Related Docs
|
||||
|
||||
| Document | Location | Purpose |
|
||||
|---|---|---|
|
||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (UX-001, Action Surface Contract, RBAC-UX) |
|
||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (OPSURF-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) |
|
||||
|
||||
521
docs/ui/operator-ux-surface-standards.md
Normal file
521
docs/ui/operator-ux-surface-standards.md
Normal file
@ -0,0 +1,521 @@
|
||||
# Operator UX & Surface Standards
|
||||
|
||||
This document defines the binding audience-and-surface contract for TenantPilot.
|
||||
|
||||
It establishes:
|
||||
|
||||
- who each product surface is primarily built for
|
||||
- what type of language is allowed on that surface
|
||||
- how technical detail must be progressively disclosed
|
||||
- how operator-facing pages must communicate status, scope, and actions
|
||||
|
||||
This document is normative for new operator-facing UI work and for major UI refactors.
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
TenantPilot is not a generic admin UI. It is an enterprise operator product for managed Microsoft tenant governance, backup, restore, monitoring, drift detection, and review workflows.
|
||||
|
||||
The product must not expose internal implementation structure as if it were product UX.
|
||||
|
||||
The default experience must be optimized for the primary working audience.
|
||||
Technical truth may remain available, but it must be progressively disclosed.
|
||||
|
||||
## 2. Product Audience Model
|
||||
|
||||
TenantPilot serves four distinct audiences. Every page must have exactly one primary audience.
|
||||
|
||||
### 2.1 Primary Persona: Operator / Engineer
|
||||
|
||||
Focus:
|
||||
|
||||
- day-to-day execution
|
||||
- monitoring
|
||||
- triage
|
||||
- safe action-taking
|
||||
|
||||
Typical tasks:
|
||||
|
||||
- onboarding tenants
|
||||
- configuring providers
|
||||
- running syncs
|
||||
- reviewing inventory state
|
||||
- running backup / restore workflows
|
||||
- baseline capture / compare
|
||||
- findings triage
|
||||
- exceptions / risk acceptance
|
||||
- operations monitoring
|
||||
|
||||
UI requirements:
|
||||
|
||||
- clear operator language
|
||||
- strong scanability
|
||||
- explicit scope
|
||||
- safe defaults
|
||||
- actionable next steps
|
||||
- minimal ambiguity
|
||||
- no unnecessary internal field leakage
|
||||
|
||||
### 2.2 Secondary Persona: Senior Engineer / Troubleshooter
|
||||
|
||||
Focus:
|
||||
|
||||
- diagnosis
|
||||
- validation
|
||||
- escalation handling
|
||||
- deep technical analysis
|
||||
|
||||
Typical needs:
|
||||
|
||||
- raw payloads
|
||||
- provider error details
|
||||
- UUIDs / external IDs
|
||||
- technical context
|
||||
- low-level diagnostic traces
|
||||
|
||||
UI requirements:
|
||||
|
||||
- access to raw truth
|
||||
- no removal of diagnostic power
|
||||
- diagnostics available on-demand, never as default-visible content
|
||||
|
||||
### 2.3 Tertiary Persona: Service Manager / CISO / Customer Reviewer
|
||||
|
||||
Focus:
|
||||
|
||||
- proof
|
||||
- reporting
|
||||
- trends
|
||||
- governance posture
|
||||
- SLA / audit communication
|
||||
|
||||
Typical needs:
|
||||
|
||||
- dashboards
|
||||
- summaries
|
||||
- evidence-backed reporting
|
||||
- accepted risks
|
||||
- review readiness
|
||||
- exported review packs
|
||||
|
||||
UI requirements:
|
||||
|
||||
- aggregated, outcome-oriented surfaces
|
||||
- low operational noise
|
||||
- no dependence on operator-level detail pages
|
||||
|
||||
### 2.4 Platform Persona: Platform Admin / System Operator
|
||||
|
||||
Focus:
|
||||
|
||||
- platform administration
|
||||
- internal platform management
|
||||
- break-glass operations
|
||||
- platform-wide controls
|
||||
|
||||
Typical surface:
|
||||
|
||||
- `/system`
|
||||
|
||||
UI requirements:
|
||||
|
||||
- technically capable
|
||||
- administratively precise
|
||||
- still intentionally designed
|
||||
- not a dumping ground for unresolved `/admin` UX
|
||||
|
||||
## 3. Surface Ownership by Persona
|
||||
|
||||
### 3.1 `/admin` operator surfaces
|
||||
|
||||
`/admin` is operator-first.
|
||||
|
||||
This includes:
|
||||
|
||||
- onboarding
|
||||
- integrations used for tenant operations
|
||||
- tenant management
|
||||
- inventory
|
||||
- policy views
|
||||
- baselines
|
||||
- findings
|
||||
- evidence
|
||||
- monitoring / operations
|
||||
- backup / restore
|
||||
- governance actions
|
||||
|
||||
Default-visible UX on these surfaces must be optimized for the Operator / Engineer persona.
|
||||
|
||||
### 3.2 `/admin` diagnostics surfaces
|
||||
|
||||
Diagnostics within `/admin` belong to the Senior Engineer / Troubleshooter persona.
|
||||
|
||||
They are:
|
||||
|
||||
- available where appropriate
|
||||
- secondary
|
||||
- collapsed, tabbed, or otherwise intentionally disclosed
|
||||
- never the primary content hierarchy
|
||||
|
||||
### 3.3 Reporting / review surfaces
|
||||
|
||||
Reports, review packs, customer read-only views, and similar proof-oriented surfaces are optimized for the Service Manager / CISO / Customer Reviewer persona.
|
||||
|
||||
These surfaces prioritize:
|
||||
|
||||
- summary
|
||||
- evidence-backed conclusions
|
||||
- trends
|
||||
- risk posture
|
||||
- review readiness
|
||||
|
||||
### 3.4 `/system` platform surfaces
|
||||
|
||||
`/system` is reserved for platform-only administration.
|
||||
|
||||
Rules:
|
||||
|
||||
- `/system` may expose more technical administrative detail than `/admin`
|
||||
- `/system` must not be used to avoid fixing weak operator UX in `/admin`
|
||||
- product operator workflows belong in `/admin`, not `/system`
|
||||
|
||||
## 4. Default vs Diagnostics Rule
|
||||
|
||||
### Gold rule
|
||||
|
||||
**Default = Operator Language**
|
||||
**Expanded = Diagnostic Truth**
|
||||
|
||||
### 4.1 Default-visible content must avoid:
|
||||
|
||||
- raw JSON blobs
|
||||
- raw internal field names
|
||||
- raw DB timestamps without product meaning
|
||||
- raw enum / reason codes
|
||||
- raw provider error bodies unless translated
|
||||
- UUIDs or IDs as primary visual content
|
||||
- implementation-specific field semantics
|
||||
|
||||
### 4.2 Diagnostics content may include:
|
||||
|
||||
- raw JSON
|
||||
- provider error details
|
||||
- raw IDs / UUIDs
|
||||
- internal field values
|
||||
- stacktrace-adjacent diagnostic summaries
|
||||
- payload sizes
|
||||
- created_at / updated_at / internal audit metadata where relevant
|
||||
|
||||
### 4.3 Diagnostics placement
|
||||
|
||||
Diagnostics must be secondary and intentionally placed, for example:
|
||||
|
||||
- collapsed diagnostics section
|
||||
- dedicated diagnostics tab
|
||||
- advanced technical panel
|
||||
- expandable system detail area
|
||||
|
||||
Diagnostics must not dominate the primary page hierarchy.
|
||||
|
||||
## 5. Operator-Language Glossary
|
||||
|
||||
Internal or technical terminology must not appear in operator-facing default surfaces without translation.
|
||||
|
||||
| Internal / Technical Term | Operator-Facing UI Term | Notes |
|
||||
|---|---|---|
|
||||
| `dry_run`, `is_dry_run` | Simulation Mode / Simulate Only | Indicates no live Microsoft mutation |
|
||||
| `metadata_only` | Incomplete Capture / Configuration Unavailable | Use based on context |
|
||||
| `reason_code` | Blocked Reason / Failure Reason | Humanized mapping required |
|
||||
| `provider_consent_missing` | Missing Admin Consent | Example translated reason |
|
||||
| `scope_jsonb.policy_types` | Included Policy Types | Never expose DB path syntax |
|
||||
| `scope_jsonb.foundation_types` | Included Foundation Types | Never expose DB path syntax |
|
||||
| `evidenceGaps` | Missing Inventory Data / Incomplete Sync Data | Use operator meaning |
|
||||
| `fidelity` | Match Confidence | Only show if actionable |
|
||||
| `role_definition_id` | Assigned Role | Prefer display name over raw ID |
|
||||
|
||||
This glossary should expand over time and be treated as a product vocabulary source of truth.
|
||||
|
||||
## 6. Status Taxonomy
|
||||
|
||||
UI surfaces must not collapse all state into one ambiguous status.
|
||||
|
||||
TenantPilot must distinguish at least four status dimensions where relevant.
|
||||
|
||||
### 6.1 Execution Outcome
|
||||
|
||||
Question:
|
||||
|
||||
- Did the operation execute successfully?
|
||||
|
||||
Examples:
|
||||
|
||||
- Queued
|
||||
- Running
|
||||
- Succeeded
|
||||
- Blocked
|
||||
- Failed
|
||||
- Cancelled
|
||||
|
||||
### 6.2 Data Completeness
|
||||
|
||||
Question:
|
||||
|
||||
- Do we have the full data required for reliable interpretation?
|
||||
|
||||
Examples:
|
||||
|
||||
- Complete
|
||||
- Incomplete
|
||||
- Stale
|
||||
- Unavailable
|
||||
|
||||
### 6.3 Governance Result
|
||||
|
||||
Question:
|
||||
|
||||
- Is the tenant or object aligned with expected governance state?
|
||||
|
||||
Examples:
|
||||
|
||||
- Aligned
|
||||
- Drift Detected
|
||||
- Exception Applied
|
||||
- Risk Accepted
|
||||
- Review Needed
|
||||
|
||||
### 6.4 Lifecycle / Readiness State
|
||||
|
||||
Question:
|
||||
|
||||
- What lifecycle or publication state is the object in?
|
||||
|
||||
Examples:
|
||||
|
||||
- Draft
|
||||
- Active
|
||||
- Archived
|
||||
- Review Ready
|
||||
- Published
|
||||
- Expired
|
||||
|
||||
These dimensions must remain semantically distinct in labels, badges, summaries, and workflows.
|
||||
|
||||
## 7. Mutation Scope Rule
|
||||
|
||||
Every action that changes state must communicate its mutation scope before execution.
|
||||
|
||||
Allowed mutation-scope categories:
|
||||
|
||||
- **TenantPilot only**
|
||||
- **Microsoft tenant**
|
||||
- **Simulation only**
|
||||
|
||||
Examples:
|
||||
|
||||
- Archive local record -> TenantPilot only
|
||||
- Restore configuration to Intune -> Microsoft tenant
|
||||
- Dry-run / simulation -> Simulation only
|
||||
|
||||
This must be understandable from the action label, preview, confirmation step, or surrounding UI copy.
|
||||
|
||||
## 8. Canonical Detail-Page Layout
|
||||
|
||||
Every operator-facing detail page should follow this hierarchy unless there is a strong documented reason not to.
|
||||
|
||||
### 8.1 Header
|
||||
|
||||
Must show:
|
||||
|
||||
- object identity
|
||||
- subtle identifier if needed
|
||||
- 1-2 primary status indicators
|
||||
|
||||
### 8.2 Action Bar
|
||||
|
||||
Rules:
|
||||
|
||||
- maximum 2 primary actions
|
||||
- destructive or uncommon actions belong in a secondary group
|
||||
- action labels must reflect real outcome and mutation scope
|
||||
|
||||
### 8.3 Summary / Health
|
||||
|
||||
Top-of-page summary should answer the primary operator question quickly.
|
||||
|
||||
Typical examples:
|
||||
|
||||
- last sync / last run
|
||||
- completeness state
|
||||
- governance state
|
||||
- current baseline / current assignment
|
||||
- current blocked reason
|
||||
- recommended next step
|
||||
|
||||
### 8.4 Primary Content
|
||||
|
||||
This is the main working surface:
|
||||
|
||||
- related records
|
||||
- findings
|
||||
- relations
|
||||
- scoped data
|
||||
- actionable tables
|
||||
- interpreted content
|
||||
|
||||
### 8.5 Diagnostics
|
||||
|
||||
Diagnostics belong at the bottom or in a secondary surface:
|
||||
|
||||
- raw JSON
|
||||
- raw IDs
|
||||
- payloads
|
||||
- internal timestamps
|
||||
- provider response detail
|
||||
|
||||
## 9. Safe Execution Standard
|
||||
|
||||
Actions with meaningful blast radius must follow a consistent pattern.
|
||||
|
||||
### Required sequence
|
||||
|
||||
1. Configuration
|
||||
2. Safety checks / simulation
|
||||
3. Preview
|
||||
4. Hard confirmation where required
|
||||
5. Execute
|
||||
|
||||
### Applies to:
|
||||
|
||||
- restore
|
||||
- baseline enforce
|
||||
- high-impact bulk actions
|
||||
- delete actions with tenant impact
|
||||
- tenant-wide mutation flows
|
||||
|
||||
### Rules
|
||||
|
||||
- no dangerous one-click actions for high-blast-radius operations
|
||||
- confirmation language must match actual action semantics
|
||||
- preview must clearly indicate mutation scope
|
||||
|
||||
## 10. Workspace vs Tenant Context Rules
|
||||
|
||||
Operators must never have to infer scope from memory.
|
||||
|
||||
### 10.1 Tenant-scoped surfaces
|
||||
|
||||
If the operator is inside a tenant surface:
|
||||
|
||||
- actions must be tenant-safe
|
||||
- workspace-wide actions must not silently appear
|
||||
- `show all tenants` or similar context escapes must not behave like normal in-flow actions
|
||||
|
||||
### 10.2 Workspace-scoped surfaces
|
||||
|
||||
Workspace surfaces may aggregate:
|
||||
|
||||
- dashboards
|
||||
- review views
|
||||
- multi-tenant monitoring
|
||||
- fleet-level rollouts
|
||||
- cross-tenant summaries
|
||||
|
||||
But they must still communicate that they are workspace-level, not tenant-level.
|
||||
|
||||
### 10.3 Context clarity requirements
|
||||
|
||||
Every page must make clear:
|
||||
|
||||
- current scope
|
||||
- object scope
|
||||
- whether actions are local or cross-scope
|
||||
- whether current data is tenant-specific or workspace-aggregated
|
||||
|
||||
## 11. Page Contract Requirement
|
||||
|
||||
Every new or materially refactored page must define the following before implementation:
|
||||
|
||||
- primary persona
|
||||
- surface type
|
||||
- primary operator question
|
||||
- default-visible information
|
||||
- diagnostics-only information
|
||||
- status dimensions used
|
||||
- mutation scope
|
||||
- primary actions
|
||||
- dangerous actions
|
||||
|
||||
A page that cannot answer these questions is not ready for implementation.
|
||||
|
||||
## 12. Surface Types
|
||||
|
||||
Recommended surface categories:
|
||||
|
||||
- operator execution surface
|
||||
- operator monitoring surface
|
||||
- diagnostic detail surface
|
||||
- reporting / review surface
|
||||
- customer read-only surface
|
||||
- platform admin surface
|
||||
|
||||
These categories help determine hierarchy, language, action model, and diagnostics visibility.
|
||||
|
||||
## 13. What Good Looks Like
|
||||
|
||||
A strong operator-facing page lets the primary persona understand within a few seconds:
|
||||
|
||||
- what this page represents
|
||||
- what scope they are in
|
||||
- whether something is healthy, blocked, incomplete, or risky
|
||||
- what they should do next
|
||||
- whether any action changes TenantPilot only, Microsoft tenant state, or nothing live
|
||||
|
||||
A strong diagnostics surface lets the troubleshooting persona access raw truth quickly without polluting the primary operator experience.
|
||||
|
||||
A strong reporting surface avoids operational noise and emphasizes proof, posture, and readiness.
|
||||
|
||||
## 14. Adoption Rules
|
||||
|
||||
This document applies to:
|
||||
|
||||
- new operator-facing resources
|
||||
- new dashboards and detail pages
|
||||
- new action-heavy flows
|
||||
- major refactors of existing surfaces
|
||||
- new specs that materially affect operator UX
|
||||
|
||||
Specs should explicitly reference this document where relevant.
|
||||
|
||||
Recommended spec fields:
|
||||
|
||||
- primary persona
|
||||
- surface type
|
||||
- diagnostics boundary
|
||||
- status taxonomy used
|
||||
- mutation scope
|
||||
- action safety model
|
||||
|
||||
## 15. Non-Goals
|
||||
|
||||
This document does not define:
|
||||
|
||||
- color palette
|
||||
- visual branding
|
||||
- CSS-level implementation details
|
||||
- pixel-level layout choices
|
||||
- complete component library design
|
||||
|
||||
This document is about audience, semantics, surface structure, diagnostics boundaries, and safe operator UX.
|
||||
|
||||
## 16. Summary
|
||||
|
||||
TenantPilot must behave like a product, not a raw schema browser.
|
||||
|
||||
That means:
|
||||
|
||||
- operator-first default surfaces
|
||||
- diagnostics available, but secondary
|
||||
- distinct status dimensions
|
||||
- explicit mutation scope
|
||||
- safe execution for dangerous actions
|
||||
- explicit workspace / tenant context
|
||||
- page-level audience and surface contracts
|
||||
@ -42,14 +42,14 @@
|
||||
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Run checks to evaluate risk before previewing.'"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$blocking > 0 ? 'danger' : 'gray'">
|
||||
{{ $blocking }} blocking
|
||||
<x-filament::badge :color="$blocking > 0 ? $severitySpec('blocking')->color : 'gray'">
|
||||
{{ $blocking }} {{ \Illuminate\Support\Str::lower($severitySpec('blocking')->label) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$warning > 0 ? 'warning' : 'gray'">
|
||||
{{ $warning }} warnings
|
||||
<x-filament::badge :color="$warning > 0 ? $severitySpec('warning')->color : 'gray'">
|
||||
{{ $warning }} {{ \Illuminate\Support\Str::lower($severitySpec('warning')->label) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$safe > 0 ? 'success' : 'gray'">
|
||||
{{ $safe }} safe
|
||||
<x-filament::badge :color="$safe > 0 ? $severitySpec('safe')->color : 'gray'">
|
||||
{{ $safe }} {{ \Illuminate\Support\Str::lower($severitySpec('safe')->label) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@ -54,14 +54,14 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
@if (! empty($group['renderingError']))
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-200">
|
||||
{{ $group['renderingError'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (is_array($messages) && $messages !== [])
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-200">
|
||||
<div class="font-medium">Gap details</div>
|
||||
<div class="rounded-lg border px-4 py-3 text-sm {{ data_get($group, 'gapSummary.has_gaps') ? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200' : 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-200' }}">
|
||||
<div class="font-medium">{{ data_get($group, 'gapSummary.has_gaps') ? 'Coverage gaps' : 'Diagnostic notes' }}</div>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5">
|
||||
@foreach ($messages as $message)
|
||||
<li>{{ $message }}</li>
|
||||
@ -117,7 +117,7 @@
|
||||
</div>
|
||||
|
||||
@if (is_array(data_get($item, 'gapSummary.messages')) && data_get($item, 'gapSummary.messages') !== [])
|
||||
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
<div class="mt-3 rounded-lg border px-3 py-2 text-xs {{ data_get($item, 'gapSummary.has_gaps') ? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-200' : 'border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-200' }}">
|
||||
{{ implode(' ', data_get($item, 'gapSummary.messages', [])) }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -32,9 +32,9 @@
|
||||
<th class="px-4 py-3">Policy type</th>
|
||||
<th class="px-4 py-3">Items</th>
|
||||
<th class="px-4 py-3">Fidelity</th>
|
||||
<th class="px-4 py-3">Gaps</th>
|
||||
<th class="px-4 py-3">Coverage state</th>
|
||||
<th class="px-4 py-3">Latest evidence</th>
|
||||
<th class="px-4 py-3">Coverage hint</th>
|
||||
<th class="px-4 py-3">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
@php
|
||||
$preview = $getState() ?? [];
|
||||
$actionPresentation = static function (array $item): array {
|
||||
$action = is_string($item['action'] ?? null) ? $item['action'] : null;
|
||||
|
||||
return match ($action) {
|
||||
'create' => ['label' => 'Will create', 'color' => 'success'],
|
||||
'update' => ['label' => 'Will update existing', 'color' => 'info'],
|
||||
default => ['label' => \Illuminate\Support\Str::headline((string) ($action ?? 'action')), 'color' => 'gray'],
|
||||
};
|
||||
};
|
||||
$foundationItems = collect($preview)->filter(function ($item) {
|
||||
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
|
||||
});
|
||||
@ -9,7 +18,7 @@
|
||||
@endphp
|
||||
|
||||
@if (empty($preview))
|
||||
<p class="text-sm text-gray-600">No preview available.</p>
|
||||
<p class="text-sm text-gray-600">No preview has been generated yet.</p>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
@if ($foundationItems->isNotEmpty())
|
||||
@ -51,6 +60,7 @@
|
||||
@foreach ($policyItems as $item)
|
||||
@php
|
||||
$restoreMode = $item['restore_mode'] ?? null;
|
||||
$actionState = $actionPresentation(is_array($item) ? $item : []);
|
||||
@endphp
|
||||
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
||||
<div class="flex items-center justify-between text-sm text-gray-800">
|
||||
@ -64,8 +74,8 @@
|
||||
{{ $restoreModeSpec->label }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs uppercase tracking-wide text-gray-700">
|
||||
{{ $item['action'] ?? 'action' }}
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">
|
||||
{{ $actionState['label'] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
@endphp
|
||||
|
||||
@if ($foundationItems->isEmpty() && $policyItems->isEmpty())
|
||||
<p class="text-sm text-gray-600">No results recorded.</p>
|
||||
<p class="text-sm text-gray-600">No restore results have been recorded yet.</p>
|
||||
@else
|
||||
@php
|
||||
$needsAttention = $policyItems->contains(function ($item) {
|
||||
@ -82,7 +82,7 @@
|
||||
|
||||
@if ($needsAttention)
|
||||
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
Some settings could not be applied automatically. Review the per-setting details below.
|
||||
Some items still need follow-up. Review the per-item details below.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -121,7 +121,7 @@
|
||||
$itemGraphMessage = $item['graph_error_message'] ?? null;
|
||||
|
||||
if ($itemReason === 'preview_only') {
|
||||
$itemReason = 'Preview-only policy type; execution skipped.';
|
||||
$itemReason = 'Preview only. This policy type is not applied during execution.';
|
||||
}
|
||||
@endphp
|
||||
|
||||
@ -141,9 +141,9 @@
|
||||
@endphp
|
||||
|
||||
<div class="mt-2 text-xs text-gray-700">
|
||||
Assignments: {{ (int) ($summary['success'] ?? 0) }} success •
|
||||
{{ (int) ($summary['failed'] ?? 0) }} failed •
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} skipped
|
||||
Assignments: {{ (int) ($summary['success'] ?? 0) }} applied •
|
||||
{{ (int) ($summary['failed'] ?? 0) }} failed items •
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} not applied
|
||||
</div>
|
||||
|
||||
@if ($assignmentIssues->isNotEmpty())
|
||||
@ -214,7 +214,7 @@
|
||||
|
||||
<div class="mt-2 text-xs text-gray-700">
|
||||
Compliance notifications: {{ (int) ($summary['mapped'] ?? 0) }} mapped •
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} skipped
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} not applied
|
||||
</div>
|
||||
|
||||
@if ($complianceEntries->isNotEmpty())
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
@php
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
@if ($rows === [])
|
||||
@ -18,16 +23,23 @@
|
||||
<th class="px-4 py-3 font-medium">Tenant</th>
|
||||
<th class="px-4 py-3 font-medium">Completeness</th>
|
||||
<th class="px-4 py-3 font-medium">Generated</th>
|
||||
<th class="px-4 py-3 font-medium">Missing</th>
|
||||
<th class="px-4 py-3 font-medium">Stale</th>
|
||||
<th class="px-4 py-3 font-medium">Not collected yet</th>
|
||||
<th class="px-4 py-3 font-medium">Refresh recommended</th>
|
||||
<th class="px-4 py-3 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white text-gray-900">
|
||||
@foreach ($rows as $row)
|
||||
@php
|
||||
$completenessSpec = BadgeRenderer::spec(BadgeDomain::EvidenceCompleteness, $row['completeness_state'] ?? null);
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="px-4 py-3">{{ $row['tenant_name'] }}</td>
|
||||
<td class="px-4 py-3">{{ ucfirst(str_replace('_', ' ', $row['completeness_state'])) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<x-filament::badge :color="$completenessSpec->color" :icon="$completenessSpec->icon" size="sm">
|
||||
{{ $completenessSpec->label }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ $row['generated_at'] ?? '—' }}</td>
|
||||
<td class="px-4 py-3">{{ $row['missing_dimensions'] }}</td>
|
||||
<td class="px-4 py-3">{{ $row['stale_dimensions'] }}</td>
|
||||
@ -41,4 +53,4 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -12,23 +12,29 @@
|
||||
>
|
||||
Active
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'blocked'"
|
||||
wire:click="$set('activeTab', 'blocked')"
|
||||
>
|
||||
Blocked by prerequisite
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'succeeded'"
|
||||
wire:click="$set('activeTab', 'succeeded')"
|
||||
>
|
||||
Succeeded
|
||||
Completed successfully
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'partial'"
|
||||
wire:click="$set('activeTab', 'partial')"
|
||||
>
|
||||
Partial
|
||||
Needs follow-up
|
||||
</x-filament::tabs.item>
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'failed'"
|
||||
wire:click="$set('activeTab', 'failed')"
|
||||
>
|
||||
Failed
|
||||
Execution failed
|
||||
</x-filament::tabs.item>
|
||||
</x-filament::tabs>
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
$hasSummary = count($summaryCounts) > 0;
|
||||
$integrityNote = \App\Support\RedactionIntegrity::noteForRun($run);
|
||||
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
@ -105,6 +106,18 @@
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($guidance)
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Next step
|
||||
</x-slot>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-800 dark:border-white/10 dark:bg-white/5 dark:text-gray-100">
|
||||
{{ $guidance }}
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($hasSummary)
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
@ -115,7 +128,7 @@
|
||||
@foreach ($summaryCounts as $key => $value)
|
||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ \Illuminate\Support\Str::headline((string) $key) }}
|
||||
{{ \App\Support\OpsUx\SummaryCountsNormalizer::label((string) $key) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xl font-bold text-gray-950 dark:text-white">
|
||||
{{ is_numeric($value) ? number_format((int) $value) : $value }}
|
||||
|
||||
@ -21,23 +21,44 @@ class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-pri
|
||||
@else
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
@foreach ($runs as $run)
|
||||
@php
|
||||
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::OperationRunStatus,
|
||||
(string) $run->status,
|
||||
);
|
||||
$outcomeSpec = \App\Support\Badges\BadgeRenderer::spec(
|
||||
\App\Support\Badges\BadgeDomain::OperationRunOutcome,
|
||||
(string) $run->outcome,
|
||||
);
|
||||
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
|
||||
@endphp
|
||||
<li class="flex items-center justify-between gap-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">
|
||||
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$statusSpec->color" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$outcomeSpec->color" size="sm">
|
||||
{{ $outcomeSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $run->created_at?->diffForHumans() ?? '—' }}
|
||||
</div>
|
||||
|
||||
@if ($guidance)
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ $guidance }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-3">
|
||||
<div class="text-right text-xs text-gray-600 dark:text-gray-300">
|
||||
<div>{{ (string) $run->status }}</div>
|
||||
<div>{{ (string) $run->outcome }}</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<a
|
||||
href="{{ \App\Support\OperationRunLinks::tenantlessView($run) }}"
|
||||
class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
@ -50,4 +71,3 @@ class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-pri
|
||||
</ul>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
|
||||
|
||||
@ -48,6 +48,12 @@ class="block rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:b
|
||||
{{ $operation['outcome_label'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if (filled($operation['guidance'] ?? null))
|
||||
<p class="text-xs leading-5 text-gray-600 dark:text-gray-300">
|
||||
{{ $operation['guidance'] }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-21
|
||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/156-operator-outcome-taxonomy/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 completed against the initial draft.
|
||||
- Strategic selection rationale is captured in the spec's final direction: this candidate is the recommended first step of the operator-truth initiative because downstream candidates depend on a shared vocabulary.
|
||||
@ -0,0 +1,186 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Operator State Presentation Logical Contract
|
||||
version: 0.1.0
|
||||
summary: Logical contract for resolving raw domain state into operator-facing state under the shared taxonomy.
|
||||
description: |
|
||||
This contract is logical rather than transport-prescriptive. It describes the
|
||||
expected behavior of existing badge mappers, presenters, notifications, and
|
||||
Filament surfaces that consume the shared operator outcome taxonomy.
|
||||
Runtime badge implementations currently expose the resolved severity band
|
||||
through Filament badge colors with the same enum values.
|
||||
servers:
|
||||
- url: https://tenantpilot.local
|
||||
paths:
|
||||
/contracts/operator-state/resolve:
|
||||
post:
|
||||
summary: Resolve one raw domain state into operator-facing presentation
|
||||
operationId: resolveOperatorState
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StateResolutionRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Taxonomy-backed state presentation resolved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StateResolutionResponse'
|
||||
examples:
|
||||
blockedOperation:
|
||||
value:
|
||||
axis: execution_outcome
|
||||
primaryLabel: Blocked by prerequisite
|
||||
severity: warning
|
||||
classificationLevel: primary
|
||||
nextActionPolicy: required
|
||||
explanationRequired: true
|
||||
diagnosticLabel: null
|
||||
legacyAliases:
|
||||
- Blocked
|
||||
notes: Execution could not start or continue until a prerequisite is fixed.
|
||||
/contracts/operator-state/validate-adoption:
|
||||
post:
|
||||
summary: Validate that an adopted surface only uses allowed taxonomy mappings
|
||||
operationId: validateAdoptionTarget
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdoptionValidationRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Adoption validation result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdoptionValidationResponse'
|
||||
components:
|
||||
schemas:
|
||||
StateResolutionRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- domain
|
||||
- rawValue
|
||||
- surfaceType
|
||||
properties:
|
||||
domain:
|
||||
type: string
|
||||
example: operation_run_outcome
|
||||
rawValue:
|
||||
type: string
|
||||
example: blocked
|
||||
surfaceType:
|
||||
type: string
|
||||
enum:
|
||||
- badge
|
||||
- summary_line
|
||||
- notification
|
||||
- infolist
|
||||
- widget
|
||||
tenantScoped:
|
||||
type: boolean
|
||||
default: true
|
||||
includeDiagnostics:
|
||||
type: boolean
|
||||
default: false
|
||||
StateResolutionResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- axis
|
||||
- primaryLabel
|
||||
- severity
|
||||
- classificationLevel
|
||||
- nextActionPolicy
|
||||
- explanationRequired
|
||||
- notes
|
||||
properties:
|
||||
axis:
|
||||
type: string
|
||||
example: execution_outcome
|
||||
primaryLabel:
|
||||
type: string
|
||||
example: Blocked by prerequisite
|
||||
severity:
|
||||
type: string
|
||||
description: Operator-facing severity band. In runtime badge mappings this is the Filament badge color.
|
||||
enum:
|
||||
- gray
|
||||
- info
|
||||
- success
|
||||
- warning
|
||||
- danger
|
||||
- primary
|
||||
classificationLevel:
|
||||
type: string
|
||||
enum:
|
||||
- primary
|
||||
- diagnostic
|
||||
nextActionPolicy:
|
||||
type: string
|
||||
enum:
|
||||
- required
|
||||
- optional
|
||||
- none
|
||||
explanationRequired:
|
||||
type: boolean
|
||||
diagnosticLabel:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
example: Fallback renderer
|
||||
legacyAliases:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
example: Execution could not start or continue until a prerequisite is fixed.
|
||||
AdoptionValidationRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- target
|
||||
- entries
|
||||
properties:
|
||||
target:
|
||||
type: string
|
||||
example: operations
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StateResolutionResponse'
|
||||
AdoptionValidationResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- valid
|
||||
- violations
|
||||
properties:
|
||||
valid:
|
||||
type: boolean
|
||||
violations:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- code
|
||||
- message
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum:
|
||||
- diagnostic_severity_violation
|
||||
- missing_next_action_policy
|
||||
- unqualified_overloaded_term
|
||||
- unauthorized_scope_leak_risk
|
||||
message:
|
||||
type: string
|
||||
example: Diagnostic taxonomy entries cannot use warning or danger severity.
|
||||
@ -0,0 +1,187 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://tenantpilot.local/contracts/operator-taxonomy-entry.schema.json",
|
||||
"title": "Operator Taxonomy Entry",
|
||||
"description": "Documentation-first schema for one taxonomy-backed operator state mapping. The severity enum matches the runtime Filament badge color band.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"domain",
|
||||
"raw_value",
|
||||
"axis",
|
||||
"primary_label",
|
||||
"severity",
|
||||
"classification_level",
|
||||
"next_action_policy",
|
||||
"legacy_aliases",
|
||||
"notes"
|
||||
],
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"examples": [
|
||||
"restore_result_status"
|
||||
]
|
||||
},
|
||||
"raw_value": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"examples": [
|
||||
"manual_required"
|
||||
]
|
||||
},
|
||||
"axis": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"execution_lifecycle",
|
||||
"execution_outcome",
|
||||
"item_result",
|
||||
"data_coverage",
|
||||
"evidence_depth",
|
||||
"product_support_maturity",
|
||||
"data_freshness",
|
||||
"operator_actionability",
|
||||
"publication_readiness",
|
||||
"governance_deviation"
|
||||
],
|
||||
"examples": [
|
||||
"operator_actionability"
|
||||
]
|
||||
},
|
||||
"primary_label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"examples": [
|
||||
"Manual follow-up needed"
|
||||
]
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"description": "Operator-facing severity band, mapped to the Filament badge color used at runtime.",
|
||||
"enum": [
|
||||
"gray",
|
||||
"info",
|
||||
"success",
|
||||
"warning",
|
||||
"danger",
|
||||
"primary"
|
||||
],
|
||||
"examples": [
|
||||
"warning"
|
||||
]
|
||||
},
|
||||
"classification_level": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"primary",
|
||||
"diagnostic"
|
||||
],
|
||||
"examples": [
|
||||
"primary"
|
||||
]
|
||||
},
|
||||
"next_action_policy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"required",
|
||||
"optional",
|
||||
"none"
|
||||
],
|
||||
"examples": [
|
||||
"required"
|
||||
]
|
||||
},
|
||||
"legacy_aliases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"examples": [
|
||||
[
|
||||
"Manual required"
|
||||
]
|
||||
]
|
||||
},
|
||||
"diagnostic_label": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"notes": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"examples": [
|
||||
"The operator must handle this item manually."
|
||||
]
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"classification_level": {
|
||||
"const": "diagnostic"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"severity": {
|
||||
"enum": [
|
||||
"gray",
|
||||
"info",
|
||||
"primary"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"severity": {
|
||||
"enum": [
|
||||
"warning",
|
||||
"danger"
|
||||
]
|
||||
},
|
||||
"classification_level": {
|
||||
"const": "primary"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"next_action_policy": {
|
||||
"enum": [
|
||||
"required",
|
||||
"optional"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"examples": [
|
||||
{
|
||||
"domain": "restore_result_status",
|
||||
"raw_value": "manual_required",
|
||||
"axis": "operator_actionability",
|
||||
"primary_label": "Manual follow-up needed",
|
||||
"severity": "warning",
|
||||
"classification_level": "primary",
|
||||
"next_action_policy": "required",
|
||||
"legacy_aliases": [
|
||||
"Manual required"
|
||||
],
|
||||
"diagnostic_label": null,
|
||||
"notes": "The operator must handle this item manually."
|
||||
}
|
||||
]
|
||||
}
|
||||
151
specs/156-operator-outcome-taxonomy/data-model.md
Normal file
151
specs/156-operator-outcome-taxonomy/data-model.md
Normal file
@ -0,0 +1,151 @@
|
||||
# Data Model: Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||
|
||||
This feature defines a shared semantic model rather than introducing a new business-domain table. The model below captures the entities that the implementation and guard coverage must agree on.
|
||||
|
||||
## Entities
|
||||
|
||||
### SemanticAxis
|
||||
|
||||
Represents one independent meaning dimension that operator-facing state is allowed to communicate.
|
||||
|
||||
**Fields**:
|
||||
- `key` (string): stable identifier such as `execution_outcome`, `freshness`, or `product_support_maturity`
|
||||
- `label` (string): human-readable axis name used in documentation
|
||||
- `definition` (string): the exact semantic question this axis answers
|
||||
- `allowedValues` (list<string>): canonical operator-facing values allowed on this axis
|
||||
- `defaultPresentationLevel` (enum): `primary` or `diagnostic`
|
||||
- `nextActionRequired` (bool): whether non-green states on this axis must always carry action guidance
|
||||
|
||||
**Validation rules**:
|
||||
- Each operator-facing primary state belongs to exactly one axis.
|
||||
- Different axes must not be flattened into one unqualified label.
|
||||
- Diagnostic-only axes cannot use warning or danger as their default severity.
|
||||
|
||||
### TaxonomyTerm
|
||||
|
||||
Represents one canonical operator-facing term or phrase permitted by the taxonomy.
|
||||
|
||||
**Fields**:
|
||||
- `term` (string): canonical label shown to operators
|
||||
- `axisKey` (string): owning semantic axis
|
||||
- `replaces` (list<string>): overloaded legacy terms or synonyms that must migrate away
|
||||
- `requiresQualifier` (bool): whether the term must include a qualifier such as count, cause, or dimension
|
||||
- `notes` (string): usage guidance and example contexts
|
||||
|
||||
**Validation rules**:
|
||||
- A canonical term must have one meaning only.
|
||||
- A term marked `requiresQualifier` is invalid when used bare.
|
||||
- Legacy overloaded terms such as `Partial`, `Blocked`, `Missing`, and `Unsupported` must map to one or more canonical replacements.
|
||||
|
||||
### SeverityRule
|
||||
|
||||
Represents the shared meaning of severity colors and tones.
|
||||
|
||||
**Fields**:
|
||||
- `severity` (enum): `danger`, `warning`, `info`, `success`, `gray`, `primary`
|
||||
- `meaning` (string): semantic meaning of the severity across the product
|
||||
- `allowedAxes` (list<string>): axes where this severity is allowed as a primary state
|
||||
- `forbiddenAxes` (list<string>): axes where this severity must not be used
|
||||
- `operatorExpectation` (string): what the operator should infer from seeing the severity
|
||||
|
||||
**Validation rules**:
|
||||
- `danger` is reserved for execution failure, governance violation, or material risk.
|
||||
- `warning` is reserved for action-recommended or mixed-outcome primary states.
|
||||
- `gray` is reserved for neutral, archived, not applicable, or explicitly non-actionable context.
|
||||
- Diagnostic-only states must not use `danger` or `warning`.
|
||||
|
||||
### PresentationClassification
|
||||
|
||||
Represents whether a state belongs in the operator's primary view or in diagnostics.
|
||||
|
||||
**Fields**:
|
||||
- `level` (enum): `primary` or `diagnostic`
|
||||
- `actionability` (enum): `action_required`, `action_recommended`, `no_action_needed`, `context_only`
|
||||
- `explanationRequired` (bool): whether helper text or a next-step reference is mandatory
|
||||
- `supportsRawDetails` (bool): whether raw technical detail may be shown as secondary detail
|
||||
|
||||
**Validation rules**:
|
||||
- Every non-green, non-gray primary state requires explanation or an explicit no-action-needed note.
|
||||
- Diagnostic classification never replaces the primary state for a surface that already exposes a primary state.
|
||||
|
||||
### TaxonomyEntry
|
||||
|
||||
Represents the complete classification of one raw domain state into taxonomy semantics.
|
||||
|
||||
**Fields**:
|
||||
- `domain` (string): source domain such as `operation_run_outcome` or `evidence_completeness`
|
||||
- `rawValue` (string): existing enum value or normalized source state
|
||||
- `axisKey` (string): owning semantic axis
|
||||
- `primaryLabel` (string): operator-facing label to render
|
||||
- `severity` (enum): resolved severity band
|
||||
- `classificationLevel` (enum): `primary` or `diagnostic`
|
||||
- `legacyAliases` (list<string>): prior labels that must stop being used
|
||||
- `nextActionPolicy` (enum): `required`, `optional`, `none`
|
||||
- `diagnosticLabel` (optional string): secondary label for detailed views
|
||||
- `notes` (string): human explanation of why the mapping exists
|
||||
|
||||
**Validation rules**:
|
||||
- Every adopted raw state must have exactly one taxonomy entry.
|
||||
- `classificationLevel = diagnostic` forbids `severity = warning|danger`.
|
||||
- `nextActionPolicy = required` must be set for non-green, non-gray primary states.
|
||||
|
||||
### AdoptionTarget
|
||||
|
||||
Represents a bounded surface or domain family included in the first implementation slice.
|
||||
|
||||
**Fields**:
|
||||
- `key` (string): stable target identifier
|
||||
- `family` (enum): `operations`, `baselines`, `restore`, `evidence`, `reviews`, `inventory_provider`, `verification`
|
||||
- `sourceArtifacts` (list<string>): enums, badge mappers, presenters, or views currently using overloaded semantics
|
||||
- `surfaceTypes` (list<string>): examples such as `table`, `infolist`, `notification`, `widget`, `helper_copy`
|
||||
- `priority` (enum): `P0`, `P1`, `P2`
|
||||
- `rolloutStage` (int): ordered implementation stage
|
||||
|
||||
**Validation rules**:
|
||||
- The first slice must include operations, baselines, restore, and at least one additional family.
|
||||
- Each target must identify both shared-code adoption points and user-visible surfaces.
|
||||
|
||||
### RegressionGuardCase
|
||||
|
||||
Represents one reusable guard or regression test category enforcing the taxonomy.
|
||||
|
||||
**Fields**:
|
||||
- `name` (string): guard case identifier
|
||||
- `assertion` (string): invariant being enforced
|
||||
- `scope` (enum): `unit`, `feature`, `architecture`
|
||||
- `coversAxes` (list<string>): axes touched by the case
|
||||
- `failureSignal` (string): what should cause CI to fail
|
||||
|
||||
**Validation rules**:
|
||||
- The first slice must include guards for diagnostic severity misuse, valid-empty misclassification, freshness severity misuse, unqualified partial or blocked labels, and cross-tenant leakage in canonical views.
|
||||
|
||||
## Relationships
|
||||
|
||||
- `SemanticAxis` 1-to-many `TaxonomyTerm`
|
||||
- `SemanticAxis` 1-to-many `SeverityRule` by allowed or forbidden usage
|
||||
- `TaxonomyEntry` belongs to one `SemanticAxis`
|
||||
- `TaxonomyEntry` belongs to one `PresentationClassification`
|
||||
- `AdoptionTarget` consumes many `TaxonomyEntry` mappings
|
||||
- `RegressionGuardCase` validates many `TaxonomyEntry` and `AdoptionTarget` combinations
|
||||
|
||||
## Initial First-Slice Adoption Set
|
||||
|
||||
### Operations
|
||||
- Source artifacts: `OperationRunOutcomeBadge`, `OperationUxPresenter`, `SummaryCountsNormalizer`, operation notifications
|
||||
- Primary axes involved: `execution_outcome`, `operator_actionability`
|
||||
|
||||
### Baseline snapshot semantics
|
||||
- Source artifacts: `FidelityState`, `GapSummary`, `BaselineSnapshotFidelityBadge`, `BaselineSnapshotGapStatusBadge`
|
||||
- Primary axes involved: `evidence_depth`, `data_coverage`, `product_support_maturity`
|
||||
|
||||
### Restore semantics
|
||||
- Source artifacts: `RestoreRunStatusBadge`, `RestoreResultStatusBadge`, `RestorePreviewDecisionBadge`, `RestoreCheckSeverityBadge`
|
||||
- Primary axes involved: `execution_lifecycle`, `item_result`, `operator_actionability`
|
||||
|
||||
### Evidence and review completeness
|
||||
- Source artifacts: `EvidenceCompletenessState`, `TenantReviewCompletenessState`, their badge mappers, canonical review surfaces
|
||||
- Primary axes involved: `data_coverage`, `data_freshness`
|
||||
|
||||
### Inventory or provider operability
|
||||
- Source artifacts: `InventoryKpiBadges`, `PolicySnapshotModeBadge`, `ProviderConnectionStatusBadge`, `ProviderConnectionHealthBadge`, verification status helpers
|
||||
- Primary axes involved: `product_support_maturity`, `operator_actionability`, `freshness`
|
||||
183
specs/156-operator-outcome-taxonomy/plan.md
Normal file
183
specs/156-operator-outcome-taxonomy/plan.md
Normal file
@ -0,0 +1,183 @@
|
||||
# Implementation Plan: Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||
|
||||
**Branch**: `156-operator-outcome-taxonomy` | **Date**: 2026-03-21 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/156-operator-outcome-taxonomy/spec.md`
|
||||
**Input**: Feature specification from `/specs/156-operator-outcome-taxonomy/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Establish one shared operator outcome taxonomy that separates semantic axes, defines a canonical term dictionary and severity rules, classifies primary versus diagnostic state, and applies that foundation to a bounded first-slice adoption set. The implementation should leave the existing badge and Filament plumbing intact, add only minimal shared enforcement at the `BadgeSpec` and `BadgeRenderer` boundary, publish a canonical reference document for downstream specs, and migrate the highest-noise cross-domain states first: operations, baselines, restore, and one additional state family with valid-empty and freshness pressure.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
**Storage**: PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata
|
||||
**Testing**: Pest feature tests, unit tests for badge contracts and taxonomy guards, existing architectural guard tests, focused Livewire or Filament surface tests where list/detail presentation is asserted
|
||||
**Target Platform**: Laravel web application running locally via Sail and deployed via Dokploy
|
||||
**Project Type**: Web application
|
||||
**Performance Goals**: No render-time external calls; no material regression to list/detail render paths; taxonomy lookup remains constant-time and cacheable through existing badge infrastructure; Monitoring and other canonical views remain DB-only at render time
|
||||
**Constraints**: Preserve BADGE-001 centralization, preserve RBAC 404 versus 403 semantics, avoid broad visual-system rewrites, keep domain-specific enum and DTO churn bounded to the first-slice adoption set, and do not introduce ad-hoc page-local labels as part of the rollout
|
||||
**Scale/Scope**: Foundation spec plus first-slice adoption across operations, baseline snapshot semantics, restore semantics, and evidence or review completeness as the additional valid-empty and freshness pressure domain
|
||||
|
||||
### Filament v5 Implementation Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: Maintained. This work changes operator-facing semantics in the existing Filament v5 + Livewire v4 stack and introduces no incompatible Livewire pattern.
|
||||
- **Provider registration location**: No new panel is introduced. Existing panel providers remain registered in `bootstrap/providers.php`.
|
||||
- **Global search rule**: No new globally searchable resource is added. Existing search behavior must stay tenant-safe when shared taxonomy-backed labels appear in canonical and tenant-context views.
|
||||
- **Destructive actions**: No new destructive action family is introduced by the foundation itself. Existing destructive actions on adopted surfaces remain confirmation-protected and capability-gated.
|
||||
- **Asset strategy**: No new global or on-demand assets are planned. Deployment remains unchanged, including `php artisan filament:assets` where already part of the deploy process.
|
||||
- **Testing plan**: Add Pest coverage for taxonomy registry validity, badge severity guardrails, valid-empty and freshness classifications, cross-tenant non-leakage on canonical views, and focused assertion coverage on the first-slice adopted surfaces.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS. The feature changes operator-facing semantics only; it does not alter inventory versus snapshot ownership rules.
|
||||
- Read/write separation: PASS. The foundation introduces no new write flow. Existing mutations on adopted surfaces remain preview-, confirmation-, and audit-governed by their owning specs.
|
||||
- Graph contract path: PASS. No new Graph calls or contract-registry changes are introduced.
|
||||
- Deterministic capabilities: PASS. Capability derivation remains unchanged and must continue to gate adopted surfaces server-side.
|
||||
- RBAC-UX plane separation: PASS. The adoption set spans tenant-context and canonical workspace views while preserving 404 for non-members and 403 for in-scope capability denial.
|
||||
- Workspace isolation: PASS. Shared taxonomy-backed labels, counts, and filter chips must be computed only from entitled workspace scope.
|
||||
- Tenant isolation: PASS. Cross-tenant aggregation remains explicit and access-checked. Shared severity rules must not leak unauthorized tenant state.
|
||||
- Destructive confirmation standard: PASS. No destructive semantics are weakened by this feature.
|
||||
- Global search tenant safety: PASS WITH WORK. Taxonomy-backed labels must not surface inaccessible results or hint text; focused regression coverage is required.
|
||||
- Run observability: PASS. Existing `OperationRun` usage remains canonical. This feature only changes how outcomes and summaries are interpreted and rendered.
|
||||
- Ops-UX 3-surface feedback: PASS WITH WORK. Notification and summary wording for adopted operation states must stay within the existing toast/progress/terminal contract.
|
||||
- Ops-UX lifecycle: PASS. `OperationRun.status` and `OperationRun.outcome` stay service-owned; this feature only changes presentation and translation rules.
|
||||
- Ops-UX summary counts: PASS WITH WORK. `SummaryCountsNormalizer` already humanizes keys, but first-slice taxonomy rules must make operator-facing summary language clearer without changing the numeric contract.
|
||||
- Ops-UX guards: PASS WITH WORK. Add guard coverage so diagnostic-only states cannot be rendered with warning or danger severity through badge mappings.
|
||||
- Ops-UX system runs: PASS. No change to initiator-null notification semantics.
|
||||
- Automation: PASS. No queue or retry contract changes.
|
||||
- Data minimization: PASS. This feature should reduce operator exposure to raw internal reason strings by pushing them into diagnostics.
|
||||
- Badge semantics (BADGE-001): PASS WITH WORK. This feature is the foundation slice that codifies badge meaning and must preserve centralization through `BadgeCatalog` and `BadgeRenderer`.
|
||||
- UI naming (UI-NAMING-001): PASS WITH WORK. The taxonomy becomes the reference for consistent operator-facing state terms across badges, summaries, notifications, and helper copy.
|
||||
- Filament UI Action Surface Contract: PASS. Existing action surfaces remain structurally intact; the rollout changes state meaning and helper copy rather than action topology.
|
||||
- Filament UI UX-001: PASS WITH WORK. Adopted empty states, badges, and summary sections must align with the taxonomy, especially for valid-empty and freshness cases.
|
||||
|
||||
**Phase 0 Gate Result**: PASS
|
||||
|
||||
- The feature is bounded to semantic normalization and first-slice adoption, not broad infrastructure replacement.
|
||||
- Existing badge plumbing is reusable, which keeps the foundation shippable.
|
||||
- The largest risk is semantic drift during the bounded first-slice adoption set, not architecture viability.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/156-operator-outcome-taxonomy/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ ├── operator-state-presentation.logical.openapi.yaml
|
||||
│ └── operator-taxonomy-entry.schema.json
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── Reviews/
|
||||
│ ├── Resources/
|
||||
│ └── Widgets/
|
||||
│ ├── Dashboard/
|
||||
│ └── Tenant/
|
||||
├── Notifications/
|
||||
├── Services/
|
||||
│ ├── Baselines/
|
||||
│ │ └── SnapshotRendering/
|
||||
│ └── Onboarding/
|
||||
├── Support/
|
||||
│ ├── Badges/
|
||||
│ │ └── Domains/
|
||||
│ ├── Evidence/
|
||||
│ ├── Inventory/
|
||||
│ ├── OpsUx/
|
||||
│ └── Verification/
|
||||
└── Models/
|
||||
|
||||
docs/
|
||||
├── audits/
|
||||
├── product/
|
||||
└── ui/
|
||||
|
||||
resources/
|
||||
└── views/
|
||||
└── filament/
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
├── Unit/
|
||||
└── Architecture/
|
||||
```
|
||||
|
||||
**Structure Decision**: Use the existing Laravel web application structure. The foundation artifacts live under `specs/156-operator-outcome-taxonomy/`, while the planned implementation targets are concentrated in `app/Support/Badges`, `app/Support/OpsUx`, `app/Services/Baselines/SnapshotRendering`, `app/Support/Evidence`, the restore badge and Filament surface files for the bounded slice, and focused Pest coverage under `tests/Feature`, `tests/Unit`, and existing guard-oriented suites.
|
||||
|
||||
## Phase 0 — Research (complete)
|
||||
|
||||
- Output: [specs/156-operator-outcome-taxonomy/research.md](research.md)
|
||||
- Resolved key decisions:
|
||||
- Keep `BadgeCatalog` and `BadgeRenderer` as the canonical rendering plumbing; fix taxonomy input, not the rendering layer itself.
|
||||
- Publish the shared reference document under `docs/product/operator-semantic-taxonomy.md` so roadmap and downstream specs can reference one canonical source.
|
||||
- Add only minimal shared enforcement in the foundation slice: `BadgeSpec` and badge contract metadata may gain diagnostic-boundary semantics, but wide domain rewrites stay bounded to the first-slice adoption set.
|
||||
- First-slice adoption order is operations outcome wording, evidence and review completeness semantics, baseline snapshot semantics, and restore semantics.
|
||||
- Guard coverage must assert that diagnostic-only states cannot ship with warning or danger severity.
|
||||
|
||||
## Phase 1 — Design & Contracts (complete)
|
||||
|
||||
- Output: [data-model.md](./data-model.md) defines the semantic-axis, term, severity, classification, and adoption-target model used by the implementation.
|
||||
- Output: [contracts/operator-state-presentation.logical.openapi.yaml](./contracts/operator-state-presentation.logical.openapi.yaml) captures the logical resolution contract between raw domain state, taxonomy lookup, and operator-facing state presentation.
|
||||
- Output: [contracts/operator-taxonomy-entry.schema.json](./contracts/operator-taxonomy-entry.schema.json) defines the shape of one taxonomy entry and its severity and diagnostic constraints.
|
||||
- Output: [quickstart.md](./quickstart.md) documents implementation order, reference-document creation, and focused validation commands.
|
||||
|
||||
### Post-design Constitution Re-check
|
||||
|
||||
- PASS: No new panel, route family, Graph path, or business-domain store is introduced.
|
||||
- PASS: The design preserves Filament v5 + Livewire v4 and keeps provider registration unchanged in `bootstrap/providers.php`.
|
||||
- PASS WITH WORK: The first-slice adoption set touches high-noise domains, so rollout must remain staged and test-backed to avoid semantic churn.
|
||||
- PASS WITH WORK: Operations notifications and summary lines need focused validation so the taxonomy improves wording without violating the existing Ops-UX contract.
|
||||
- PASS WITH WORK: Canonical views and tenant-context views need explicit non-member regression coverage because shared labels and filter chips are part of the leak surface.
|
||||
|
||||
## Phase 2 — Implementation Planning
|
||||
|
||||
`tasks.md` should cover:
|
||||
|
||||
- Publishing the canonical reference document at `docs/product/operator-semantic-taxonomy.md` with semantic axes, term dictionary, severity rules, diagnostic boundary, and next-action policy.
|
||||
- Extending shared badge contracts so taxonomy metadata can distinguish primary versus diagnostic states and can reject warning or danger severity for diagnostic-only values.
|
||||
- Aligning operations outcome presentation: `OperationRunOutcome` badge mapping, `OperationUxPresenter`, `SummaryCountsNormalizer`, and notification wording.
|
||||
- Aligning valid-empty and freshness semantics in `EvidenceCompletenessState` and `TenantReviewCompletenessState`, including badge, empty-state, and canonical view updates.
|
||||
- Aligning baseline snapshot fidelity and gap presentation so product-support tier becomes diagnostic and true coverage problems remain primary.
|
||||
- Aligning restore run, item, and preview semantics so partial, blocked, manual, and dry-run states are axis-specific and action-oriented.
|
||||
- Adding explicit tenant-safe global-search and authorization regression coverage for taxonomy-backed labels, counts, and filters on adopted canonical and tenant-context surfaces.
|
||||
- Adding Pest guard tests that fail when diagnostic-only taxonomy values map to warning or danger badges.
|
||||
- Adding positive and negative authorization regression coverage for canonical views that adopt taxonomy-backed labels, counts, and filters.
|
||||
|
||||
### Contract Implementation Note
|
||||
|
||||
- The OpenAPI file is logical, not transport-prescriptive. It documents how existing Filament, presenter, and notification flows should resolve raw state into operator-facing state.
|
||||
- The JSON schema is documentation-first and guard-friendly. It can be enforced through curated fixtures, unit tests, or static registry declarations rather than a new runtime parser in the first slice.
|
||||
- The preferred rollout is to add taxonomy metadata centrally and then migrate first-slice domains to consume it, rather than pushing page-local label overrides.
|
||||
|
||||
### Deployment Sequencing Note
|
||||
|
||||
- No migration is expected in the foundation slice.
|
||||
- No asset publish change is expected.
|
||||
- Rollout should start with the reference document and shared badge contract, then the operations adoption slice, then evidence and review completeness semantics, then baseline semantics, then restore semantics, with each stop point guarded before broadening the slice.
|
||||
|
||||
### Story Delivery Note
|
||||
|
||||
- User Stories 1 and 2 are both P1. The executable delivery order should start with User Story 2 because operations are the proving ground for the shared actionability vocabulary, then continue with User Story 1 for valid-empty and diagnostic-boundary cleanup, and finish with User Story 3 for restore convergence plus cross-view safety hardening.
|
||||
- Provider, inventory, onboarding, verification, and finding-specific vocabulary changes remain out of the first implementation slice unless required only for shared guard coverage.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| None | Not applicable | Not applicable |
|
||||
118
specs/156-operator-outcome-taxonomy/quickstart.md
Normal file
118
specs/156-operator-outcome-taxonomy/quickstart.md
Normal file
@ -0,0 +1,118 @@
|
||||
# Quickstart: Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||
|
||||
## Goal
|
||||
|
||||
Validate and extend the shipped first-slice taxonomy without breaking RBAC, badge centralization, or the existing Ops-UX feedback contract.
|
||||
|
||||
## Adopted Scope
|
||||
|
||||
The current slice covers:
|
||||
|
||||
1. Operations outcomes, notifications, summaries, widgets, and run detail copy
|
||||
2. Evidence and tenant-review completeness plus freshness semantics
|
||||
3. Baseline fidelity, diagnostic notes, and gap-status presentation
|
||||
4. Restore run, preview, item-result, and safety-check semantics
|
||||
5. Canonical-view and tenant-context filter labels that now resolve through `BadgeCatalog`
|
||||
|
||||
## Curated Example Rubric
|
||||
|
||||
Score these 12 cases with a pass or fail for "action required is obvious in one inspection step":
|
||||
|
||||
1. Operation blocked by missing prerequisite
|
||||
2. Operation completed with follow-up
|
||||
3. Operation completed successfully
|
||||
4. Evidence not collected yet
|
||||
5. Evidence refresh recommended
|
||||
6. Review input pending
|
||||
7. Mixed evidence detail stays diagnostic
|
||||
8. Support limited stays diagnostic
|
||||
9. Coverage gaps need review
|
||||
10. Restore preview blocked by a check
|
||||
11. Restore run applied with follow-up
|
||||
12. Restore item requires manual follow-up
|
||||
|
||||
## Focused Validation Commands
|
||||
|
||||
Run all commands through Sail.
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact \
|
||||
tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php \
|
||||
tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php \
|
||||
tests/Unit/Badges/OperationRunBadgesTest.php \
|
||||
tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php \
|
||||
tests/Unit/Support/Badges/BaselineSnapshotRenderingBadgeTest.php \
|
||||
tests/Unit/Badges/RestoreRunBadgesTest.php \
|
||||
tests/Feature/Notifications/OperationRunNotificationTest.php \
|
||||
tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php
|
||||
```
|
||||
|
||||
Optional broader slice verification:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact \
|
||||
tests/Feature/Evidence/EvidenceOverviewPageTest.php \
|
||||
tests/Feature/Evidence/EvidenceSnapshotResourceTest.php \
|
||||
tests/Feature/TenantReview/TenantReviewRegisterTest.php \
|
||||
tests/Feature/TenantReview/TenantReviewUiContractTest.php \
|
||||
tests/Unit/TenantReview/TenantReviewBadgeTest.php \
|
||||
tests/Unit/Baselines/SnapshotRendering/BaselineSnapshotPresenterTest.php \
|
||||
tests/Feature/Filament/BaselineSnapshotFidelityVisibilityTest.php \
|
||||
tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php
|
||||
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual Smoke Checklist
|
||||
|
||||
### `/admin/operations`
|
||||
|
||||
- Queued runs show `Queued for execution`.
|
||||
- Blocked runs show `Blocked by prerequisite` and surface a next step or blocked-prerequisite guidance.
|
||||
- Partial runs show `Completed with follow-up`, not bare `Partial`.
|
||||
- Success copy can explicitly say `No action needed`.
|
||||
|
||||
### `/admin/monitoring/evidence-overview`
|
||||
|
||||
- Missing evidence shows `Not collected yet`, not a failure label.
|
||||
- Stale evidence shows `Refresh recommended`.
|
||||
- Operations summary language distinguishes execution failures from follow-up states.
|
||||
|
||||
### `/admin/reviews`
|
||||
|
||||
- Completeness states show `Review input pending`, `Review inputs incomplete`, or `Refresh review inputs`.
|
||||
- No tenant-review surface uses bare `Missing` or `Stale` as the primary operator label.
|
||||
|
||||
### Baseline detail surfaces
|
||||
|
||||
- `Support limited` and fallback notes appear as diagnostic-only detail.
|
||||
- `Coverage gaps need review` appears only for real coverage gaps.
|
||||
- Diagnostic-only fallback notes do not increment the primary gap count.
|
||||
- Clear baseline groups can show `No follow-up needed`.
|
||||
|
||||
### Restore detail surfaces
|
||||
|
||||
- Safety checks show `Fix before running`, `Review before running`, or `Ready to continue`.
|
||||
- Empty states read `No preview has been generated yet.` and `No restore results have been recorded yet.`
|
||||
- Preview actions use `Will create`, `Will create copy`, `Will map existing`, or `Will skip`.
|
||||
- Item results use `Applied`, `Mapped to existing item`, `Not applied`, `Manual follow-up needed`, or `Apply failed`.
|
||||
|
||||
### Search and scope hardening
|
||||
|
||||
- Operations, evidence snapshots, baseline snapshots, and tenant reviews remain out of global search.
|
||||
- Canonical summaries, badge counts, and filters only reflect authorized workspace and tenant scope.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- Operators can distinguish actionable risk from diagnostics on each adopted surface.
|
||||
- Valid-empty states no longer render as failure.
|
||||
- Freshness states no longer render as archival or product-maturity context.
|
||||
- Partial and blocked states are qualified by cause or dimension.
|
||||
- Diagnostic-only baseline states never use `warning` or `danger`.
|
||||
- Canonical views do not reveal unauthorized tenant state through taxonomy-backed labels, counts, or filters.
|
||||
- Existing operations notifications still obey the queued, progress, and terminal feedback contract.
|
||||
- At least 11 of the 12 curated example cases make the required operator action obvious in one inspection step.
|
||||
|
||||
## Rollout Note
|
||||
|
||||
Do not migrate unrelated badge domains opportunistically. Keep the slice bounded to operations, evidence and review completeness, baseline semantics, and restore semantics so semantic regressions stay attributable and reversible.
|
||||
92
specs/156-operator-outcome-taxonomy/research.md
Normal file
92
specs/156-operator-outcome-taxonomy/research.md
Normal file
@ -0,0 +1,92 @@
|
||||
# Research: Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||
|
||||
## Decision 1: Keep the badge infrastructure and fix the taxonomy feeding it
|
||||
|
||||
- Decision: Reuse `BadgeCatalog`, `BadgeRenderer`, and the existing badge-domain architecture as the canonical rendering path, and focus the foundation on semantic-axis definitions, term rules, severity rules, and minimal contract enforcement at the shared badge boundary.
|
||||
- Rationale: The repo already has centralized rendering infrastructure and audit work describes the plumbing as sound. The systemic problem is not how badges render but how different domains feed overloaded meanings into the same rendering layer.
|
||||
- Alternatives considered:
|
||||
- Replace the entire badge system with a new UI-state framework: rejected because it solves the wrong problem and would create avoidable churn.
|
||||
- Fix each badge mapper independently without a shared foundation: rejected because that would reproduce the current drift under new names.
|
||||
- Keep the current mappings and only improve helper copy: rejected because false-warning color and label semantics would remain structurally wrong.
|
||||
|
||||
## Decision 2: Publish one canonical reference document under product documentation
|
||||
|
||||
- Decision: The shared taxonomy reference should be published as `docs/product/operator-semantic-taxonomy.md` and treated as the source of truth for downstream domain specs and operator-facing copy decisions.
|
||||
- Rationale: The audit material, roadmap, and spec-candidate sequencing already live under product-oriented documentation. The taxonomy is a product truth document first and a code contract second, so it belongs where product and engineering follow-up specs can reference it consistently.
|
||||
- Alternatives considered:
|
||||
- Put the reference only inside the spec folder: rejected because downstream specs and ongoing product work need a durable cross-spec reference after planning is complete.
|
||||
- Put the reference under `docs/ui/`: rejected because the problem is not purely visual UI guidance; it is product semantics that affect notifications, audit prose, and run summaries as well.
|
||||
- Encode the taxonomy only in code comments: rejected because non-code stakeholders and future spec authors need a stable human-readable reference.
|
||||
|
||||
## Decision 3: The first slice should include bounded real adoption, not only prose
|
||||
|
||||
- Decision: The foundation slice should ship both the shared taxonomy definition and a bounded adoption set covering operations, evidence and review completeness, baseline snapshot semantics, and restore semantics.
|
||||
- Rationale: A reference document without any applied adoption leaves the repo vulnerable to immediate semantic drift and would not prove that the taxonomy is executable. The spec's acceptance criteria require an applied adoption set.
|
||||
- Alternatives considered:
|
||||
- Ship only the reference document and defer all code adoption: rejected because it would not create any enforcement or executable proof.
|
||||
- Apply the taxonomy to every affected domain in one release: rejected because the rollout would become too large and hard to validate.
|
||||
- Apply the taxonomy only to one domain such as baselines: rejected because the feature's value is cross-domain alignment, not a local cleanup.
|
||||
|
||||
## Decision 4: Diagnostic-only states need a contract-level severity guard
|
||||
|
||||
- Decision: Introduce a shared badge-contract concept that distinguishes primary operator states from diagnostic-only states and supports a guard that rejects warning or danger severity on diagnostic-only values.
|
||||
- Rationale: The audit identifies product-support and renderer-maturity facts as repeatedly surfacing as warnings. If the foundation only defines prose rules, a future mapper can silently violate them. A contract-level guard makes the taxonomy enforceable in CI.
|
||||
- Alternatives considered:
|
||||
- Rely on reviewer discipline only: rejected because the problem is systemic and already slipped through multiple domains.
|
||||
- Encode the rule as page-level helper methods: rejected because local helpers do not create a cross-domain invariant.
|
||||
- Ban all gray or informational diagnostic states: rejected because diagnostic-only information still needs a supported visual form.
|
||||
|
||||
## Decision 5: Valid-empty and freshness must be treated as separate cross-domain concerns
|
||||
|
||||
- Decision: The taxonomy must explicitly separate valid-empty, completeness, and freshness semantics, and the first slice should apply that rule to `EvidenceCompletenessState` and `TenantReviewCompletenessState` in addition to the more visible operations, baseline, and restore surfaces.
|
||||
- Rationale: The audit identifies false-red `Missing` states and passive-gray `Stale` states as trust-damaging patterns. These are foundational semantics, not niche edge cases, and they affect both tenant detail surfaces and canonical review surfaces.
|
||||
- Alternatives considered:
|
||||
- Leave completeness and freshness to downstream evidence-only work: rejected because the taxonomy would remain incomplete and operators would keep seeing the most obvious false alarms.
|
||||
- Model freshness as only helper copy, not a state axis: rejected because severity and attention meaning are the core issue.
|
||||
- Treat zero-data states uniformly as `Missing`: rejected because valid-empty tenants are not failures.
|
||||
|
||||
## Decision 6: Operations should be the proving ground for cause-specific, action-oriented states
|
||||
|
||||
- Decision: Use operations outcomes, notifications, and summary lines as the first proving ground for the taxonomy's `blocked`, `partial`, and summary-language rules.
|
||||
- Rationale: `OperationRunOutcomeBadge`, `OperationUxPresenter`, and `SummaryCountsNormalizer` already centralize cross-domain operator language for many flows. Improving them demonstrates immediate product value and sets the vocabulary that reason-code translation will later consume.
|
||||
- Alternatives considered:
|
||||
- Start with restore only because it is safety-critical: rejected because operations are more centralized and affect more domains quickly.
|
||||
- Start with provider health only: rejected because it would not address the broadest shared language path.
|
||||
- Defer operations until reason-code translation exists: rejected because the taxonomy is the prerequisite that reason-code translation depends on.
|
||||
|
||||
## Decision 7: Downstream domain specs should consume the taxonomy, but the foundation may still touch a few low-isolation cross-domain terms
|
||||
|
||||
- Decision: Keep major domain rewrites in follow-up specs, but allow the foundation to directly normalize low-isolation cross-domain terms that appear in many places and do not justify their own dedicated spec, such as simple completeness or support-maturity labels.
|
||||
- Rationale: The audit and candidate notes treat major baselines, restore, evidence, and provider semantics as downstream work. However, some overloaded terms are lightweight and shared enough that the foundation should resolve them centrally to avoid leaving obvious contradictions in place.
|
||||
- Alternatives considered:
|
||||
- Forbid all code changes outside shared badge contracts: rejected because the spec requires a real adoption set.
|
||||
- Pull all domain-specific enum redesign into the foundation: rejected because that would turn the foundation into an unshippable monolith.
|
||||
- Leave lightweight terms untouched until every downstream spec lands: rejected because drift would remain visible on day one.
|
||||
|
||||
## Decision 8: The rollout needs explicit adoption order and guard-backed stop points
|
||||
|
||||
- Decision: Roll out in this order: shared reference and badge contract, operations wording, evidence and review completeness semantics, baseline snapshot semantics, then restore semantics. Each step should be guarded before the next begins.
|
||||
- Rationale: The order follows both strategic value and centralization. Shared contracts come first, then the most centralized cross-domain wording path, then the most obvious false-alarm states, then the domain-specific high-noise and high-risk surfaces.
|
||||
- Alternatives considered:
|
||||
- Roll out by team ownership rather than semantic priority: rejected because the trust problem is cross-domain and needs one operator-centered order.
|
||||
- Roll out baselines before operations: rejected because operations provide faster shared leverage and a natural bridge to later reason-code translation.
|
||||
- Extend the first slice into provider, inventory, onboarding, or verification semantics: rejected because it would blur accountability and make the semantic cleanup harder to validate.
|
||||
- Roll out everything behind one big feature flag: rejected because the repo already uses test-backed staged hardening more effectively than dormant global flags.
|
||||
|
||||
## Decision 9: Meta-fallback must stay diagnostic and must not inflate baseline gap counts
|
||||
|
||||
- Decision: Treat `meta_fallback` and renderer-fallback notes as diagnostic evidence-maturity context, not as primary coverage gaps.
|
||||
- Rationale: The shipped baseline slice now separates `Support limited`, `Mixed evidence detail`, and fallback notes from true coverage gaps. Operators should only see `Coverage gaps need review` when a real data-coverage issue exists.
|
||||
- Alternatives considered:
|
||||
- Count every fallback or renderer limitation as a gap: rejected because it recreates the false-warning problem this feature is fixing.
|
||||
- Hide fallback detail entirely: rejected because technical truth still matters for troubleshooting and roadmap follow-up.
|
||||
- Model fallback only in prose outside the taxonomy: rejected because the diagnostic boundary needs a shared, testable contract.
|
||||
|
||||
## Decision 10: First-slice taxonomy labels should be resolved in already-authorized views, not through global search
|
||||
|
||||
- Decision: Keep the adopted operations, evidence snapshot, baseline snapshot, and tenant review resources out of global search while normalizing their canonical and tenant-context labels through `BadgeCatalog`.
|
||||
- Rationale: Filament v5 global search remains a leak surface when shared labels, hints, and summaries can imply hidden tenant state. The implemented slice keeps the vocabulary inside views that already have tenant and workspace authorization.
|
||||
- Alternatives considered:
|
||||
- Make every adopted resource globally searchable immediately: rejected because the slice is semantics-first, not search-first, and the leak risk outweighs the value.
|
||||
- Add page-local label overrides instead of registry-backed options and summaries: rejected because that would reintroduce drift and defeat BADGE-001.
|
||||
- Leave filters and summary labels on raw enum values: rejected because it would preserve the same operator confusion even after the badges were fixed.
|
||||
176
specs/156-operator-outcome-taxonomy/spec.md
Normal file
176
specs/156-operator-outcome-taxonomy/spec.md
Normal file
@ -0,0 +1,176 @@
|
||||
# Feature Specification: Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||
|
||||
**Feature Branch**: `156-operator-outcome-taxonomy`
|
||||
**Created**: 2026-03-21
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Operator Outcome Taxonomy and Cross-Domain State Separation"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace + tenant + canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/admin/operations`
|
||||
- `/admin/operations/{run}`
|
||||
- `/admin/t/{tenant}/...` adopted tenant governance surfaces for evidence, review, baseline, and restore states that present status, outcome, completeness, freshness, or actionability
|
||||
- Workspace-scoped canonical views that aggregate the bounded first-slice states across operations, evidence or review completeness, baselines, and restore workflows
|
||||
- **Data Ownership**:
|
||||
- This feature does not create a new business domain record; it defines the shared operator-facing meaning layer used when existing workspace-owned and tenant-owned records are presented.
|
||||
- Workspace-owned records affected include operation runs, system-console summaries, canonical lists, and shared cross-tenant presentation surfaces.
|
||||
- Tenant-owned records affected in the bounded first slice include baseline snapshots, evidence and review completeness summaries, and restore outcomes as they are rendered to operators.
|
||||
- Underlying source data ownership does not change; only the normalized operator-facing classification of that data changes.
|
||||
- **RBAC**:
|
||||
- Existing workspace membership, tenant entitlement, and capability rules remain the only access boundary for affected surfaces.
|
||||
- This feature does not create a new privilege family; it standardizes how already-authorized operators interpret state once a record is visible.
|
||||
- Non-members and wrong-tenant users remain deny-as-not-found, and the taxonomy must not leak hidden tenant state through shared vocabulary, filter values, counts, badges, or summaries.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: When an operator enters a canonical workspace view from tenant context, the view opens prefiltered to the current tenant and only exposes taxonomy-backed state filters for tenants the operator is entitled to inspect.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: State labels, badge counts, warning totals, empty-state wording, filter options, and aggregated summaries must be computed only from records inside the operator's authorized workspace and tenant scope. Unauthorized tenant states must not be inferable from shared labels such as `Needs attention`, `Blocked`, `Stale`, or `Missing`.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Distinguish Real Governance Risk From Diagnostics (Priority: P1)
|
||||
|
||||
As an operator, I want warning and error states to mean the same thing across the product, so that I can tell whether a record represents a real governance problem, a valid empty state, or only diagnostic product detail.
|
||||
|
||||
**Why this priority**: This is the trust-critical problem the candidate addresses. If warning colors and overloaded terms continue to mean different things across domains, later domain work keeps reproducing false alarms.
|
||||
|
||||
**Independent Test**: Can be fully tested by reviewing a curated set of adopted surfaces containing mixed examples such as blocked runs, stale data, valid-empty evidence, and product-support limitations, and verifying that each example is classified on the correct semantic axis with the correct severity.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a surface shows a valid empty state with no findings or no evidence collected yet, **When** the operator views the record, **Then** the state is rendered as neutral or informational rather than as a danger or warning condition.
|
||||
2. **Given** a surface shows a real governance or execution problem, **When** the operator views the record, **Then** the state is rendered with the shared warning or error vocabulary and not with a domain-local synonym.
|
||||
3. **Given** a surface contains product-support or renderer-maturity detail, **When** the operator views the record, **Then** that detail appears as secondary diagnostics rather than as a primary warning badge.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Know What Happened And Whether Action Is Needed (Priority: P1)
|
||||
|
||||
As an operator, I want every non-green state to explain whether action is required and what kind of problem it is, so that I do not need to reverse-engineer ambiguous labels like `Partial`, `Blocked`, or `Missing`.
|
||||
|
||||
**Why this priority**: Even if colors are corrected, the system still fails operators when non-green states do not say whether the issue is execution failure, stale data, incomplete coverage, or a no-action-needed limitation.
|
||||
|
||||
**Independent Test**: Can be fully tested by checking adopted non-green examples and verifying that each one includes a cause-specific label plus either a next action, a link to the next action, or an explicit `No action needed` explanation.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a run or governance record is blocked by a prerequisite, **When** the operator opens the list or detail surface, **Then** the state names the blocking cause and points to the appropriate next action.
|
||||
2. **Given** a record is stale but otherwise valid, **When** the operator views it, **Then** the state makes freshness the primary issue and does not imply the record is archived or broken.
|
||||
3. **Given** a mixed or partial result is shown, **When** the operator inspects it, **Then** the state clarifies which dimension is partial instead of using an unqualified `Partial` label.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Reuse One Vocabulary Across Domains (Priority: P2)
|
||||
|
||||
As a product owner, I want one shared operator outcome taxonomy used by every adopted domain, so that new governance surfaces do not invent local badge words, color rules, or severity meanings.
|
||||
|
||||
**Why this priority**: This converts a one-time semantic cleanup into a durable product standard. Without a shared taxonomy, every downstream spec reintroduces drift.
|
||||
|
||||
**Independent Test**: Can be fully tested by reviewing the bounded first-slice adoption set across operations, evidence and review completeness, baseline snapshots, and restore surfaces and confirming that the same shared term dictionary and axis rules are applied everywhere.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** two adopted domains display comparable state concepts, **When** the operator moves between them, **Then** the same term keeps the same meaning and severity in both places.
|
||||
2. **Given** a state belongs to a different semantic axis than the one currently shown, **When** the surface renders it, **Then** the system keeps those axes separate instead of flattening them into one overloaded badge.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A record has more than one degraded dimension at the same time, such as successful execution with stale data and partial coverage; the surface must separate those signals instead of collapsing them into one ambiguous warning.
|
||||
- A valid empty tenant with no findings, no evidence snapshot, or no completed operation history must not appear as failed merely because no data exists yet.
|
||||
- A domain still needs to preserve raw technical cause information for diagnostics; the taxonomy must allow secondary detail without letting that detail replace the primary operator message.
|
||||
- The same record appears in both tenant-context and workspace-canonical views; the wording and severity must stay consistent while still respecting scope filtering.
|
||||
- A condition is non-green but intentionally needs no operator action, such as a known product-support limitation; the state must say that no action is required.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls and no new business-domain mutation flow. It defines a shared semantic contract for operator-facing state presentation across existing records and workflows. Where adopted surfaces already create `OperationRun` records, this spec may refine how those outcomes are named, grouped, and explained, but it does not create a second observability model. No safety-critical mutation is hidden behind semantics-only changes; any adopted destructive actions continue to use their existing preview, confirmation, audit, and tenant-isolation rules.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature may change how existing `OperationRun` outcomes are translated and explained on list and detail surfaces, but it does not alter the three-surface feedback contract. Start surfaces remain intent-only. Progress remains confined to active-ops and canonical run detail. Terminal database notifications remain driven by the existing operation lifecycle. `OperationRun.status` and `OperationRun.outcome` transitions remain service-owned, while this spec only standardizes operator-facing meaning, explanation, and summary wording. Any summary-count language exposed to operators must remain numerically stable and semantically translated without leaking internal metric keys.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature affects operator-facing vocabulary in both tenant/admin and workspace-canonical views. Cross-plane access remains deny-as-not-found. Non-members or users outside workspace or tenant scope continue to receive `404`, while in-scope users lacking required capabilities for the underlying resource continue to receive `403`. Authorization is not delegated to semantics. The taxonomy must never cause a hidden record to become inferable via global search, filter values, counts, badges, or summary text. At least one positive and one negative authorization regression test must prove that shared state presentation remains non-member-safe.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature does not touch authentication handshakes.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This feature is a direct BADGE-001 foundation slice. All adopted status-like values must map through centralized semantics rather than page-local badge decisions. The taxonomy must define which categories are primary operator signals versus secondary diagnostics, and tests must cover any newly introduced or remapped values.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** The target objects are operator-facing states shown for runs, baselines, evidence, findings, restore results, inventory or provider posture, and onboarding or verification summaries. Operator vocabulary must preserve one meaning per term across buttons, headers, notifications, helper copy, summaries, and audit prose. Implementation-first terms and backend-only codes must remain secondary details.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature modifies operator-facing meaning on existing Filament pages and resources but does not introduce a new destructive action family. The Action Surface Contract remains satisfied when adopted surfaces preserve canonical inspect affordances, keep mutation actions capability-gated and auditable, and use the shared taxonomy only to clarify state meaning rather than to hide action consequences.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature is primarily a semantic and presentation foundation. Existing screens retain their current page categories and layouts unless a downstream adoption spec changes them. For adopted screens, status badges, empty states, search filters, and summary sections must use the taxonomy consistently. Valid-empty states must have specific explanatory copy and exactly one clear next-step or `No action needed` signal.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-156-001**: The system MUST define one shared operator outcome taxonomy that separates at least execution lifecycle, execution outcome, item-level result, data coverage, evidence depth, product support tier, data freshness, and operator actionability into distinct semantic axes.
|
||||
- **FR-156-002**: The system MUST ensure that a term used as a primary operator label has exactly one meaning across all adopted domains.
|
||||
- **FR-156-003**: The system MUST prevent different semantic axes from being flattened into one overloaded badge, label, or state unless the surface explicitly presents each axis separately.
|
||||
- **FR-156-004**: The system MUST define shared severity and color decision rules so that danger means execution failure, governance breach, or material risk; warning means operator attention is recommended; informational means in-progress or context-only signals; success means the intended outcome was achieved; and neutral means archived, not applicable, or expected non-actionable context.
|
||||
- **FR-156-005**: The system MUST forbid product-support maturity, renderer tier, and other implementation-limitation facts from appearing as primary warning or danger states.
|
||||
- **FR-156-006**: The system MUST classify every adopted state signal as either primary operator meaning or secondary diagnostic detail.
|
||||
- **FR-156-007**: The system MUST require every adopted non-green, non-neutral primary state to include either a next action, a resolution link, or an explicit explanation that no operator action is required.
|
||||
- **FR-156-008**: The system MUST treat valid-empty conditions such as zero findings, no evidence yet collected, or no completed work yet as non-failure states unless a separate adopted rule explicitly says otherwise.
|
||||
- **FR-156-009**: The system MUST require that `Blocked`, `Partial`, `Missing`, `Unsupported`, `Stale`, and similar historically overloaded labels be replaced or qualified so the operator can tell which semantic axis is being described.
|
||||
- **FR-156-010**: The system MUST make freshness a distinct axis from completeness, so stale data is not rendered as archival or product-maturity context.
|
||||
- **FR-156-011**: The system MUST make partiality a qualified state that states what is partial, such as execution coverage, data coverage, or evidence depth, rather than allowing an unqualified partial label.
|
||||
- **FR-156-012**: The system MUST make blocked states cause-specific and action-oriented rather than using bare blocked wording.
|
||||
- **FR-156-013**: The system MUST preserve raw technical reasons and backend precision for diagnostics, but those details MUST remain secondary to the shared operator-facing label.
|
||||
- **FR-156-014**: The system MUST provide one shared taxonomy reference that downstream domain specs and adopted surfaces can consume as the source of truth for operator-facing state semantics.
|
||||
- **FR-156-015**: The first implementation slice MUST apply the taxonomy to a defined adoption set that includes operations, baseline-related surfaces, restore-related surfaces, and evidence plus review completeness as the additional cross-domain family.
|
||||
- **FR-156-016**: The first implementation slice MUST define migration guidance for existing overloaded labels so downstream domain work can replace local synonyms without inventing new ones.
|
||||
- **FR-156-017**: Adopted canonical views and tenant-context views MUST apply the same term dictionary and severity rules while still respecting tenant-scope filtering and deny-as-not-found behavior.
|
||||
- **FR-156-018**: Global search, cross-tenant counts, filter chips, and summary badges MUST remain non-member-safe when they adopt the shared taxonomy.
|
||||
- **FR-156-019**: The feature MUST include at least one positive and one negative authorization regression test proving that taxonomy-backed presentation does not leak unauthorized tenant state.
|
||||
- **FR-156-020**: The feature MUST include regression coverage for valid-empty states, freshness states, blocked states, partial states, and product-support diagnostics so that future domain work cannot silently reintroduce overloaded semantics.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Operations index and detail | Existing operations list and run detail surfaces | Existing controls unchanged | Existing canonical run inspection remains primary | None added by this feature | None added by this feature | Existing CTA unchanged | Existing run actions unchanged | N/A | Existing audit model unchanged | This feature changes state meaning, summary wording, and next-action clarity, not the action set |
|
||||
| Baseline, restore, evidence, and review surfaces | Existing tenant and workspace governance surfaces inside the bounded first slice | Existing controls unchanged | Existing row or detail inspection unchanged | None added by this feature | None added by this feature | Existing CTA unchanged unless empty-state wording must be corrected | Existing actions unchanged | N/A | Existing audit model unchanged | Shared taxonomy applies to badges, summaries, filters, helper copy, and empty states across the adopted bounded slice |
|
||||
| Cross-domain canonical views | Workspace-scoped aggregated views using shared state filters | Existing filters and scope controls unchanged | Existing list inspection unchanged | None added by this feature | None added by this feature | Existing CTA unchanged | Existing actions unchanged | N/A | Existing audit model unchanged | The key requirement is non-member-safe state aggregation and consistent severity meaning |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Operator Outcome Taxonomy**: The shared meaning layer that defines state axes, term dictionary, severity rules, and primary-versus-diagnostic presentation rules.
|
||||
- **State Axis**: One independent dimension of meaning, such as execution outcome, freshness, coverage, or actionability, that must not be collapsed into a different dimension's label.
|
||||
- **Primary Operator Signal**: The first-class label or badge an operator uses to decide whether attention or action is required.
|
||||
- **Diagnostic Detail**: Secondary explanatory context that preserves technical precision without replacing the primary operator signal.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-156-001**: In the defined first-slice adoption set, 100% of adopted primary states map to one declared semantic axis and one shared severity rule.
|
||||
- **SC-156-002**: In focused regression coverage, 100% of adopted valid-empty examples render without warning or danger severity.
|
||||
- **SC-156-003**: In focused regression coverage, 100% of adopted blocked and partial examples include cause-specific or dimension-specific wording rather than bare overloaded labels.
|
||||
- **SC-156-004**: In focused review of 12 curated example cases drawn from operations, evidence or review completeness, baseline snapshots, and restore results, operators can determine whether action is required from the primary state presentation in one inspection step for at least 11 of 12 cases, using the quickstart smoke checklist as the scoring rubric.
|
||||
- **SC-156-005**: In focused authorization regression coverage, 100% of taxonomy-backed canonical views suppress unauthorized tenant labels, counts, and filter values.
|
||||
- **SC-156-006**: In the first implementation slice, no adopted surface uses product-support or renderer-maturity facts as a primary warning or danger signal.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing badge and presentation infrastructure is stable enough to consume a corrected taxonomy without a full visual-system rewrite.
|
||||
- Downstream domain follow-up specs will apply this foundation incrementally rather than requiring all product surfaces to migrate in one release.
|
||||
- Existing reason-code and run-summary work can be aligned to this taxonomy without changing the underlying machine-readable contracts.
|
||||
- The bounded first slice is limited to operations, evidence or review completeness, baseline snapshot semantics, and restore semantics; broader provider, inventory, onboarding, and verification adoption follows in later specs.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing operator-facing status and badge infrastructure
|
||||
- Existing operations, baseline, restore, evidence, and review surfaces that will adopt the taxonomy in the bounded first slice
|
||||
- BADGE-001 and UI naming enforcement work already in flight across the product
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Redesigning the visual theme or component library
|
||||
- Refactoring every operator-facing surface in one release
|
||||
- Changing the underlying business meaning of existing domain records
|
||||
- Replacing raw diagnostic data with simplified summaries only; diagnostics remain available as secondary detail
|
||||
- Applying the taxonomy to provider health, onboarding, verification, findings, or inventory beyond shared guard coverage in this first slice
|
||||
|
||||
## Final Direction
|
||||
|
||||
This spec is the strategically first follow-up because it fixes the product-wide meaning system, not a single domain symptom. It establishes one shared operator vocabulary and severity model so later work on reason-code translation, provider preflight states, baseline semantics, restore clarity, and evidence presentation can converge instead of creating new local interpretations.
|
||||
208
specs/156-operator-outcome-taxonomy/tasks.md
Normal file
208
specs/156-operator-outcome-taxonomy/tasks.md
Normal file
@ -0,0 +1,208 @@
|
||||
# Tasks: Operator Outcome Taxonomy and Cross-Domain State Separation
|
||||
|
||||
**Input**: Design documents from `/specs/156-operator-outcome-taxonomy/`
|
||||
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature because it changes runtime operator-facing semantics, badge meaning, notifications, canonical view presentation, global-search safety, and cross-tenant non-leakage behavior in a Laravel/Pest codebase.
|
||||
**Operations**: This feature reuses existing `OperationRun` records and Operations surfaces. Tasks below preserve the Ops-UX 3-surface contract while improving outcome wording, summary language, blocked guidance, and next-action clarity.
|
||||
**RBAC**: This feature changes presentation on tenant-context and canonical workspace views. Tasks below preserve `404` for non-members or non-entitled actors, `403` for in-scope capability denial, tenant-safe global search and canonical filtering, and non-leakage of unauthorized tenant labels, counts, and filters.
|
||||
**UI Naming**: This feature introduces a canonical operator term dictionary. Tasks below align badges, summaries, notifications, helper copy, and canonical views to one shared vocabulary and remove implementation-first wording from primary operator-facing labels.
|
||||
**Filament UI Action Surfaces**: This feature does not add a new action family. Existing action surfaces remain intact while adopted list, widget, notification, and detail surfaces are updated to use taxonomy-backed state semantics.
|
||||
**Filament UI UX-001**: This feature is not a layout redesign. Adopted screens keep their existing structure while badges, empty-state wording, filters, and summaries are normalized.
|
||||
**Badges**: This feature changes status-like badge semantics. All tasks below continue to use `BadgeCatalog` and `BadgeRenderer` and add guard coverage so diagnostic-only states cannot ship with `warning` or `danger` severity.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently, while the delivery sequence follows the bounded first-slice rollout from the plan.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Establish the taxonomy source of truth, curated example rubric, and guard scaffolding used by all stories.
|
||||
|
||||
- [X] T001 Publish the canonical taxonomy reference scaffold in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/docs/product/operator-semantic-taxonomy.md`
|
||||
- [X] T002 [P] Create the shared taxonomy registry skeleton in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/OperatorOutcomeTaxonomy.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/OperatorSemanticAxis.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/OperatorStateClassification.php`
|
||||
- [X] T003 [P] Create the first-slice taxonomy guard and curated-example harness in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/156-operator-outcome-taxonomy/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Put the shared taxonomy contract and badge enforcement in place before domain adoption begins.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T004 Implement the shared taxonomy axis, term, severity, and next-action policy contract in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/OperatorOutcomeTaxonomy.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/OperatorSemanticAxis.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/OperatorStateClassification.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/OperatorNextActionPolicy.php`
|
||||
- [X] T005 Extend the shared badge boundary to carry taxonomy metadata and enforce diagnostic severity rules in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeSpec.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeCatalog.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeRenderer.php`
|
||||
- [X] T006 [P] Wire taxonomy-aware architectural guard expectations into `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
|
||||
- [X] T007 [P] Add shared badge-contract coverage for taxonomy registration, invalid severity combinations, and required next-action policies in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/BadgeCatalogTest.php` and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php`
|
||||
|
||||
**Checkpoint**: The repo has one shared taxonomy registry, one shared badge-enforcement boundary, and one curated-example rubric, so story-specific adoption can proceed independently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 - Know What Happened And Whether Action Is Needed (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Use operations as the proving ground for cause-specific, action-oriented, and tenant-safe state presentation.
|
||||
|
||||
**Independent Test**: Review adopted operations list, detail, widget, and notification examples and confirm that blocked, partial, stale, and terminal states explain what happened and whether action is required in one inspection step without leaking unauthorized tenant state.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T008 [P] [US2] Extend operations outcome, blocked, and summary-language coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/OperationRunBadgesTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Notifications/OperationRunNotificationTest.php`
|
||||
- [X] T009 [P] [US2] Add positive and negative authorization plus non-leakage coverage for operations surfaces in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Monitoring/OperationsTenantScopeTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/MonitoringOperationsTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T010 [US2] Make operation outcomes taxonomy-backed, cause-specific, and action-oriented in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/OperationRunOutcomeBadge.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OpsUx/OperationUxPresenter.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OpsUx/SummaryCountsNormalizer.php`
|
||||
- [X] T011 [US2] Normalize blocked, failed, and no-action-needed guidance for operation notifications in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/OpsUx/RunFailureSanitizer.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Notifications/OperationRunCompleted.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Notifications/OperationRunQueued.php`
|
||||
- [X] T012 [US2] Update operations list, workspace widget, and run-detail surfaces to render primary labels and next steps consistently in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Widgets/Dashboard/RecentOperations.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/pages/monitoring/operations.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/system/pages/ops/view-run.blade.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when operations surfaces provide the shared actionability vocabulary and remain tenant-safe under positive and negative authorization cases.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 1 - Distinguish Real Governance Risk From Diagnostics (Priority: P1)
|
||||
|
||||
**Goal**: Ensure valid-empty, product-support, and diagnostic-only states stop presenting as real governance warnings on the bounded evidence, review, and baseline slice.
|
||||
|
||||
**Independent Test**: Review adopted evidence, review, and baseline surfaces and confirm valid-empty states are neutral or informational, freshness is distinct from completeness, and support limitations remain diagnostic rather than primary warnings.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T013 [P] [US1] Extend valid-empty, freshness, and explicit no-action-needed coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/TenantReview/TenantReviewBadgeTest.php`
|
||||
- [X] T014 [P] [US1] Extend baseline diagnostic-boundary, secondary-detail, and tenant-scope coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Support/Badges/BaselineSnapshotRenderingBadgeTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/BaselineSnapshotAuthorizationTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/BaselineProfileFoundationScopeTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T015 [US1] Reclassify evidence and tenant-review completeness semantics in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Evidence/EvidenceCompletenessState.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/TenantReviewCompletenessState.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/EvidenceCompletenessBadge.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/TenantReviewCompletenessStateBadge.php`
|
||||
- [X] T016 [US1] Reclassify baseline fidelity and gap semantics so support limitations stay diagnostic in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/SnapshotRendering/FidelityState.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Services/Baselines/SnapshotRendering/GapSummary.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/BaselineSnapshotFidelityBadge.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/BaselineSnapshotGapStatusBadge.php`
|
||||
- [X] T017 [US1] Update evidence, review, and baseline surfaces to show neutral valid-empty states and explicit no-action-needed copy in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/infolists/entries/baseline-snapshot-groups.blade.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/infolists/entries/evidence-dimension-summary.blade.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/infolists/entries/tenant-review-summary.blade.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/pages/monitoring/evidence-overview.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when operators can distinguish real governance warnings from diagnostics across the adopted evidence, review, and baseline slice.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Reuse One Vocabulary Across Domains (Priority: P2)
|
||||
|
||||
**Goal**: Converge restore semantics and cross-view safety across the bounded first-slice adoption set so the same terms keep the same meaning everywhere they appear.
|
||||
|
||||
**Independent Test**: Review the bounded first-slice adoption set across operations, evidence or review completeness, baselines, and restore surfaces and confirm the same shared term dictionary applies everywhere while global search, counts, and filters remain tenant-safe.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T018 [P] [US3] Extend restore semantic-clarity, next-step, and taxonomy-invariant coverage in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/RestoreRunBadgesTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/RestoreUiBadgesTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`
|
||||
- [X] T019 [P] [US3] Add global-search hint-suppression and canonical-view non-leakage coverage for taxonomy-backed labels in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/BaselineSnapshotAuthorizationTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T020 [US3] Qualify restore run, item result, preview, and check semantics with shared axes and next-step rules in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/RestoreRunStatusBadge.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/RestoreResultStatusBadge.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/RestorePreviewDecisionBadge.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/Domains/RestoreCheckSeverityBadge.php`
|
||||
- [X] T021 [US3] Preserve tenant-safe search and scope behavior for taxonomy-backed labels in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Concerns/ScopesGlobalSearchToTenant.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/OperationRunResource.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/EvidenceSnapshotResource.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/BaselineSnapshotResource.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Filament/Resources/TenantReviewResource.php`
|
||||
- [X] T022 [US3] Align restore and adopted canonical surfaces to the shared term dictionary in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/infolists/entries/restore-results.blade.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/forms/components/restore-run-checks.blade.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/infolists/entries/restore-preview.blade.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/widgets/dashboard/needs-attention.blade.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`
|
||||
- [X] T023 [US3] Publish migration guidance and curated example mappings for the bounded adoption slice in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/docs/product/operator-semantic-taxonomy.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/156-operator-outcome-taxonomy/research.md`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/156-operator-outcome-taxonomy/quickstart.md`
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when restore semantics and all adopted canonical or tenant-context surfaces use the same vocabulary and remain safe under scoped search and aggregation behavior.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Finalize contract artifacts, verify the staged rollout, and keep the branch releasable.
|
||||
|
||||
- [X] T024 [P] Align the feature artifacts with the implemented taxonomy decisions in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/156-operator-outcome-taxonomy/contracts/operator-state-presentation.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/156-operator-outcome-taxonomy/contracts/operator-taxonomy-entry.schema.json`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/156-operator-outcome-taxonomy/research.md`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/156-operator-outcome-taxonomy/quickstart.md`
|
||||
- [X] T025 [P] Run the focused Pest suites from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/156-operator-outcome-taxonomy/quickstart.md` covering `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/OperationRunBadgesTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Support/Badges/BaselineSnapshotRenderingBadgeTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Unit/Badges/RestoreRunBadgesTest.php`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Notifications/OperationRunNotificationTest.php`, and `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`
|
||||
- [X] T026 Run formatting for touched files with `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [X] T027 [P] Validate the manual smoke checklist in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/156-operator-outcome-taxonomy/quickstart.md` against `/admin/operations`, `/admin/reviews`, `/admin/monitoring/evidence-overview`, and representative baseline and restore detail surfaces
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1: Setup** has no dependencies and can start immediately.
|
||||
- **Phase 2: Foundational** depends on Phase 1 and blocks all user story work.
|
||||
- **Phase 3: User Story 2** depends on Phase 2 and delivers the recommended MVP slice.
|
||||
- **Phase 4: User Story 1** depends on Phase 2 and should follow the operations proving ground while the shared vocabulary is still fresh.
|
||||
- **Phase 5: User Story 3** depends on Phase 2 and should land after the two P1 slices prove the taxonomy on real runtime paths.
|
||||
- **Phase 6: Polish** depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 2 (P1)** should start first after the foundational phase because operations are the proving ground for the shared actionability vocabulary.
|
||||
- **User Story 1 (P1)** should follow User Story 2 to remove the highest-volume false-warning patterns from the bounded evidence, review, and baseline slice.
|
||||
- **User Story 3 (P2)** depends on the foundational phase and should follow the two P1 slices so restore semantics and cross-view safety build on proven vocabulary.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests MUST be written and fail before implementation.
|
||||
- Shared taxonomy and badge-enforcement work must exist before any domain mapper rewrites begin.
|
||||
- Shared surfaces and helper copy should be updated after the underlying badge or enum semantics are in place.
|
||||
- Canonical-view non-leakage and global-search safety checks should pass before finalizing any cross-tenant-facing wording changes.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T002` and `T003` can run in parallel.
|
||||
- `T006` and `T007` can run in parallel after `T004` and `T005` define the shared contract.
|
||||
- `T008` and `T009` can run in parallel within User Story 2.
|
||||
- `T013` and `T014` can run in parallel within User Story 1.
|
||||
- `T018` and `T019` can run in parallel within User Story 3.
|
||||
- `T024`, `T025`, and `T027` can run in parallel after implementation is complete.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Split operations semantics from operations RBAC/non-leakage coverage:
|
||||
Task: "Extend operations outcome, blocked, and summary-language coverage in tests/Unit/Badges/OperationRunBadgesTest.php, tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php, tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php, and tests/Feature/Notifications/OperationRunNotificationTest.php"
|
||||
Task: "Add positive and negative authorization plus non-leakage coverage for operations surfaces in tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php, tests/Feature/Monitoring/OperationsTenantScopeTest.php, and tests/Feature/MonitoringOperationsTest.php"
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Split valid-empty coverage from baseline diagnostic-boundary coverage:
|
||||
Task: "Extend valid-empty, freshness, and explicit no-action-needed coverage in tests/Feature/Evidence/EvidenceOverviewPageTest.php, tests/Feature/Evidence/EvidenceSnapshotResourceTest.php, tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php, and tests/Unit/TenantReview/TenantReviewBadgeTest.php"
|
||||
Task: "Extend baseline diagnostic-boundary, secondary-detail, and tenant-scope coverage in tests/Unit/Support/Badges/BaselineSnapshotRenderingBadgeTest.php, tests/Feature/Filament/BaselineSnapshotAuthorizationTest.php, and tests/Feature/Filament/BaselineProfileFoundationScopeTest.php"
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Split restore semantics from search/non-leakage hardening:
|
||||
Task: "Extend restore semantic-clarity, next-step, and taxonomy-invariant coverage in tests/Unit/Badges/RestoreRunBadgesTest.php, tests/Unit/Badges/RestoreUiBadgesTest.php, tests/Feature/OpsUx/RestoreExecutionOperationRunSyncTest.php, and tests/Feature/Guards/NoAdHocStatusBadgesTest.php"
|
||||
Task: "Add global-search hint-suppression and canonical-view non-leakage coverage for taxonomy-backed labels in tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php, tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php, tests/Feature/Evidence/EvidenceSnapshotResourceTest.php, and tests/Feature/Filament/BaselineSnapshotAuthorizationTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 2.
|
||||
4. **Stop and validate** that operations surfaces communicate actionability in one inspection step and remain tenant-safe under positive and negative authorization cases.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Land the shared taxonomy reference, curated example rubric, and badge contract.
|
||||
2. Deliver User Story 2 to prove the shared actionability vocabulary on operations.
|
||||
3. Deliver User Story 1 to remove false-warning patterns from evidence, review, and baseline semantics.
|
||||
4. Deliver User Story 3 to converge restore semantics and harden cross-view search and aggregation safety.
|
||||
5. Finish with Phase 6 regression, formatting, and manual validation.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. One engineer owns the shared taxonomy registry and badge contract in `app/Support/Badges`.
|
||||
2. A second engineer can prepare operations semantics and operations RBAC/non-leakage coverage in parallel once the foundational contract lands.
|
||||
3. A third engineer can prepare evidence, review, and baseline diagnostic-boundary coverage once User Story 2 proves the vocabulary.
|
||||
4. Final restore convergence and global-search hardening land after the two P1 slices are proven.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks are limited to work on different files with no incomplete dependency overlap.
|
||||
- User Story 2 is the recommended MVP because it matches the plan's rollout order and proves the shared actionability vocabulary on the most centralized runtime path.
|
||||
- User Story 1 removes the highest-volume false-warning patterns from the bounded first-slice domains.
|
||||
- User Story 3 finishes the bounded first slice by converging restore semantics and hardening search plus aggregation safety.
|
||||
@ -61,6 +61,13 @@ function seedEvidenceDomain(Tenant $tenant): void
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('disables evidence global search while keeping the view page available', function (): void {
|
||||
$reflection = new ReflectionClass(EvidenceSnapshotResource::class);
|
||||
|
||||
expect($reflection->getStaticPropertyValue('isGloballySearchable'))->toBeFalse()
|
||||
->and(array_keys(EvidenceSnapshotResource::getPages()))->toContain('view');
|
||||
});
|
||||
|
||||
it('returns 404 for non members on the evidence list route', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
@ -244,9 +251,9 @@ function seedEvidenceDomain(Tenant $tenant): void
|
||||
->assertSeeText('Admin consent missing')
|
||||
->assertSeeText('2 privileged Entra roles captured.')
|
||||
->assertSeeText('Global Administrator')
|
||||
->assertSeeText('Evidence snapshot generation · Running')
|
||||
->assertSeeText('Review pack generation · Completed')
|
||||
->assertSeeText('Backup schedule purge · Queued')
|
||||
->assertSeeText('Evidence snapshot generation · In progress')
|
||||
->assertSeeText('Review pack generation · Completed successfully')
|
||||
->assertSeeText('Backup schedule purge · Queued for execution')
|
||||
->assertDontSeeText('Tenant.evidence.snapshot.generate · Pending · Running')
|
||||
->assertSeeText('Copy JSON');
|
||||
});
|
||||
|
||||
@ -22,6 +22,13 @@
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('disables baseline snapshot global search while keeping the view page available', function (): void {
|
||||
$reflection = new ReflectionClass(BaselineSnapshotResource::class);
|
||||
|
||||
expect($reflection->getStaticPropertyValue('isGloballySearchable'))->toBeFalse()
|
||||
->and(array_keys(BaselineSnapshotResource::getPages()))->toContain('view');
|
||||
});
|
||||
|
||||
it('returns 403 for workspace members without the baseline view capability', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -34,24 +34,24 @@
|
||||
'summary_jsonb' => [
|
||||
'total_items' => 5,
|
||||
'fidelity_counts' => ['content' => 3, 'meta' => 2],
|
||||
'gaps' => ['count' => 2, 'by_reason' => ['meta_fallback' => 2]],
|
||||
'gaps' => ['count' => 2, 'by_reason' => ['missing_evidence' => 2]],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Complete')
|
||||
->assertSee('Captured with gaps')
|
||||
->assertSee('Content 5, Meta 0')
|
||||
->assertSee('Content 3, Meta 2');
|
||||
->assertSee('No follow-up needed')
|
||||
->assertSee('Coverage gaps need review')
|
||||
->assertSee('Detailed evidence 5, Metadata only 0')
|
||||
->assertSee('Detailed evidence 3, Metadata only 2');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $withGaps], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Coverage summary')
|
||||
->assertSee('Captured with gaps')
|
||||
->assertSee('Content 3, Meta 2')
|
||||
->assertSee('Coverage gaps need review')
|
||||
->assertSee('Detailed evidence 3, Metadata only 2')
|
||||
->assertSee('Evidence gaps')
|
||||
->assertSee('2');
|
||||
|
||||
@ -59,8 +59,8 @@
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $complete], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Coverage summary')
|
||||
->assertSee('Complete')
|
||||
->assertSee('Content 5, Meta 0')
|
||||
->assertSee('No follow-up needed')
|
||||
->assertSee('Detailed evidence 5, Metadata only 0')
|
||||
->assertSee('Evidence gaps')
|
||||
->assertSee('0');
|
||||
});
|
||||
|
||||
@ -22,7 +22,7 @@ function baselineSnapshotSummary(int $content, int $meta, int $gaps): array
|
||||
],
|
||||
'gaps' => [
|
||||
'count' => $gaps,
|
||||
'by_reason' => $gaps > 0 ? ['meta_fallback' => $gaps] : [],
|
||||
'by_reason' => $gaps > 0 ? ['missing_evidence' => $gaps] : [],
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -94,7 +94,7 @@ function baselineSnapshotFilterIndicatorLabels($component): array
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ListBaselineSnapshots::class)
|
||||
->filterTable('baseline_profile_id', (string) $baselineA->getKey())
|
||||
->filterTable('snapshot_state', 'with_gaps')
|
||||
->filterTable('snapshot_state', 'gaps_present')
|
||||
->set('tableFilters.captured_at.from', now()->subDays(2)->toDateString())
|
||||
->set('tableFilters.captured_at.until', now()->toDateString())
|
||||
->assertCanSeeTableRecords([$matching])
|
||||
@ -130,7 +130,7 @@ function baselineSnapshotFilterIndicatorLabels($component): array
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ListBaselineSnapshots::class)
|
||||
->filterTable('snapshot_state', 'with_gaps')
|
||||
->filterTable('snapshot_state', 'gaps_present')
|
||||
->set('tableFilters.captured_at.from', now()->subDays(2)->toDateString())
|
||||
->set('tableFilters.captured_at.until', now()->toDateString())
|
||||
->assertCanSeeTableRecords([$withGaps])
|
||||
|
||||
@ -16,12 +16,14 @@
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'success',
|
||||
'outcome' => 'succeeded',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(RecentOperationsSummary::class, ['record' => $tenant])
|
||||
->assertSee('Recent operations')
|
||||
->assertSee('Provider connection check')
|
||||
->assertSee('Run finished')
|
||||
->assertSee('No action needed.')
|
||||
->assertDontSee('No operations yet.');
|
||||
});
|
||||
|
||||
@ -191,10 +191,12 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
$response = $this->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run])));
|
||||
$response->assertOk();
|
||||
$response->assertSee('Some items still need follow-up. Review the per-item details below.');
|
||||
$response->assertSee('Manual follow-up needed');
|
||||
$response->assertSee('Graph bulk apply failed');
|
||||
$response->assertSee('Setting missing');
|
||||
$response->assertSee('req-setting-404');
|
||||
$response->assertSee('Assignments: 0 success');
|
||||
$response->assertSee('Assignments: 0 applied');
|
||||
$response->assertSee('Assignment details');
|
||||
$response->assertSee('Graph create failed');
|
||||
});
|
||||
|
||||
@ -2,7 +2,11 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
@ -55,3 +59,10 @@ function tenantSearchTitles($results): array
|
||||
expect($results->first()?->url)
|
||||
->not->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps first-slice taxonomy resources out of global search', function (): void {
|
||||
expect(OperationRunResource::canGloballySearch())->toBeFalse()
|
||||
->and(EvidenceSnapshotResource::canGloballySearch())->toBeFalse()
|
||||
->and(BaselineSnapshotResource::canGloballySearch())->toBeFalse()
|
||||
->and(TenantReviewResource::canGloballySearch())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
$root.'/public/build',
|
||||
];
|
||||
|
||||
$statusLikeTokenPattern = '/[\'"](?:queued|running|completed|pending|active|expiring|expired|rejected|revoked|superseded|succeeded|partial|failed|cancelled|canceled|applied|dry_run|manual_required|mapped_existing|created|created_copy|skipped|blocking|acknowledged|new|risk_accepted|low|medium|high)[\'"]/';
|
||||
$statusLikeTokenPattern = '/[\'"](?:queued|running|completed|completed_with_errors|pending|active|expiring|expired|rejected|revoked|superseded|succeeded|partial|failed|cancelled|canceled|aborted|applied|dry_run|manual_required|mapped|mapped_existing|created|created_copy|skipped|blocking|acknowledged|new|risk_accepted|low|medium|high)[\'"]/';
|
||||
$inlineColorStartPattern = '/->color\\s*\\(\\s*(?:fn|function)\\b/';
|
||||
$inlineLabelStartPattern = '/->formatStateUsing\\s*\\(\\s*(?:fn|function)\\b/';
|
||||
|
||||
|
||||
43
tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php
Normal file
43
tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\Badges\OperatorStateClassification;
|
||||
|
||||
it('never allows diagnostic taxonomy entries to use warning or danger colors', function (): void {
|
||||
$violations = [];
|
||||
|
||||
foreach (OperatorOutcomeTaxonomy::all() as $domain => $entries) {
|
||||
foreach ($entries as $state => $entry) {
|
||||
if ($entry['classification'] !== OperatorStateClassification::Diagnostic) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! in_array($entry['color'], ['warning', 'danger'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$violations[] = sprintf('%s:%s => %s', $domain, $state, $entry['color']);
|
||||
}
|
||||
}
|
||||
|
||||
expect($violations)->toBeEmpty("Diagnostic taxonomy entries must not use warning or danger colors:\n".implode("\n", $violations));
|
||||
});
|
||||
|
||||
it('does not keep overloaded bare labels in the adopted taxonomy slice', function (): void {
|
||||
$forbiddenLabels = ['Blocked', 'Missing', 'Partial', 'Stale', 'Unsupported', 'Warning', 'Safe'];
|
||||
$violations = [];
|
||||
|
||||
foreach (OperatorOutcomeTaxonomy::all() as $domain => $entries) {
|
||||
foreach ($entries as $state => $entry) {
|
||||
if (! in_array($entry['label'], $forbiddenLabels, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$violations[] = sprintf('%s:%s => %s', $domain, $state, $entry['label']);
|
||||
}
|
||||
}
|
||||
|
||||
expect($violations)->toBeEmpty("Overloaded bare operator labels remain in the first-slice taxonomy:\n".implode("\n", $violations));
|
||||
});
|
||||
@ -145,6 +145,14 @@
|
||||
'initiator_name' => 'A-partial',
|
||||
]);
|
||||
|
||||
$runBlockedA = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantA->getKey(),
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'blocked',
|
||||
'initiator_name' => 'A-blocked',
|
||||
]);
|
||||
|
||||
$runFailedA = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantA->getKey(),
|
||||
'type' => 'policy.sync',
|
||||
@ -181,20 +189,34 @@
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Operations::class)
|
||||
->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA])
|
||||
->assertCanSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runBlockedA, $runFailedA])
|
||||
->assertCanNotSeeTableRecords([$runActiveB, $runFailedB])
|
||||
->set('activeTab', 'active')
|
||||
->assertCanSeeTableRecords([$runActiveA])
|
||||
->assertCanNotSeeTableRecords([$runSucceededA, $runPartialA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->assertCanNotSeeTableRecords([$runSucceededA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->set('activeTab', 'blocked')
|
||||
->assertCanSeeTableRecords([$runBlockedA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->set('activeTab', 'succeeded')
|
||||
->assertCanSeeTableRecords([$runSucceededA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runPartialA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runPartialA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->set('activeTab', 'partial')
|
||||
->assertCanSeeTableRecords([$runPartialA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runBlockedA, $runFailedA, $runActiveB, $runFailedB])
|
||||
->set('activeTab', 'failed')
|
||||
->assertCanSeeTableRecords([$runFailedA])
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runActiveB, $runFailedB]);
|
||||
->assertCanNotSeeTableRecords([$runActiveA, $runSucceededA, $runPartialA, $runBlockedA, $runActiveB, $runFailedB]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Blocked by prerequisite')
|
||||
->assertSee('Completed successfully')
|
||||
->assertSee('Needs follow-up')
|
||||
->assertSee('Execution failed');
|
||||
});
|
||||
|
||||
it('prevents cross-workspace access to Monitoring → Operations detail', function () {
|
||||
|
||||
@ -36,6 +36,7 @@
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the run for progress and next steps.');
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($run, $tenant));
|
||||
});
|
||||
@ -86,6 +87,7 @@
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the run for progress and next steps.');
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::tenantlessView($run));
|
||||
});
|
||||
@ -122,11 +124,13 @@
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Inventory sync completed',
|
||||
'data->title' => 'Inventory sync completed successfully',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['body'] ?? null)->toContain('Completed successfully.');
|
||||
expect($notification->data['body'] ?? null)->toContain('No action needed.');
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($run, $tenant));
|
||||
});
|
||||
@ -205,8 +209,9 @@
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['title'] ?? null)->toBe('Backup set update completed with warnings');
|
||||
expect($notification->data['body'] ?? null)->toContain('Completed with warnings.');
|
||||
expect($notification->data['title'] ?? null)->toBe('Backup set update needs follow-up');
|
||||
expect($notification->data['body'] ?? null)->toContain('Completed with follow-up.');
|
||||
expect($notification->data['body'] ?? null)->toContain('Review the affected items before rerunning.');
|
||||
expect($notification->data['body'] ?? null)->toContain('Total: 3');
|
||||
expect($notification->data['body'] ?? null)->toContain('Affected items: 3');
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
|
||||
@ -47,13 +47,14 @@
|
||||
'notifiable_id' => $user->getKey(),
|
||||
'notifiable_type' => $user->getMorphClass(),
|
||||
'type' => OperationRunCompleted::class,
|
||||
'data->title' => 'Inventory sync blocked',
|
||||
'data->title' => 'Inventory sync blocked by prerequisite',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
|
||||
expect($notification)->not->toBeNull()
|
||||
->and($notification->data['body'] ?? null)->toContain('Execution was blocked.')
|
||||
->and($notification->data['body'] ?? null)->toContain('Blocked by prerequisite.')
|
||||
->and($notification->data['body'] ?? null)->toContain('required capability')
|
||||
->and($notification->data['body'] ?? null)->toContain('Review the blocked prerequisite before retrying.')
|
||||
->and($notification->data['body'] ?? null)->toContain('Total: 2');
|
||||
});
|
||||
|
||||
@ -203,12 +203,13 @@
|
||||
])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Execution blocked')
|
||||
->assertSee('Blocked by prerequisite')
|
||||
->assertSee('Blocked reason')
|
||||
->assertSee('Blocked detail')
|
||||
->assertSee('Execution legitimacy revalidation')
|
||||
->assertSee('missing_capability')
|
||||
->assertSee('required capability');
|
||||
->assertSee('required capability')
|
||||
->assertSee('Review the blocked prerequisite before retrying.');
|
||||
});
|
||||
|
||||
it('keeps a canonical run viewer accessible when the remembered tenant differs from the run tenant', function (): void {
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
$toast = OperationUxPresenter::queuedToast('policy.sync');
|
||||
|
||||
expect($toast->getTitle())->toBe('Policy sync queued');
|
||||
expect($toast->getBody())->toBe('Running in the background.');
|
||||
expect($toast->getBody())->toBe('Queued for execution. Open the run for progress and next steps.');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('enforces queued toast duration within 3–5 seconds', function (): void {
|
||||
@ -25,5 +25,5 @@
|
||||
$toast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies');
|
||||
|
||||
expect($toast->getTitle())->toBe('Backup set update already queued');
|
||||
expect($toast->getBody())->toBe('A matching run is already queued or running.');
|
||||
expect($toast->getBody())->toBe('A matching run is already queued or running. No action needed unless it stays stuck.');
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -44,8 +44,9 @@
|
||||
|
||||
$body = (string) ($notification->data['body'] ?? '');
|
||||
|
||||
expect($body)->toContain('Failed.');
|
||||
expect($body)->toContain('Execution failed.');
|
||||
expect($body)->toContain('This is a very long failure message');
|
||||
expect($body)->toContain('Review the run details before retrying.');
|
||||
|
||||
// Ensure message is not full-length / multiline.
|
||||
expect($body)->not->toContain(str_repeat('x', 200));
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorSemanticAxis;
|
||||
use App\Support\Badges\OperatorStateClassification;
|
||||
|
||||
it('returns a safe unknown badge spec for unknown values', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'not-a-real-status');
|
||||
@ -24,3 +26,11 @@
|
||||
'primary',
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries taxonomy metadata on first-slice badge specs', function (): void {
|
||||
$spec = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, 'unsupported');
|
||||
|
||||
expect($spec->semanticAxis)->toBe(OperatorSemanticAxis::ProductSupportMaturity)
|
||||
->and($spec->classification)->toBe(OperatorStateClassification::Diagnostic)
|
||||
->and($spec->diagnosticLabel)->toBe('Fallback renderer');
|
||||
});
|
||||
|
||||
@ -7,37 +7,37 @@
|
||||
|
||||
it('maps operation run status values to canonical badge semantics', function (): void {
|
||||
$queued = BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'queued');
|
||||
expect($queued->label)->toBe('Queued');
|
||||
expect($queued->color)->toBe('warning');
|
||||
expect($queued->label)->toBe('Queued for execution');
|
||||
expect($queued->color)->toBe('info');
|
||||
|
||||
$running = BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'running');
|
||||
expect($running->label)->toBe('Running');
|
||||
expect($running->label)->toBe('In progress');
|
||||
expect($running->color)->toBe('info');
|
||||
|
||||
$completed = BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'completed');
|
||||
expect($completed->label)->toBe('Completed');
|
||||
expect($completed->label)->toBe('Run finished');
|
||||
expect($completed->color)->toBe('gray');
|
||||
});
|
||||
|
||||
it('maps operation run outcome values to canonical badge semantics', function (): void {
|
||||
$pending = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'pending');
|
||||
expect($pending->label)->toBe('Pending');
|
||||
expect($pending->label)->toBe('Awaiting result');
|
||||
expect($pending->color)->toBe('gray');
|
||||
|
||||
$succeeded = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'succeeded');
|
||||
expect($succeeded->label)->toBe('Succeeded');
|
||||
expect($succeeded->label)->toBe('Completed successfully');
|
||||
expect($succeeded->color)->toBe('success');
|
||||
|
||||
$partial = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'partially_succeeded');
|
||||
expect($partial->label)->toBe('Partially succeeded');
|
||||
expect($partial->label)->toBe('Completed with follow-up');
|
||||
expect($partial->color)->toBe('warning');
|
||||
|
||||
$blocked = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'blocked');
|
||||
expect($blocked->label)->toBe('Blocked');
|
||||
expect($blocked->label)->toBe('Blocked by prerequisite');
|
||||
expect($blocked->color)->toBe('warning');
|
||||
|
||||
$failed = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'failed');
|
||||
expect($failed->label)->toBe('Failed');
|
||||
expect($failed->label)->toBe('Execution failed');
|
||||
expect($failed->color)->toBe('danger');
|
||||
|
||||
$cancelled = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'cancelled');
|
||||
|
||||
58
tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php
Normal file
58
tests/Unit/Badges/OperatorOutcomeTaxonomyTest.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Badges\OperatorNextActionPolicy;
|
||||
use App\Support\Badges\OperatorOutcomeTaxonomy;
|
||||
use App\Support\Badges\OperatorSemanticAxis;
|
||||
use App\Support\Badges\OperatorStateClassification;
|
||||
|
||||
it('defines curated examples for the first-slice adoption set', function (): void {
|
||||
expect(OperatorOutcomeTaxonomy::curatedExamples())->toHaveCount(12);
|
||||
});
|
||||
|
||||
it('registers taxonomy metadata for every first-slice entry', function (): void {
|
||||
foreach (OperatorOutcomeTaxonomy::all() as $entries) {
|
||||
foreach ($entries as $entry) {
|
||||
expect($entry['axis'])->toBeInstanceOf(OperatorSemanticAxis::class)
|
||||
->and($entry['classification'])->toBeInstanceOf(OperatorStateClassification::class)
|
||||
->and($entry['next_action_policy'])->toBeInstanceOf(OperatorNextActionPolicy::class)
|
||||
->and($entry['label'])->not->toBe('')
|
||||
->and($entry['notes'])->not->toBe('');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('qualifies overloaded operator-facing labels in the first slice', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::OperationRunStatus, 'queued')->label)->toBe('Queued for execution')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, 'blocked')->label)->toBe('Blocked by prerequisite')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, 'missing')->label)->toBe('Not collected yet')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'stale')->label)->toBe('Refresh review inputs')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, 'unsupported')->label)->toBe('Support limited')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'partial')->label)->toBe('Applied with follow-up')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'manual_required')->label)->toBe('Manual follow-up needed')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'warning')->label)->toBe('Review before running');
|
||||
});
|
||||
|
||||
it('rejects diagnostic warning or danger taxonomy combinations', function (): void {
|
||||
expect(fn (): BadgeSpec => new BadgeSpec(
|
||||
label: 'Invalid diagnostic warning',
|
||||
color: 'warning',
|
||||
semanticAxis: OperatorSemanticAxis::EvidenceDepth,
|
||||
classification: OperatorStateClassification::Diagnostic,
|
||||
nextActionPolicy: OperatorNextActionPolicy::None,
|
||||
))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('rejects primary warning badges without a next-action policy', function (): void {
|
||||
expect(fn (): BadgeSpec => new BadgeSpec(
|
||||
label: 'Invalid primary warning',
|
||||
color: 'warning',
|
||||
semanticAxis: OperatorSemanticAxis::ExecutionOutcome,
|
||||
classification: OperatorStateClassification::Primary,
|
||||
nextActionPolicy: OperatorNextActionPolicy::None,
|
||||
))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
@ -11,28 +11,36 @@
|
||||
expect($draft->color)->toBe('gray');
|
||||
|
||||
$previewed = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'previewed');
|
||||
expect($previewed->label)->toBe('Previewed');
|
||||
expect($previewed->label)->toBe('Preview ready');
|
||||
expect($previewed->color)->toBe('gray');
|
||||
|
||||
$queued = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'queued');
|
||||
expect($queued->label)->toBe('Queued');
|
||||
expect($queued->color)->toBe('warning');
|
||||
expect($queued->label)->toBe('Queued for execution');
|
||||
expect($queued->color)->toBe('info');
|
||||
|
||||
$running = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'running');
|
||||
expect($running->label)->toBe('Running');
|
||||
expect($running->label)->toBe('Applying restore');
|
||||
expect($running->color)->toBe('info');
|
||||
|
||||
$completed = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'completed');
|
||||
expect($completed->label)->toBe('Completed');
|
||||
expect($completed->label)->toBe('Applied successfully');
|
||||
expect($completed->color)->toBe('success');
|
||||
|
||||
$partial = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'partial');
|
||||
expect($partial->label)->toBe('Partial');
|
||||
expect($partial->label)->toBe('Applied with follow-up');
|
||||
expect($partial->color)->toBe('warning');
|
||||
|
||||
$completedWithErrors = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'completed_with_errors');
|
||||
expect($completedWithErrors->label)->toBe('Applied with follow-up');
|
||||
expect($completedWithErrors->color)->toBe('warning');
|
||||
|
||||
$failed = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'failed');
|
||||
expect($failed->label)->toBe('Failed');
|
||||
expect($failed->label)->toBe('Restore failed');
|
||||
expect($failed->color)->toBe('danger');
|
||||
|
||||
$aborted = BadgeCatalog::spec(BadgeDomain::RestoreRunStatus, 'aborted');
|
||||
expect($aborted->label)->toBe('Stopped early');
|
||||
expect($aborted->color)->toBe('gray');
|
||||
});
|
||||
|
||||
it('never represents a completed outcome with warning/attention meaning', function (): void {
|
||||
@ -43,14 +51,14 @@
|
||||
|
||||
it('maps restore safety check severity values to canonical badge semantics', function (): void {
|
||||
$blocking = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'blocking');
|
||||
expect($blocking->label)->toBe('Blocking');
|
||||
expect($blocking->label)->toBe('Fix before running');
|
||||
expect($blocking->color)->toBe('danger');
|
||||
|
||||
$warning = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'warning');
|
||||
expect($warning->label)->toBe('Warning');
|
||||
expect($warning->label)->toBe('Review before running');
|
||||
expect($warning->color)->toBe('warning');
|
||||
|
||||
$safe = BadgeCatalog::spec(BadgeDomain::RestoreCheckSeverity, 'safe');
|
||||
expect($safe->label)->toBe('Safe');
|
||||
expect($safe->label)->toBe('Ready to continue');
|
||||
expect($safe->color)->toBe('success');
|
||||
});
|
||||
|
||||
@ -7,15 +7,23 @@
|
||||
|
||||
it('maps restore preview decisions to canonical badge semantics', function (): void {
|
||||
$created = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'created');
|
||||
expect($created->label)->toBe('Created');
|
||||
expect($created->label)->toBe('Will create');
|
||||
expect($created->color)->toBe('success');
|
||||
|
||||
$mappedExisting = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'mapped_existing');
|
||||
expect($mappedExisting->label)->toBe('Mapped existing');
|
||||
expect($mappedExisting->label)->toBe('Will map existing');
|
||||
expect($mappedExisting->color)->toBe('info');
|
||||
|
||||
$createdCopy = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'created_copy');
|
||||
expect($createdCopy->label)->toBe('Will create copy');
|
||||
expect($createdCopy->color)->toBe('warning');
|
||||
|
||||
$skipped = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'skipped');
|
||||
expect($skipped->label)->toBe('Will skip');
|
||||
expect($skipped->color)->toBe('gray');
|
||||
|
||||
$failed = BadgeCatalog::spec(BadgeDomain::RestorePreviewDecision, 'failed');
|
||||
expect($failed->label)->toBe('Failed');
|
||||
expect($failed->label)->toBe('Cannot apply');
|
||||
expect($failed->color)->toBe('danger');
|
||||
});
|
||||
|
||||
@ -25,14 +33,22 @@
|
||||
expect($applied->color)->toBe('success');
|
||||
|
||||
$dryRun = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'dry_run');
|
||||
expect($dryRun->label)->toBe('Dry run');
|
||||
expect($dryRun->label)->toBe('Preview only');
|
||||
expect($dryRun->color)->toBe('info');
|
||||
|
||||
$mapped = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'mapped');
|
||||
expect($mapped->label)->toBe('Mapped to existing item');
|
||||
expect($mapped->color)->toBe('info');
|
||||
|
||||
$skipped = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'skipped');
|
||||
expect($skipped->label)->toBe('Not applied');
|
||||
expect($skipped->color)->toBe('gray');
|
||||
|
||||
$manualRequired = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'manual_required');
|
||||
expect($manualRequired->label)->toBe('Manual required');
|
||||
expect($manualRequired->label)->toBe('Manual follow-up needed');
|
||||
expect($manualRequired->color)->toBe('warning');
|
||||
|
||||
$failed = BadgeCatalog::spec(BadgeDomain::RestoreResultStatus, 'failed');
|
||||
expect($failed->label)->toBe('Failed');
|
||||
expect($failed->label)->toBe('Apply failed');
|
||||
expect($failed->color)->toBe('danger');
|
||||
});
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
],
|
||||
'gaps' => [
|
||||
'count' => 1,
|
||||
'by_reason' => ['meta_fallback' => 1],
|
||||
'by_reason' => ['missing_evidence' => 1],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
@ -30,7 +30,8 @@
|
||||
->and($rendered['typeLabel'])->toBe('Mystery Policy Type')
|
||||
->and($rendered['referenceStatus'])->toBe('Policy version')
|
||||
->and($rendered['fidelity'])->toBe('unsupported')
|
||||
->and(data_get($rendered, 'gapSummary.has_gaps'))->toBeTrue()
|
||||
->and(data_get($rendered, 'gapSummary.has_gaps'))->toBeFalse()
|
||||
->and(data_get($rendered, 'gapSummary.messages.0'))->toBe('A fallback renderer is being used for this item.')
|
||||
->and(collect($rendered['structuredAttributes'])->pluck('label')->all())
|
||||
->toContain('Category', 'Platform', 'Evidence source');
|
||||
});
|
||||
|
||||
@ -22,3 +22,11 @@
|
||||
'missing completeness' => [BadgeDomain::EvidenceCompleteness, 'missing'],
|
||||
'stale completeness' => [BadgeDomain::EvidenceCompleteness, 'stale'],
|
||||
]);
|
||||
|
||||
it('reclassifies valid-empty and freshness evidence states with explicit labels', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, 'complete')->label)->toBe('Coverage ready')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, 'missing')->label)->toBe('Not collected yet')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, 'missing')->color)->toBe('info')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, 'stale')->label)->toBe('Refresh recommended')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, 'stale')->color)->toBe('warning');
|
||||
});
|
||||
|
||||
@ -10,11 +10,11 @@
|
||||
$referenceOnly = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, 'reference_only');
|
||||
$unsupported = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, 'unsupported');
|
||||
|
||||
expect($full->label)->toBe('Full')
|
||||
expect($full->label)->toBe('Detailed evidence')
|
||||
->and($full->color)->toBe('success')
|
||||
->and($referenceOnly->label)->toBe('Reference only')
|
||||
->and($referenceOnly->label)->toBe('Metadata only')
|
||||
->and($referenceOnly->color)->toBe('info')
|
||||
->and($unsupported->label)->toBe('Unsupported')
|
||||
->and($unsupported->label)->toBe('Support limited')
|
||||
->and($unsupported->color)->toBe('gray');
|
||||
});
|
||||
|
||||
@ -22,8 +22,8 @@
|
||||
$clear = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotGapStatus, 'clear');
|
||||
$present = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotGapStatus, 'gaps_present');
|
||||
|
||||
expect($clear->label)->toBe('No gaps')
|
||||
expect($clear->label)->toBe('No follow-up needed')
|
||||
->and($clear->color)->toBe('success')
|
||||
->and($present->label)->toBe('Gaps present')
|
||||
->and($present->label)->toBe('Coverage gaps need review')
|
||||
->and($present->color)->toBe('warning');
|
||||
});
|
||||
|
||||
@ -14,8 +14,9 @@
|
||||
});
|
||||
|
||||
it('maps tenant review completeness values to canonical badge semantics', function (): void {
|
||||
expect(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'complete')->label)->toBe('Complete')
|
||||
expect(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'complete')->label)->toBe('Review inputs ready')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'partial')->color)->toBe('warning')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'missing')->color)->toBe('danger')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'stale')->label)->toBe('Stale');
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'missing')->label)->toBe('Review input pending')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'missing')->color)->toBe('info')
|
||||
->and(BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, 'stale')->label)->toBe('Refresh review inputs');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user