Compare commits
4 Commits
192-record
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| efd4f31ba3 | |||
| 68be99e27b | |||
| bef9020159 | |||
| 9f6985291e |
7
.github/agents/copilot-instructions.md
vendored
7
.github/agents/copilot-instructions.md
vendored
@ -169,6 +169,10 @@ ## Active Technologies
|
||||
- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode)
|
||||
- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders (192-record-header-discipline)
|
||||
- PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned (192-record-header-discipline)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders (193-monitoring-action-hierarchy)
|
||||
- PostgreSQL through existing workspace-owned and tenant-owned models; no schema change planned (193-monitoring-action-hierarchy)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -203,7 +207,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 193-monitoring-action-hierarchy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders
|
||||
- 192-record-header-discipline: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders
|
||||
- 191-baseline-compare-operator-mode: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns
|
||||
- 190-baseline-compare-matrix: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -1,24 +1,37 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 2.0.0 -> 2.1.0
|
||||
- Version change: 2.2.0 -> 2.3.0
|
||||
- Modified principles:
|
||||
- UX-001 (Layout & IA): header action line strengthened from SHOULD to MUST
|
||||
with cross-reference to new HDR-001
|
||||
- UI-CONST-001: expanded to make TenantPilot's decision-first
|
||||
governance identity explicit
|
||||
- UI-REVIEW-001: spec and PR review gates expanded for surface role,
|
||||
human-in-the-loop justification, workflow-vs-storage IA, and
|
||||
attention-load reduction
|
||||
- Immediate Retrofit Priorities: expanded with a classification-first
|
||||
wave for existing surfaces
|
||||
- Added sections:
|
||||
- Header Action Discipline & Contextual Navigation (HDR-001)
|
||||
- Decision-First Operating Model & Progressive Disclosure
|
||||
(DECIDE-001)
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .specify/memory/constitution.md
|
||||
- ✅ .specify/templates/plan-template.md (Constitution Check: HDR-001 added)
|
||||
- ✅ .specify/templates/tasks-template.md (Filament UI section: HDR-001 added)
|
||||
- ⚠ .specify/templates/spec-template.md (no changes needed; existing
|
||||
UI/UX Surface Classification and Operator Surface Contract tables already
|
||||
cover header action placement implicitly)
|
||||
- ✅ .specify/templates/plan-template.md (Constitution Check updated for
|
||||
decision-first surface roles, workflow-first IA, and calm-surface
|
||||
review)
|
||||
- ✅ .specify/templates/spec-template.md (surface role classification,
|
||||
operator contract, and requirements updated for decision-first
|
||||
governance)
|
||||
- ✅ .specify/templates/tasks-template.md (implementation task guidance
|
||||
updated for progressive disclosure, single-case context, and
|
||||
attention-load reduction)
|
||||
- ✅ docs/product/standards/README.md (Constitution index updated for
|
||||
DECIDE-001)
|
||||
- Commands checked:
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||
- Follow-up TODOs:
|
||||
- None.
|
||||
- Create a dedicated surface / IA classification spec to retrofit
|
||||
existing surfaces against DECIDE-001.
|
||||
-->
|
||||
|
||||
# TenantPilot Constitution
|
||||
@ -318,13 +331,189 @@ ### Operator-Facing UI/UX Constitution v1 (UI-CONST-001)
|
||||
|
||||
Purpose and scope
|
||||
- This section governs operator-facing admin UI semantics across TenantPilot / TenantAtlas.
|
||||
- It defines allowed surface types, allowed interaction models, primary/secondary/destructive action hierarchy, list/detail/queue semantics, scope and context signals, canonical navigation and naming rules, visibility of critical operational truth, scanability and density rules, exception handling, and review and enforcement requirements.
|
||||
- It defines decision-first prominence roles, allowed surface types,
|
||||
allowed interaction models, primary/secondary/destructive action
|
||||
hierarchy, list/detail/queue semantics, scope and context signals,
|
||||
canonical navigation and naming rules, visibility of critical
|
||||
operational truth, scanability and density rules, exception handling,
|
||||
and review and enforcement requirements.
|
||||
- It does not govern branding, colors, typography, spacing tokens, marketing or landing pages, implementation details without UX effect, purely cosmetic copy changes, or backend architecture except where backend design would create false UI mental models.
|
||||
- This section is governance, not a style guide. Its purpose is to prevent ambiguity, operator risk, and UI drift before they spread through the product.
|
||||
|
||||
#### Decision-First Operating Model & Progressive Disclosure (DECIDE-001)
|
||||
|
||||
Goal: TenantPilot is primarily a governance and decision platform, not
|
||||
a browser for internal technical detail objects. This section governs
|
||||
surface prominence and default information depth. It is orthogonal to
|
||||
UI-SURF-001 and ACTSURF-001: every operator-facing surface MUST declare
|
||||
both its interaction model and its decision-role prominence.
|
||||
|
||||
##### Surface prominence roles
|
||||
|
||||
- Every operator-facing surface MUST declare exactly one decision-role
|
||||
prominence:
|
||||
- Primary Decision Surface
|
||||
- Secondary Context Surface
|
||||
- Tertiary Evidence / Diagnostics Surface
|
||||
- Decision-role prominence is separate from action-surface class and
|
||||
detailed surface type.
|
||||
- Prominence determines what deserves top-level navigation, default
|
||||
emphasis, and default-visible information depth.
|
||||
|
||||
##### Primary surfaces are for human decisions
|
||||
|
||||
- Primary Decision Surfaces MUST support a clear human-in-the-loop
|
||||
moment such as attention prioritization, approval, risk acceptance or
|
||||
rejection, drift / findings / exception triage, review completion,
|
||||
evaluation of blocked or failed automations, or execution /
|
||||
escalation of the next governance action.
|
||||
- A prominent surface MUST NOT exist primarily to display internal
|
||||
model objects, raw data, diagnostics, or technical object hubs
|
||||
without clear operator value.
|
||||
- Every proposed primary surface MUST answer: what concrete decision or
|
||||
operator action does this surface support?
|
||||
|
||||
##### Detail surfaces are evidence surfaces
|
||||
|
||||
- OperationRun detail, evidence detail, policy version detail, audit
|
||||
log detail, JSON / payload / diff views, and deep diagnostic contexts
|
||||
are normally Secondary Context or Tertiary Evidence / Diagnostics
|
||||
surfaces.
|
||||
- These surfaces remain essential for verification, diagnosis, and
|
||||
auditability, but they MUST NOT dominate default operator workflows
|
||||
or primary navigation merely because the underlying objects exist.
|
||||
|
||||
##### Default to decisions, not details
|
||||
|
||||
- Default-visible information MUST first answer what happened, why it
|
||||
matters, how urgent it is, what the system recommends, what impact
|
||||
the decision has, and what action or approval is required now.
|
||||
- Internal IDs, relation depth, raw payloads, full snapshot history,
|
||||
debug views, and unstructured technical detail MUST stay secondary
|
||||
unless they are required for the first decision.
|
||||
|
||||
##### Progressive disclosure is the default
|
||||
|
||||
- Depth MUST be preserved but revealed on demand through
|
||||
expand/collapse, drawers, tabs, side panels, explicit "Show details"
|
||||
affordances, or focused drill-downs from a clear decision context.
|
||||
- The default workflow SHOULD let the operator decide before navigating
|
||||
through diagnostic depth.
|
||||
- Primary flows MUST NOT force operators through multiple technical
|
||||
subpages before a single governance decision can be made.
|
||||
|
||||
##### Navigation follows workflows, not storage structures
|
||||
|
||||
- Primary navigation and prominent entry points MUST follow operator
|
||||
workflows such as pending decisions, alerts / escalations, reviews,
|
||||
exceptions / accepted risks, governance priorities, and blocked or
|
||||
failed automations.
|
||||
- Internal persistence terms such as OperationRuns, EvidenceItems,
|
||||
PolicyVersions, StoredReports, or relational chains MAY exist as
|
||||
supporting surfaces, but they do not earn primary information
|
||||
architecture status by default.
|
||||
- Every navigation proposal MUST answer: does this reflect a working
|
||||
task or only an internal storage structure?
|
||||
|
||||
##### Meaning comes before model names
|
||||
|
||||
- Operator-facing surfaces MUST prefer governance language such as
|
||||
"Drift detected", "Exception expires soon", "Evidence incomplete",
|
||||
"Review ready", "Remediation recommended", or "Further review
|
||||
required".
|
||||
- Model names, table/entity language, relation terminology, and
|
||||
implementation-first state labels MUST NOT be the primary UX
|
||||
language when business meaning can be expressed directly.
|
||||
|
||||
##### One case, one decision context
|
||||
|
||||
- A single governance case SHOULD be decidable within one focused
|
||||
context that brings together the problem, risk or relevance,
|
||||
recommendation, impact, ownership, next action, approval options, and
|
||||
optional detail beneath or beside the decision.
|
||||
- Operators MUST NOT be forced to reconstruct one decision across
|
||||
multiple equal-rank Run, Evidence, Policy, Audit, and Finding pages
|
||||
when the product can present one coherent decision context.
|
||||
|
||||
##### Audit depth is mandatory; dominance is not
|
||||
|
||||
- Enterprise-grade evidence, verification, and audit depth MUST remain
|
||||
available.
|
||||
- Audit requirements do NOT justify default surfaces that look or
|
||||
behave like forensic diagnostics consoles.
|
||||
- The standard operator flow SHOULD remain calm, prioritized, and
|
||||
decision-led even when deep proof is available.
|
||||
|
||||
##### New primary surfaces require strict justification
|
||||
|
||||
- Every new top-level or otherwise prominent surface MUST justify:
|
||||
1. which human-in-the-loop moment it supports,
|
||||
2. why an existing surface is insufficient,
|
||||
3. why a drawer, panel, tab, or embedded decision context is
|
||||
insufficient,
|
||||
4. what search, review, or click work it removes.
|
||||
- If those answers are weak, the work MUST reuse an existing decision
|
||||
context or remain secondary/tertiary.
|
||||
|
||||
##### Automation must reduce attention load
|
||||
|
||||
- New automation, notification, or autonomous governance behavior MUST
|
||||
measurably reduce search work, review work, or click load.
|
||||
- Automation that primarily creates extra lists, statuses, surfaces, or
|
||||
detail work is non-conformant even if technically correct.
|
||||
- The review question is: does this make the platform quieter and
|
||||
clearer, or merely larger?
|
||||
|
||||
##### Calm default surfaces
|
||||
|
||||
- The default workspace experience MUST distinguish clearly between
|
||||
immediately actionable work, worth-watching context, and
|
||||
reference-only information.
|
||||
- Unranked warning floods, parallel attention entry points, and
|
||||
perpetual visual escalation are forbidden on primary surfaces.
|
||||
- A surface that only creates noise instead of priority is
|
||||
non-conformant.
|
||||
|
||||
##### Retrofit requirement
|
||||
|
||||
- DECIDE-001 applies to existing as well as new surfaces.
|
||||
- Existing surfaces MUST be reclassified as Primary Decision,
|
||||
Secondary Context, or Tertiary Evidence / Diagnostics surfaces and
|
||||
then reviewed for prominence, disclosure, consolidation, and
|
||||
workflow alignment.
|
||||
- Surface retrofit work SHOULD prefer reclassification and
|
||||
consolidation before creating new navigation branches.
|
||||
|
||||
##### Review gate
|
||||
|
||||
Every operator-facing spec or PR that changes a surface MUST answer:
|
||||
1. What concrete decision or operator action does this support?
|
||||
2. Who is the human in the loop?
|
||||
3. What MUST be immediately visible for the first decision?
|
||||
4. What is preserved but only revealed on demand?
|
||||
5. Is this a Primary Decision Surface, Secondary Context Surface, or
|
||||
Tertiary Evidence / Diagnostics Surface?
|
||||
6. If it is primary, why can it not live inside an existing decision
|
||||
context?
|
||||
7. Does the navigation reflect a workflow or only storage structure?
|
||||
8. Does this reduce search, review, or click work?
|
||||
9. Does this make the product calmer and clearer instead of louder?
|
||||
|
||||
#### Surface Taxonomy (UI-SURF-001)
|
||||
|
||||
Every new admin surface MUST be assigned exactly one surface type before implementation. Ad-hoc interaction models are forbidden.
|
||||
Every new admin surface MUST be assigned exactly one broad action-surface
|
||||
class before implementation. Ad-hoc interaction models are forbidden.
|
||||
|
||||
The allowed broad action-surface classes are:
|
||||
- Record / Detail / Edit
|
||||
- Monitoring / Queue / Workbench
|
||||
- List / Table / Bulk
|
||||
- Wizard / Flow
|
||||
- Utility / System
|
||||
|
||||
Operator-facing surfaces MUST also declare exactly one detailed surface
|
||||
type from the taxonomy below. The broad class determines the action
|
||||
hierarchy first; the detailed surface type refines it.
|
||||
|
||||
##### CRUD / List-first Resource
|
||||
- Purpose: scan, find, open, and selectively mutate many business records.
|
||||
@ -377,6 +566,157 @@ ##### Detail-first Operational Surface
|
||||
- Destructive actions: detail header or grouped header actions only, always with confirmation.
|
||||
- Row click and explicit View/Inspect: not applicable.
|
||||
|
||||
#### Action Surface Discipline (ACTSURF-001)
|
||||
|
||||
Goal: actions across all surfaces MUST make the next sensible operator
|
||||
step obvious, keep safe navigation distinct from mutation, and prevent
|
||||
dangerous or governance-relevant actions from sitting casually beside
|
||||
harmless context changes.
|
||||
|
||||
##### Surface class first
|
||||
|
||||
- Every new or materially changed surface MUST declare exactly one broad
|
||||
action-surface class before actions are designed.
|
||||
- Different surface classes MAY use different action models only when
|
||||
the difference is deliberate, documented, and justified by the
|
||||
workflow.
|
||||
- Detailed surface types refine the rule set; they do not replace the
|
||||
broad class requirement.
|
||||
|
||||
##### Record / Detail / Edit surfaces
|
||||
|
||||
- Classic record/detail/edit pages MUST expose at most one visible
|
||||
primary header action.
|
||||
- Pure navigation MUST NOT live in the header when it can be placed
|
||||
inline at summary, field, badge, status, or related-context level.
|
||||
- Secondary, rare, or administrative actions MUST be grouped.
|
||||
- Multiple equally weighted mutation buttons in the header are
|
||||
forbidden.
|
||||
- Destructive, irreversible, or governance-relevant actions MUST be
|
||||
clearly separated from routine actions.
|
||||
- The likely next operator step MUST be recognizable within seconds.
|
||||
- HDR-001 is the binding specialization for record/detail/edit headers.
|
||||
|
||||
##### Monitoring / Queue / Workbench surfaces
|
||||
|
||||
- Surface-level context, scope context, navigation, selection actions,
|
||||
and object actions MUST NOT be mixed as one flat header strip.
|
||||
- Scope indicators are context signals, not ordinary calls to action.
|
||||
- Selection-dependent actions SHOULD become prominent only when a
|
||||
selection or focused object actually exists.
|
||||
- Record-page header rules MUST NOT be copied blindly onto workbench
|
||||
surfaces.
|
||||
- Workbench surfaces MAY use a different action model, but that model
|
||||
MUST be explicit, repeatable, and internally consistent.
|
||||
|
||||
##### List / Table / Bulk surfaces
|
||||
|
||||
- Inspect/open affordances MUST remain consistent within the same
|
||||
surface class.
|
||||
- Bulk actions are allowed only for genuine multi-record work.
|
||||
- Row actions MUST NOT dominate reading and scanning.
|
||||
- Rare, destructive, or governance-relevant actions MUST NOT accumulate
|
||||
casually in default row actions.
|
||||
- Tables exist primarily to scan, filter, compare, and decide; they
|
||||
MUST NOT become unstructured action stockpiles.
|
||||
|
||||
##### Wizard / Flow surfaces
|
||||
|
||||
- Wizard actions MUST reflect staged progression, explicit back/cancel
|
||||
semantics, and safe confirmation at the step where risk becomes real.
|
||||
- Wizard pages MAY expose more than one visible action when the flow
|
||||
genuinely requires progression, backtracking, or guarded cancellation.
|
||||
- Even in a wizard, the next primary step MUST remain obvious.
|
||||
|
||||
##### Utility / System surfaces
|
||||
|
||||
- Utility and system pages MAY use narrower tooling-oriented action
|
||||
sets, but they MUST still separate safe navigation, routine control,
|
||||
and dangerous intervention.
|
||||
- System or recovery status does not justify casual placement of
|
||||
destructive or governance-changing actions.
|
||||
|
||||
##### Action grouping and order
|
||||
|
||||
- Actions MUST be ordered by meaning, frequency, and risk.
|
||||
- The preferred order is:
|
||||
1. primary next step
|
||||
2. common secondary action
|
||||
3. rare or contextual action
|
||||
4. dangerous or irreversible action
|
||||
- An `ActionGroup` / More menu is not a junk drawer. Navigation,
|
||||
mutation, external links, and destructive actions inside a group MUST
|
||||
still be named, ordered, and separated coherently.
|
||||
|
||||
##### Navigation vs mutation
|
||||
|
||||
- Navigation and mutation are different intent classes and MUST NOT
|
||||
appear as equal-weight peers without explicit hierarchy.
|
||||
- Harmless context switches MUST NOT visually overpower
|
||||
governance-relevant actions.
|
||||
- Pure context navigation SHOULD live near the content it concerns
|
||||
rather than as header filler.
|
||||
|
||||
##### Governance friction
|
||||
|
||||
- Actions with risk, blast radius, or irreversible effect MUST use
|
||||
shared governance-friction rules rather than per-surface improvisation.
|
||||
- Depending on impact, the required friction is confirmation, optional
|
||||
reason, mandatory reason, typed confirmation, or staged flow.
|
||||
- Clear danger semantics and separated placement are mandatory for
|
||||
dangerous or governance-changing actions.
|
||||
|
||||
##### Exceptions require explicit reason
|
||||
|
||||
- New surfaces MAY deviate only when the surface class or workflow truly
|
||||
requires it.
|
||||
- Allowed justification labels are:
|
||||
- Special type
|
||||
- Workflow hub
|
||||
- Wizard
|
||||
- Utility / System surface
|
||||
- Another clearly defined exception documented in the governing spec
|
||||
- "Historically grew this way" and "it was easy to add to the header"
|
||||
are invalid reasons.
|
||||
|
||||
##### Reuse before invention
|
||||
|
||||
- New features MUST reuse existing disciplined patterns, reference
|
||||
architectures, and shared primitives when they fit the chosen surface
|
||||
class.
|
||||
- Reference patterns are reuse baselines, not automatic mandates for
|
||||
every surface.
|
||||
|
||||
##### Constitution over convenience
|
||||
|
||||
- Local implementation speed MUST NOT override consistent action
|
||||
hierarchy.
|
||||
- No new feature may introduce:
|
||||
- multiple equal-rank header mutations without a clear primary
|
||||
- navigation as casual header filler
|
||||
- unreflective mixing of record, workbench, and governance patterns
|
||||
- new local exceptions without explicit rationale
|
||||
|
||||
##### Review gate
|
||||
|
||||
Every new or materially changed surface with actions MUST answer:
|
||||
1. What broad action-surface class is it?
|
||||
2. What is the one most likely next operator action?
|
||||
3. Is navigation cleanly separated from mutation?
|
||||
4. Are rare or risky actions removed from the primary plane?
|
||||
5. Is the hierarchy scanable in a few seconds?
|
||||
6. Is this a real special type or just an unordered exception?
|
||||
|
||||
If those answers are not clear, the surface is non-conformant.
|
||||
|
||||
##### Canonical outcome
|
||||
|
||||
- The goal is not the smallest possible number of buttons.
|
||||
- A conformant surface highlights the next sensible step, separates
|
||||
context, navigation, mutation, and danger cleanly, remains structured
|
||||
as capability grows, and applies the same principles consistently
|
||||
across the product.
|
||||
|
||||
#### Hard Rules (UI-HARD-001)
|
||||
|
||||
##### Primary inspect model
|
||||
@ -509,6 +849,8 @@ #### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||
|
||||
Behavior over declaration
|
||||
- Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix.
|
||||
- Every changed operator-facing surface MUST declare its broad
|
||||
action-surface class and the one most likely next operator action.
|
||||
- Custom action-surface contracts are legitimate only when they validate rendered behavior, not only declarations or slot counts.
|
||||
- A change is not Done unless the implemented interaction semantics conform to the declared surface type or an approved exception documents and tests the deviation.
|
||||
|
||||
@ -532,7 +874,10 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
|
||||
|
||||
Actions and flows
|
||||
- Pages MUST expose at most one primary header action and one secondary header action; all others belong in groups (see HDR-001 for the full header discipline rule).
|
||||
- Record / Detail / Edit pages MUST expose at most one visible primary
|
||||
header action. Any additional visible secondary header action requires
|
||||
explicit justification under ACTSURF-001 / HDR-001; the rest belong in
|
||||
groups or contextual placement.
|
||||
- Multi-step or high-risk flows MUST use a wizard or an equivalent staged flow with preview and confirmation.
|
||||
- Destructive actions remain non-primary and confirmed.
|
||||
|
||||
@ -545,9 +890,12 @@ #### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
- Shared layout builders such as `MainAsideForm`, `MainAsideInfolist`, and `StandardTableDefaults` SHOULD be reused where available.
|
||||
- A change is not Done unless UX-001 is satisfied or an approved exception documents why not.
|
||||
|
||||
#### Header Action Discipline & Contextual Navigation (HDR-001)
|
||||
#### Record / Detail / Edit Header Discipline & Contextual Navigation (HDR-001)
|
||||
|
||||
Goal: record, detail, and edit pages MUST be comprehensible within
|
||||
seconds. HDR-001 is the binding record/detail/edit specialization of
|
||||
ACTSURF-001.
|
||||
|
||||
Goal: record and detail pages MUST be comprehensible within seconds.
|
||||
Header actions are reserved for the primary workflow of the current page
|
||||
and MUST NOT become a dumping ground for every available action or
|
||||
navigation jump.
|
||||
@ -565,6 +913,8 @@ ##### Maximum one primary visible header action
|
||||
primary visible header action.
|
||||
- That action MUST represent the most obvious next operator step on
|
||||
exactly this page.
|
||||
- Multiple equally weighted mutation buttons in the header are
|
||||
forbidden.
|
||||
|
||||
##### Navigation does not belong in headers
|
||||
|
||||
@ -594,6 +944,8 @@ ##### Rare secondary actions belong in an Action Group
|
||||
or are only occasionally needed MUST NOT appear as equally weighted
|
||||
visible header buttons.
|
||||
- They MUST be placed in an Action Group.
|
||||
- The Action Group itself MUST remain structured; it MUST NOT become an
|
||||
unlabelled mix of navigation, external links, mutations, and danger.
|
||||
|
||||
##### Header clarity over implementation convenience
|
||||
|
||||
@ -744,17 +1096,58 @@ #### Spec Scope Fields (SCOPE-002)
|
||||
#### Enforcement Model (UI-REVIEW-001)
|
||||
|
||||
Spec review requirements
|
||||
- Every spec that changes an operator-facing surface MUST answer: surface type, primary inspect/open model, row-click rule, whether explicit View/Inspect exists or is forbidden, where secondary actions live, where destructive actions live, canonical collection route, canonical detail route, scope signals and their exact meaning, canonical noun, critical truth visible by default, and whether an exception type is used.
|
||||
- Every spec that changes an operator-facing surface MUST answer:
|
||||
decision-role prominence, human-in-the-loop moment, immediate-visible
|
||||
decision information, on-demand evidence/diagnostics boundary,
|
||||
whether a new primary surface is actually justified, broad
|
||||
action-surface class, detailed surface type, one likely next operator
|
||||
action, primary inspect/open model, row-click rule, whether explicit
|
||||
View/Inspect exists or is forbidden, where navigation lives, where
|
||||
secondary actions live, where destructive actions live, how grouped
|
||||
actions are ordered, canonical collection route, canonical detail
|
||||
route, scope signals and their exact meaning, canonical noun,
|
||||
critical truth visible by default, workflow-vs-storage IA
|
||||
justification, attention-load reduction, and whether an exception
|
||||
type is used.
|
||||
- Missing any of those answers makes the spec incomplete.
|
||||
|
||||
PR review requirements
|
||||
- A PR MUST NOT pass when it introduces more than one primary inspect model, redundant View beside row click, destructive inline actions beside inspect on standard lists, empty overflow or bulk groups, long workflow labels in dense rows, misleading scope chips, drifting domain nouns, hidden critical operational truth, or undocumented exceptions without dedicated tests.
|
||||
- A PR MUST NOT pass when it introduces more than one primary inspect
|
||||
model, redundant View beside row click, destructive inline actions
|
||||
beside inspect on standard lists, empty overflow or bulk groups, long
|
||||
workflow labels in dense rows, misleading scope chips, drifting domain
|
||||
nouns, hidden critical operational truth, flat record headers with
|
||||
multiple equal-weight mutations, workbench headers that mix scope,
|
||||
selection, navigation, and object actions as peers, a primary surface
|
||||
with no clear human-in-the-loop purpose, detail/evidence objects
|
||||
promoted into primary navigation without justification, one case
|
||||
fragmented across multiple equal-rank pages, new automation that adds
|
||||
attention surfaces without reducing operator work, noisy default
|
||||
surfaces with no action/watch/reference hierarchy, or undocumented
|
||||
exceptions without dedicated tests.
|
||||
|
||||
Guard tests
|
||||
- Repository guards SHOULD validate: declared surface type, conformant primary inspect model, absence of redundant View actions, presence of explicit Inspect on Queue / Review and History / Audit surfaces, absence of empty `ActionGroup` or `BulkActionGroup`, correct placement of destructive actions, truthful scope signals, stable canonical nouns across shells, and dedicated tests for every approved exception.
|
||||
- Repository guards SHOULD validate: declared surface type, declared
|
||||
decision-role prominence where specs or metadata expose it,
|
||||
conformant primary inspect model, absence of redundant View actions,
|
||||
presence of explicit Inspect on Queue / Review and History / Audit
|
||||
surfaces, absence of empty `ActionGroup` or `BulkActionGroup`,
|
||||
correct placement of destructive actions, truthful scope signals,
|
||||
stable canonical nouns across shells, and dedicated tests for every
|
||||
approved exception.
|
||||
|
||||
#### Immediate Retrofit Priorities
|
||||
|
||||
Wave 0 - Surface role classification
|
||||
- First classify existing surfaces as Primary Decision, Secondary
|
||||
Context, or Tertiary Evidence / Diagnostics surfaces.
|
||||
- For each surface, determine whether its current prominence is
|
||||
justified, which detail can move into progressive disclosure, and
|
||||
whether several technical pages should collapse into one decision
|
||||
context.
|
||||
- Wave 0 is done only when primary navigation candidates are grounded
|
||||
in workflows rather than storage structures.
|
||||
|
||||
Wave 1 - Interaction normalization
|
||||
- First fixes target redundant row click plus View, destructive row actions on standard lists, empty overflow or bulk groups, and rows that have become pseudo-control centers.
|
||||
- First-slice focus surfaces are Tenants, Workspaces, Policies, Alert Deliveries, and other CRUD-first list surfaces with the same drift pattern.
|
||||
@ -768,6 +1161,23 @@ #### Immediate Retrofit Priorities
|
||||
|
||||
#### Appendix A - One-page Condensed Constitution
|
||||
|
||||
- Every operator-facing surface declares one decision role:
|
||||
Primary Decision, Secondary Context, or Tertiary Evidence /
|
||||
Diagnostics.
|
||||
- Primary surfaces exist to help a human prioritize, judge, approve,
|
||||
reject, escalate, or act.
|
||||
- Evidence and diagnostics remain available but do not dominate the
|
||||
default workflow.
|
||||
- Default to decisions, not details.
|
||||
- Progressive disclosure preserves depth without forcing it into the
|
||||
first decision.
|
||||
- Navigation follows workflows, not storage structures.
|
||||
- One governance case should be decidable in one focused context.
|
||||
- Automation must reduce attention load.
|
||||
- Default surfaces stay calm, prioritized, and explicit about what is
|
||||
actionable, worth watching, and reference-only.
|
||||
- Every new or materially changed surface declares one broad
|
||||
action-surface class first.
|
||||
- Every admin surface has one surface type.
|
||||
- Every list has exactly one primary inspect/open model.
|
||||
- CRUD and Registry surfaces use one-click open.
|
||||
@ -777,6 +1187,10 @@ #### Appendix A - One-page Condensed Constitution
|
||||
- Destructive actions never sit openly beside inspect on standard lists.
|
||||
- Overflow is standardized per surface class and is never empty.
|
||||
- Bulk exists only when it is genuinely useful.
|
||||
- Navigation and mutation do not share equal visual weight without
|
||||
explicit hierarchy.
|
||||
- Monitoring and workbench surfaces separate scope/context, selection,
|
||||
navigation, and object actions.
|
||||
- Scope chips must be truthful.
|
||||
- Domain nouns are canonical and stable.
|
||||
- Critical operational truth is default-visible.
|
||||
@ -788,11 +1202,24 @@ #### Appendix A - One-page Condensed Constitution
|
||||
|
||||
#### Appendix B - Feature Review Checklist
|
||||
|
||||
- Surface type is declared.
|
||||
- Decision-role prominence is declared.
|
||||
- The human-in-the-loop moment is explicit.
|
||||
- Immediate-visible decision information is explicit.
|
||||
- On-demand evidence / diagnostics boundaries are explicit.
|
||||
- Any new primary surface is justified against an existing decision
|
||||
context.
|
||||
- Navigation reflects a workflow rather than storage structure.
|
||||
- One governance case stays decidable in one focused context.
|
||||
- The feature reduces search, review, or click work.
|
||||
- The resulting surface is calmer and clearer, not merely larger.
|
||||
- Broad action-surface class is declared.
|
||||
- Detailed surface type is declared.
|
||||
- The one most likely next operator action is explicit.
|
||||
- Primary inspect/open model is defined.
|
||||
- Row-click rule is decided.
|
||||
- View/Inspect is correctly present or correctly forbidden.
|
||||
- Edit-as-inspect is used only when allowed.
|
||||
- Navigation and mutation are separated intentionally.
|
||||
- Secondary actions are grouped correctly.
|
||||
- Destructive actions are placed correctly.
|
||||
- Overflow is not empty.
|
||||
@ -806,18 +1233,32 @@ #### Appendix B - Feature Review Checklist
|
||||
- Header passes the 5-second scan rule (HDR-001).
|
||||
- No pure navigation in the header.
|
||||
- Governance-changing actions have extra friction.
|
||||
- Any special type or workflow-hub exception is real and justified.
|
||||
|
||||
#### Appendix C - Red Flags for Future PRs
|
||||
|
||||
- A primary surface has no clear human-in-the-loop moment.
|
||||
- A technical object hub is promoted into primary navigation without
|
||||
workflow justification.
|
||||
- Default-visible content behaves like a diagnostics console instead of
|
||||
a decision surface.
|
||||
- The operator must assemble one decision from multiple equal-rank Run,
|
||||
Evidence, Policy, Audit, or Finding pages.
|
||||
- A feature adds automation, alerts, or statuses that increase net
|
||||
attention load.
|
||||
- The surface creates more noise than priority.
|
||||
- Row click and View open the same destination.
|
||||
- A row becomes a control center.
|
||||
- Archive or Delete sits openly beside View or Inspect on a standard list.
|
||||
- More menus or bulk menus are empty.
|
||||
- A More menu becomes a mixed junk drawer with no ordering logic.
|
||||
- Scope chips have no real scope effect.
|
||||
- Runs and Operations are used as competing primary collection nouns.
|
||||
- Long workflow labels live in dense tables.
|
||||
- Edit is used as default inspect even though a true View surface exists.
|
||||
- Queue surfaces throw the operator out of context through row click.
|
||||
- A workbench surface mixes scope, selection, navigation, and object
|
||||
actions as one flat header rail.
|
||||
- Critical health or operability truth is hidden by default.
|
||||
- A contract claims conformance while the rendered UI behaves differently.
|
||||
- Header has multiple equally weighted buttons without clear prioritization.
|
||||
@ -893,6 +1334,9 @@ ### Scope, Compliance, and Review Expectations
|
||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
||||
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
||||
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
||||
- Specs and PRs that change operator-facing surfaces MUST classify each
|
||||
affected surface under DECIDE-001 and justify any new Primary
|
||||
Decision Surface or workflow-first navigation change.
|
||||
- Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering.
|
||||
- Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it.
|
||||
|
||||
@ -906,4 +1350,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 2.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-07
|
||||
**Version**: 2.3.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-12
|
||||
|
||||
@ -58,6 +58,14 @@ ## Constitution Check
|
||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
|
||||
- Decision-first operating model (DECIDE-001): each changed
|
||||
operator-facing surface is classified as Primary Decision,
|
||||
Secondary Context, or Tertiary Evidence / Diagnostics; primary
|
||||
surfaces justify the human-in-the-loop moment, default-visible info
|
||||
is limited to first-decision needs, deep proof is progressive
|
||||
disclosed, one governance case stays decidable in one context where
|
||||
practical, navigation follows workflows not storage structures, and
|
||||
automation / alerts reduce attention load instead of adding noise
|
||||
- UI/UX inspect model (UI-HARD-001): each list surface has exactly one primary inspect/open model; redundant View beside row click or identifier click is forbidden; edit-as-inspect is limited to Config-lite resources
|
||||
- UI/UX action hierarchy (UI-HARD-001 / UI-EX-001): standard CRUD and Registry rows expose at most one inline safe shortcut; destructive actions are grouped or in the detail header; queue exceptions are catalogued, justified, and tested
|
||||
- UI/UX scope, truth, and naming (UI-HARD-001 / UI-NAMING-001 / OPSURF-001): scope signals are truthful, canonical nouns stay stable across shells, critical operational truth is default-visible, and standard lists remain scanable
|
||||
@ -71,7 +79,14 @@ ## Constitution Check
|
||||
- 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 surface-appropriate inspect affordance, remove redundant View when row click or identifier click already opens the same destination, keep standard CRUD/Registry rows to inspect plus at most one inline safe shortcut, group or relocate the rest to “More” or detail header, forbid empty bulk/overflow groups, require confirmations for destructive actions, 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 (see HDR-001); tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||
- Header action discipline (HDR-001): record/detail pages expose at most 1 primary visible header action; pure navigation (Open finding, Open tenant, View related run, etc.) is placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions are separated and require friction; rare actions live in Action Groups; every record/detail page passes the 5-second scan rule
|
||||
- Action-surface discipline (ACTSURF-001 / HDR-001): every changed
|
||||
surface declares one broad action-surface class; the spec names the
|
||||
one likely next operator action; navigation is separated from
|
||||
mutation; record/detail/edit pages keep at most one visible primary
|
||||
header action; monitoring/workbench surfaces separate scope/context,
|
||||
selection actions, navigation, and object actions; risky or rare
|
||||
actions are grouped and ordered by meaning/frequency/risk; any special
|
||||
type or workflow-hub exception is explicit and justified
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
@ -35,22 +35,37 @@ ## 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]
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds or materially changes an operator-facing surface,
|
||||
fill out one row per affected surface. This role is orthogonal to the
|
||||
Action Surface Class / Surface Type below.
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||
fill out one row per affected surface.
|
||||
fill out one row per affected surface. Declare the broad Action Surface
|
||||
Class first, then the detailed Surface Type. Keep this table in sync
|
||||
with the Decision-First Surface Role section above.
|
||||
|
||||
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | CRUD / List-first Resource | Full-row click | required | One inline safe shortcut + More | More / detail header | /admin/t/{tenant}/policies | /admin/t/{tenant}/policies/{record} | Tenant chip scopes rows and actions | Policies / Policy | Policy health, drift, assignment coverage | none |
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | List / Table / Bulk | CRUD / List-first Resource | Open policy for review | Full-row click | required | One inline safe shortcut + More | More / detail header | /admin/t/{tenant}/policies | /admin/t/{tenant}/policies/{record} | Tenant chip scopes rows and actions | Policies / Policy | Policy health, drift, assignment coverage | none |
|
||||
|
||||
## 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.
|
||||
If this feature adds a new operator-facing page or materially refactors
|
||||
one, fill out one row per affected page/surface. The contract MUST show
|
||||
how one governance case or operator task becomes decidable without
|
||||
unnecessary cross-page reconstruction.
|
||||
|
||||
| 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 |
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| e.g. Tenant policies page | Tenant operator | Decide whether policy state needs follow-up | 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 |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
@ -199,19 +214,50 @@ ## 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 (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
||||
- the chosen surface type and why it is the correct classification,
|
||||
**Constitution alignment (DECIDE-001):** If this feature adds or changes operator-facing surfaces, the spec MUST describe:
|
||||
- whether each affected surface is a Primary Decision Surface,
|
||||
Secondary Context Surface, or Tertiary Evidence / Diagnostics
|
||||
Surface, and why,
|
||||
- which human-in-the-loop moment each primary surface supports,
|
||||
- what MUST be visible immediately for the first decision,
|
||||
- what is preserved but only revealed on demand,
|
||||
- why any new primary surface cannot live inside an existing decision
|
||||
context,
|
||||
- how navigation follows operator workflows rather than storage
|
||||
structures,
|
||||
- how one governance case remains decidable in one focused context,
|
||||
- how any new automation, notifications, or autonomous governance logic
|
||||
reduce search/review/click load,
|
||||
- and how the resulting default experience is calmer and clearer rather
|
||||
than merely larger.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** If this feature adds or changes an operator-facing surface, the spec MUST describe:
|
||||
- the chosen broad action-surface class and why it is the correct classification,
|
||||
- the chosen detailed surface type and why it is the correct refinement,
|
||||
- the one most likely next operator action,
|
||||
- the one and only primary inspect/open model,
|
||||
- whether row click is required, allowed, or forbidden,
|
||||
- whether explicit View or Inspect is present, and why it is present or forbidden,
|
||||
- where pure navigation lives and why it is not competing with mutation,
|
||||
- where secondary actions live,
|
||||
- where destructive actions live,
|
||||
- how grouped actions are ordered by meaning, frequency, and risk,
|
||||
- the canonical collection route and canonical detail route,
|
||||
- the scope signals shown to the operator and what real effect each one has,
|
||||
- the canonical noun used across routes, labels, runs, notifications, and audit prose,
|
||||
- which critical operational truth is visible by default,
|
||||
- and any catalogued exception type, rationale, and dedicated test coverage.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** If this
|
||||
feature adds or materially changes header actions, row actions, bulk
|
||||
actions, or workbench controls, the spec MUST describe:
|
||||
- how navigation, mutation, context signals, selection actions, and
|
||||
dangerous actions are separated,
|
||||
- why any visible secondary action deserves primary-plane placement,
|
||||
- why any ActionGroup is structured rather than a mixed catch-all,
|
||||
- and why any workflow-hub, wizard, system, or other special-type
|
||||
exception is genuine rather than a convenience shortcut.
|
||||
|
||||
**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,
|
||||
|
||||
@ -39,30 +39,62 @@ # Tasks: [FEATURE NAME]
|
||||
- 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:
|
||||
- classifying each affected surface as Primary Decision, Secondary
|
||||
Context, or Tertiary Evidence / Diagnostics and keeping that role in
|
||||
sync with the governing spec,
|
||||
- defining the human-in-the-loop moment and justifying any new Primary
|
||||
Decision Surface against existing decision contexts,
|
||||
- filling the spec’s UI/UX Surface Classification for every affected surface,
|
||||
- filling the spec’s Operator Surface Contract for every affected page,
|
||||
- keeping default-visible content limited to first-decision needs and
|
||||
moving proof, payloads, and diagnostics into progressive disclosure,
|
||||
- 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,
|
||||
- keeping each governance case decidable in one focused context where
|
||||
practical instead of forcing cross-page reconstruction,
|
||||
- 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 canonical nouns stable across routes, buttons, run titles, notifications, and audit prose,
|
||||
- keeping navigation aligned to operator workflows rather than storage
|
||||
structures,
|
||||
- ensuring new automation, alerts, or autonomous flows reduce
|
||||
search/review/click load instead of adding noise, extra lists, or
|
||||
extra detail work,
|
||||
- preserving a calm, prioritized default state that distinguishes
|
||||
actionable work from worth-watching context and reference-only
|
||||
information,
|
||||
- keeping scope signals truthful and ensuring critical operational truth is visible by default,
|
||||
- keeping standard CRUD / Registry rows scanable rather than prose-heavy,
|
||||
- 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,
|
||||
- assigning exactly one broad action-surface class to every changed
|
||||
operator-facing surface and keeping the detailed surface type in sync
|
||||
with the spec,
|
||||
- identifying the one likely next operator action for each changed
|
||||
surface and shaping the visible hierarchy around it,
|
||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||
- ensuring every List/Table has exactly one primary inspect/open model with the correct surface-appropriate affordance,
|
||||
- removing redundant View/Inspect actions when row click or identifier click already opens the same destination,
|
||||
- keeping standard CRUD / Registry rows to inspect/open plus at most one inline safe shortcut,
|
||||
- separating navigation from mutation so pure context changes do not
|
||||
compete visually with state-changing actions,
|
||||
- moving additional secondary actions into More or the detail header,
|
||||
- ordering visible actions and grouped actions by meaning, frequency,
|
||||
and risk rather than append order,
|
||||
- placing destructive actions in More or the detail header for standard lists and using catalogued exceptions only where allowed,
|
||||
- ensuring workbench and monitoring surfaces separate scope/context,
|
||||
selection actions, navigation, and object actions instead of mixing
|
||||
them into one flat header zone,
|
||||
- grouping bulk actions via BulkActionGroup,
|
||||
- preventing empty `ActionGroup` / `BulkActionGroup` placeholders,
|
||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||
- adding `AuditLog` entries for relevant mutations,
|
||||
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
||||
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
||||
- documenting any workflow-hub, wizard, utility/system, or other
|
||||
special-type exception in the spec/PR and adding dedicated test
|
||||
coverage,
|
||||
- documenting any catalogued UI exception in the spec/PR and adding dedicated test coverage,
|
||||
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||
@ -71,8 +103,13 @@ # Tasks: [FEATURE NAME]
|
||||
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),
|
||||
- ensuring View pages use Infolists (not disabled edit forms); status badges use BADGE-001,
|
||||
- ensuring empty states show a specific title + explanation + exactly 1 CTA; non-empty tables move CTA to header,
|
||||
- capping header actions to max 1 primary + 1 secondary (rest grouped),
|
||||
- enforcing HDR-001 header action discipline: at most 1 primary visible action per record/detail page; pure navigation (Open finding, Open tenant, View related run, etc.) placed at the relevant field/badge/relation, NOT in the header; destructive or governance-changing actions separated and requiring friction; rare actions in Action Groups; every record/detail page passing the 5-second scan rule,
|
||||
- enforcing ACTSURF-001 / HDR-001 action discipline: record/detail/edit
|
||||
pages keep at most 1 visible primary header action; pure navigation
|
||||
moves to contextual placement; destructive or governance-changing
|
||||
actions are separated and require friction; monitoring/workbench
|
||||
surfaces use their own layered hierarchy; rare actions live in
|
||||
structured Action Groups; every affected surface passes the few-second
|
||||
scan rule,
|
||||
- using shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) where available,
|
||||
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||
|
||||
@ -12,6 +12,10 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -37,6 +41,16 @@ class Alerts extends Page
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.alerts';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header keeps alerts scope and origin navigation quiet on the page-level overview.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'The alerts overview is a page-level monitoring summary and does not inspect records inline.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The alerts overview does not render row-level secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The alerts overview does not expose bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'The overview always renders KPI widgets and downstream drilldown navigation instead of a list-style empty state.');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -93,7 +94,6 @@ public function mount(): void
|
||||
if ($requestedEventId !== null) {
|
||||
$this->resolveAuditLog($requestedEventId);
|
||||
$this->selectedAuditLogId = $requestedEventId;
|
||||
$this->mountTableAction('inspect', (string) $requestedEventId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,10 +102,24 @@ public function mount(): void
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
$actions = app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_audit_log',
|
||||
returnActionName: 'operate_hub_return_audit_log',
|
||||
);
|
||||
|
||||
$navigationContext = CanonicalNavigationContext::fromRequest(request());
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
array_splice($actions, 1, 0, [
|
||||
Action::make('operate_hub_back_to_origin_audit_log')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
@ -164,30 +165,32 @@ protected function getHeaderActions(): array
|
||||
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
});
|
||||
|
||||
$actions[] = Action::make('clear_selected_exception')
|
||||
$selectedContextActions = [
|
||||
Action::make('clear_selected_exception')
|
||||
->label('Close details')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->action(function (): void {
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
});
|
||||
$this->clearSelectedException();
|
||||
}),
|
||||
|
||||
$actions[] = Action::make('open_selected_exception')
|
||||
Action::make('open_selected_exception')
|
||||
->label('Open tenant detail')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->url(fn (): ?string => $this->selectedExceptionUrl());
|
||||
->url(fn (): ?string => $this->selectedExceptionUrl()),
|
||||
|
||||
$actions[] = Action::make('open_selected_finding')
|
||||
Action::make('open_selected_finding')
|
||||
->label('Open finding')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
||||
->url(fn (): ?string => $this->selectedFindingUrl());
|
||||
->url(fn (): ?string => $this->selectedFindingUrl()),
|
||||
];
|
||||
|
||||
$actions[] = Action::make('approve_selected_exception')
|
||||
$selectedDecisionActions = [
|
||||
Action::make('approve_selected_exception')
|
||||
->label('Approve exception')
|
||||
->color('success')
|
||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||
@ -223,9 +226,9 @@ protected function getHeaderActions(): array
|
||||
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved')
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}),
|
||||
|
||||
$actions[] = Action::make('reject_selected_exception')
|
||||
Action::make('reject_selected_exception')
|
||||
->label('Reject exception')
|
||||
->color('danger')
|
||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
||||
@ -254,7 +257,20 @@ protected function getHeaderActions(): array
|
||||
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected')
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
$actions[] = ActionGroup::make($selectedContextActions)
|
||||
->label('Selected context')
|
||||
->icon('heroicon-o-rectangle-stack')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null);
|
||||
|
||||
$actions[] = ActionGroup::make($selectedDecisionActions)
|
||||
->label('Review selected')
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false);
|
||||
|
||||
return $actions;
|
||||
}
|
||||
@ -409,6 +425,12 @@ public function selectedFindingUrl(): ?string
|
||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||
}
|
||||
|
||||
public function clearSelectedException(): void
|
||||
{
|
||||
$this->selectedFindingExceptionId = null;
|
||||
$this->showSelectedExceptionSummary = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
|
||||
@ -142,6 +142,49 @@ protected function getHeaderActions(): array
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* scope_label: string,
|
||||
* scope_body: string,
|
||||
* return_label: ?string,
|
||||
* return_body: ?string,
|
||||
* scope_reset_label: ?string,
|
||||
* scope_reset_body: ?string,
|
||||
* inspect_body: string
|
||||
* }
|
||||
*/
|
||||
public function landingHierarchySummary(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$navigationContext = $this->navigationContext();
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
|
||||
$returnLabel = null;
|
||||
$returnBody = null;
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$returnLabel = $navigationContext->backLinkLabel;
|
||||
$returnBody = 'Return to the originating monitoring surface without competing with the current tab, filters, or row inspection flow.';
|
||||
} elseif ($activeTenant instanceof Tenant) {
|
||||
$returnLabel = 'Back to '.$activeTenant->name;
|
||||
$returnBody = 'Return to the tenant dashboard when you need tenant-specific context outside this workspace monitoring landing.';
|
||||
}
|
||||
|
||||
return [
|
||||
'scope_label' => $operateHubShell->scopeLabel(request()),
|
||||
'scope_body' => $activeTenant instanceof Tenant
|
||||
? 'The landing is currently narrowed to one tenant inside the active workspace.'
|
||||
: 'The landing is currently showing workspace-wide monitoring across all entitled tenants.',
|
||||
'return_label' => $returnLabel,
|
||||
'return_body' => $returnBody,
|
||||
'scope_reset_label' => $activeTenant instanceof Tenant ? 'Show all tenants' : null,
|
||||
'scope_reset_body' => $activeTenant instanceof Tenant
|
||||
? 'Reset the landing back to workspace-wide monitoring when tenant-specific context is no longer needed.'
|
||||
: null,
|
||||
'inspect_body' => 'Open a run from the table to enter the canonical monitoring detail viewer.',
|
||||
];
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
if (! is_array($this->navigationContextPayload)) {
|
||||
|
||||
@ -123,7 +123,7 @@ protected function getHeaderActions(): array
|
||||
$actions[] = Action::make('refresh')
|
||||
->label('Refresh')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('gray')
|
||||
->color('primary')
|
||||
->url(fn (): string => isset($this->run)
|
||||
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||
: route('admin.operations.index'));
|
||||
@ -155,6 +155,57 @@ protected function getHeaderActions(): array
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* scope_label: string,
|
||||
* scope_body: string,
|
||||
* navigation_label: string,
|
||||
* navigation_body: string,
|
||||
* utility_body: string,
|
||||
* related_body: string,
|
||||
* follow_up_body: string,
|
||||
* follow_up_label: ?string
|
||||
* }
|
||||
*/
|
||||
public function monitoringDetailSummary(): array
|
||||
{
|
||||
$operateHubShell = app(OperateHubShell::class);
|
||||
$navigationContext = $this->navigationContext();
|
||||
$activeTenant = $operateHubShell->activeEntitledTenant(request());
|
||||
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
|
||||
|
||||
$navigationLabel = 'Back to Operations';
|
||||
$navigationBody = 'Return to the operations landing when this review is complete.';
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$navigationLabel = $navigationContext->backLinkLabel;
|
||||
$navigationBody = 'Return to the originating surface while keeping refresh and follow-up work separate from navigation.';
|
||||
} elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
|
||||
$navigationLabel = 'Back to '.$activeTenant->name;
|
||||
$navigationBody = 'Return to the active tenant dashboard, then widen back to the workspace view only when you need broader monitoring context.';
|
||||
}
|
||||
|
||||
$relatedLabels = array_values(array_keys($this->relatedLinks()));
|
||||
$relatedBody = $relatedLabels === []
|
||||
? 'Open keeps secondary drilldowns grouped under one control when downstream context exists.'
|
||||
: 'Open keeps secondary drilldowns grouped under one control: '.implode(', ', $relatedLabels).'.';
|
||||
|
||||
$followUpLabel = $this->canResumeCapture() ? 'Resume capture' : null;
|
||||
|
||||
return [
|
||||
'scope_label' => $operateHubShell->scopeLabel(request()),
|
||||
'scope_body' => 'The current workspace or tenant scope remains visible without behaving like a primary task action.',
|
||||
'navigation_label' => $navigationLabel,
|
||||
'navigation_body' => $navigationBody,
|
||||
'utility_body' => 'Refresh keeps the current run state accurate without changing scope.',
|
||||
'related_body' => $relatedBody,
|
||||
'follow_up_body' => $followUpLabel !== null
|
||||
? 'Resume capture only appears when this run supports additional evidence collection.'
|
||||
: 'No run-specific follow-up is currently available.',
|
||||
'follow_up_label' => $followUpLabel,
|
||||
];
|
||||
}
|
||||
|
||||
public function mount(OperationRun $run): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -364,6 +415,7 @@ private function resumeCaptureAction(): Action
|
||||
return Action::make('resumeCapture')
|
||||
->label('Resume capture')
|
||||
->icon('heroicon-o-forward')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Resume capture')
|
||||
->modalDescription('This will start a follow-up operation to capture remaining baseline evidence for this scope.')
|
||||
@ -532,9 +584,16 @@ private function relatedLinks(bool $fresh = false): array
|
||||
|
||||
$resolver = app(RelatedNavigationResolver::class);
|
||||
|
||||
return $fresh
|
||||
$links = $fresh
|
||||
? $resolver->operationLinksFresh($this->run, $this->relatedLinksTenant())
|
||||
: $resolver->operationLinks($this->run, $this->relatedLinksTenant());
|
||||
|
||||
unset(
|
||||
$links[OperationRunLinks::collectionLabel()],
|
||||
$links[OperationRunLinks::openCollectionLabel()],
|
||||
);
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
private function lifecycleAttentionSummary(bool $fresh = false): ?string
|
||||
|
||||
@ -94,7 +94,7 @@ protected function getHeaderActions(): array
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveFilters())
|
||||
->action(function (): void {
|
||||
$this->resetTable();
|
||||
$this->clearRegisterFilters();
|
||||
}),
|
||||
];
|
||||
}
|
||||
@ -209,7 +209,7 @@ public function table(Table $table): Table
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(fn (): mixed => $this->resetTable()),
|
||||
->action(fn (): mixed => $this->clearRegisterFilters()),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -311,9 +311,29 @@ private function applyRequestedTenantPrefilter(): void
|
||||
|
||||
private function hasActiveFilters(): bool
|
||||
{
|
||||
$filters = array_filter((array) $this->tableFilters);
|
||||
return $this->currentTenantFilterId() !== null
|
||||
|| is_string(data_get($this->tableFilters, 'status.value'))
|
||||
|| is_string(data_get($this->tableFilters, 'completeness_state.value'))
|
||||
|| is_string(data_get($this->tableFilters, 'published_state.value'))
|
||||
|| filled(data_get($this->tableFilters, 'review_date.from'))
|
||||
|| filled(data_get($this->tableFilters, 'review_date.until'));
|
||||
}
|
||||
|
||||
return $filters !== [];
|
||||
private function clearRegisterFilters(): void
|
||||
{
|
||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
||||
$this->removeTableFilters();
|
||||
}
|
||||
|
||||
private function currentTenantFilterId(): ?int
|
||||
{
|
||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
||||
|
||||
if (! is_numeric($tenantFilter)) {
|
||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
||||
}
|
||||
|
||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
||||
}
|
||||
|
||||
private function workspace(): ?Workspace
|
||||
|
||||
@ -6,7 +6,9 @@
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAlertDeliveries extends ListRecords
|
||||
@ -22,9 +24,23 @@ public function mount(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
$actions = app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_alerts',
|
||||
returnActionName: 'operate_hub_return_alerts',
|
||||
);
|
||||
|
||||
$navigationContext = CanonicalNavigationContext::fromRequest(request());
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
array_splice($actions, 1, 0, [
|
||||
Action::make('operate_hub_back_to_origin_alert_deliveries')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,9 @@
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
@ -44,6 +47,7 @@
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
@ -136,7 +140,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture, compare-now, open-matrix, compare-assigned-tenants, and edit actions.');
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page keeps one state-sensitive primary action, moves snapshot and compare-matrix navigation into contextual related context, and groups secondary actions under "More".');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
@ -319,6 +323,15 @@ public static function infolist(Schema $schema): Schema
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
ViewEntry::make('related_context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (BaselineProfile $record): array => self::detailRelatedContextEntries($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
TextEntry::make('createdByUser.name')
|
||||
@ -334,6 +347,37 @@ public static function infolist(Schema $schema): Schema
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function detailRelatedContextEntries(BaselineProfile $record): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
$snapshotEntry = app(RelatedNavigationResolver::class)
|
||||
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $record)[0] ?? null;
|
||||
|
||||
if ($snapshotEntry instanceof RelatedContextEntry) {
|
||||
$entries[] = $snapshotEntry->toArray();
|
||||
}
|
||||
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'compare_matrix',
|
||||
label: 'Compare matrix',
|
||||
value: 'Review compare matrix',
|
||||
secondaryValue: $record->resolveCurrentConsumableSnapshot() instanceof BaselineSnapshot
|
||||
? 'Use the latest consumable snapshot to inspect compare outcomes.'
|
||||
: 'Open the matrix to inspect compare readiness and previous results.',
|
||||
targetUrl: self::compareMatrixUrl($record),
|
||||
targetKind: 'canonical_page',
|
||||
priority: 20,
|
||||
actionLabel: 'Open compare matrix',
|
||||
contextBadge: 'Comparison',
|
||||
)->toArray();
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$workspace = self::resolveWorkspace();
|
||||
|
||||
@ -16,15 +16,13 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -37,26 +35,19 @@ class ViewBaselineProfile extends ViewRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('view_active_snapshot')
|
||||
->label(fn (): string => $this->activeSnapshotEntry()?->actionLabel ?? 'View snapshot')
|
||||
->url(fn (): ?string => $this->activeSnapshotEntry()?->targetUrl)
|
||||
->hidden(fn (): bool => ! ($this->activeSnapshotEntry()?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
$this->captureAction(),
|
||||
$this->compareNowAction(),
|
||||
$this->openCompareMatrixAction(),
|
||||
ActionGroup::make([
|
||||
$this->compareAssignedTenantsAction(),
|
||||
EditAction::make()
|
||||
->visible(fn (): bool => $this->hasManageCapability()),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
];
|
||||
}
|
||||
|
||||
private function activeSnapshotEntry(): ?RelatedContextEntry
|
||||
{
|
||||
return app(RelatedNavigationResolver::class)
|
||||
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $this->getRecord())[0] ?? null;
|
||||
}
|
||||
|
||||
private function captureAction(): Action
|
||||
{
|
||||
/** @var BaselineProfile $profile */
|
||||
@ -78,6 +69,7 @@ private function captureAction(): Action
|
||||
->label($label)
|
||||
->icon('heroicon-o-camera')
|
||||
->color('primary')
|
||||
->hidden(fn (): bool => $this->profileHasConsumableSnapshot())
|
||||
->requiresConfirmation()
|
||||
->modalHeading($label)
|
||||
->modalDescription($modalDescription)
|
||||
@ -190,6 +182,8 @@ private function compareNowAction(): Action
|
||||
return Action::make('compareNow')
|
||||
->label($label)
|
||||
->icon('heroicon-o-play')
|
||||
->color('primary')
|
||||
->hidden(fn (): bool => ! $this->profileHasConsumableSnapshot())
|
||||
->requiresConfirmation()
|
||||
->modalHeading($label)
|
||||
->modalDescription($modalDescription)
|
||||
@ -309,15 +303,6 @@ private function compareNowAction(): Action
|
||||
});
|
||||
}
|
||||
|
||||
private function openCompareMatrixAction(): Action
|
||||
{
|
||||
return Action::make('openCompareMatrix')
|
||||
->label('Open compare matrix')
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->color('gray')
|
||||
->url(fn (): string => BaselineProfileResource::compareMatrixUrl($this->getRecord()));
|
||||
}
|
||||
|
||||
private function compareAssignedTenantsAction(): Action
|
||||
{
|
||||
$action = Action::make('compareAssignedTenants')
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Models\Tenant;
|
||||
@ -18,6 +19,7 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -115,7 +117,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Expire snapshot remains grouped under More.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence as the primary action, keeps Expire snapshot visibly separated as danger, and renders operation/review-pack navigation in contextual related context.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
@ -181,6 +183,15 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
ViewEntry::make('related_context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (EvidenceSnapshot $record): array => static::relatedContextEntries($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Evidence dimensions')
|
||||
->schema([
|
||||
RepeatableEntry::make('items')
|
||||
@ -213,6 +224,48 @@ public static function infolist(Schema $schema): Schema
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
if (is_numeric($record->operation_run_id)) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'operation_run',
|
||||
label: 'Operation',
|
||||
value: sprintf('#%d', (int) $record->operation_run_id),
|
||||
secondaryValue: 'Open the latest evidence refresh operation.',
|
||||
targetUrl: OperationRunLinks::tenantlessView((int) $record->operation_run_id),
|
||||
targetKind: 'canonical_page',
|
||||
priority: 10,
|
||||
actionLabel: OperationRunLinks::openLabel(),
|
||||
contextBadge: 'Operations',
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
$pack = $record->reviewPacks()
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'review_pack',
|
||||
label: 'Review pack',
|
||||
value: sprintf('#%d', (int) $pack->getKey()),
|
||||
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
|
||||
targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant),
|
||||
targetKind: 'direct_record',
|
||||
priority: 20,
|
||||
actionLabel: 'View review pack',
|
||||
contextBadge: 'Reporting',
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
|
||||
@ -5,12 +5,9 @@
|
||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -29,30 +26,11 @@ protected function resolveRecord(int|string $key): Model
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->url(fn (): ?string => $this->record->operation_run_id ? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id) : null)
|
||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id)),
|
||||
Actions\Action::make('view_review_pack')
|
||||
->label('View review pack')
|
||||
->icon('heroicon-o-document-text')
|
||||
->color('gray')
|
||||
->url(function (): ?string {
|
||||
$pack = $this->latestReviewPack();
|
||||
|
||||
if (! $pack instanceof ReviewPack || ! $pack->tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
||||
})
|
||||
->hidden(fn (): bool => ! $this->latestReviewPack() instanceof ReviewPack),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('refresh_snapshot')
|
||||
->label('Refresh evidence')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
@ -92,11 +70,4 @@ protected function getHeaderActions(): array
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
|
||||
private function latestReviewPack(): ?ReviewPack
|
||||
{
|
||||
return $this->record->reviewPacks()
|
||||
->latest('created_at')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingExceptionResource\Pages;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionEvidenceReference;
|
||||
use App\Models\Tenant;
|
||||
@ -20,6 +21,7 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -34,6 +36,7 @@
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
@ -115,7 +118,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.');
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes renewal and revocation only, while linked finding and approval-queue navigation move into contextual related context.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
@ -217,6 +220,15 @@ public static function infolist(Schema $schema): Schema
|
||||
])
|
||||
->columns(3),
|
||||
]),
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
ViewEntry::make('related_context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (FindingException $record): array => static::relatedContextEntries($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Evidence references')
|
||||
->schema([
|
||||
RepeatableEntry::make('evidenceReferences')
|
||||
@ -245,6 +257,44 @@ public static function infolist(Schema $schema): Schema
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function relatedContextEntries(FindingException $record): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
if ($record->finding && $record->tenant instanceof Tenant) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'finding',
|
||||
label: 'Finding',
|
||||
value: static::findingSummary($record),
|
||||
secondaryValue: 'Return to the linked finding detail.',
|
||||
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
|
||||
targetKind: 'direct_record',
|
||||
priority: 10,
|
||||
actionLabel: 'Open finding',
|
||||
contextBadge: 'Governance',
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
if ($record->tenant instanceof Tenant && static::canAccessApprovalQueueForTenant($record->tenant)) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'approval_queue',
|
||||
label: 'Approval queue',
|
||||
value: 'Review pending exception requests',
|
||||
secondaryValue: 'Return to the queue for the rest of this tenant’s governance workload.',
|
||||
targetUrl: static::approvalQueueUrl($record->tenant),
|
||||
targetKind: 'canonical_page',
|
||||
priority: 20,
|
||||
actionLabel: 'Open approval queue',
|
||||
contextBadge: 'Queue',
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -34,40 +33,10 @@ protected function resolveRecord(int|string $key): Model
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('open_finding')
|
||||
->label('Open finding')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(function (): ?string {
|
||||
$record = $this->getRecord();
|
||||
|
||||
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||
}),
|
||||
Action::make('open_approval_queue')
|
||||
->label('Open approval queue')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(function (): bool {
|
||||
$record = $this->getRecord();
|
||||
|
||||
return $record instanceof FindingException
|
||||
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
|
||||
})
|
||||
->url(function (): ?string {
|
||||
$record = $this->getRecord();
|
||||
|
||||
return $record instanceof FindingException
|
||||
? FindingExceptionResource::approvalQueueUrl($record->tenant)
|
||||
: null;
|
||||
}),
|
||||
Action::make('renew_exception')
|
||||
->label('Renew exception')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
|
||||
->fillForm(fn (): array => [
|
||||
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
|
||||
|
||||
@ -46,6 +46,8 @@
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\UnavailableRelationState;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use App\Support\Tenants\TenantActionDescriptor;
|
||||
use App\Support\Tenants\TenantActionSurface;
|
||||
@ -79,6 +81,7 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
@ -175,7 +178,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; overflow keeps helpers first, workflow actions next, and destructive actions last.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view remains the workflow-heavy special type: pure navigation moves into contextual related content while header actions stay grouped into external-link, setup, and lifecycle buckets.');
|
||||
}
|
||||
|
||||
private static function userCanManageAnyTenant(User $user): bool
|
||||
@ -194,9 +197,12 @@ private static function userCanDeleteAnyTenant(User $user): bool
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
// ... [Schema Omitted - No Change] ...
|
||||
return $schema
|
||||
->schema([
|
||||
Forms\Components\Placeholder::make('related_context')
|
||||
->label('Related context')
|
||||
->content(fn (?Tenant $record): HtmlString => static::tenantEditContextHtml($record))
|
||||
->visible(fn (?Tenant $record): bool => $record instanceof Tenant && static::tenantEditContextEntries($record) !== []),
|
||||
Forms\Components\TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
@ -1990,6 +1996,16 @@ public static function infolist(Schema $schema): Schema
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('related_context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (Tenant $record): array => static::tenantViewContextEntries($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull()
|
||||
->visible(fn (Tenant $record): bool => static::tenantViewContextEntries($record) !== []),
|
||||
Section::make('Provider')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('provider_connection_state')
|
||||
@ -2236,6 +2252,166 @@ public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
|
||||
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function tenantViewContextEntries(Tenant $tenant): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
if (static::canEdit($tenant)) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'tenant_edit',
|
||||
label: 'Tenant edit',
|
||||
value: 'Edit tenant',
|
||||
secondaryValue: 'Update tenant identity and lifecycle metadata.',
|
||||
targetUrl: static::getUrl('edit', ['record' => $tenant]),
|
||||
targetKind: 'direct_record',
|
||||
priority: 10,
|
||||
actionLabel: 'Edit',
|
||||
contextBadge: 'Management',
|
||||
);
|
||||
} elseif (static::viewerCanInspectTenantContext($tenant)) {
|
||||
$entries[] = RelatedContextEntry::unavailable(
|
||||
key: 'tenant_edit',
|
||||
label: 'Tenant edit',
|
||||
state: new UnavailableRelationState(
|
||||
relationKey: 'tenant_edit',
|
||||
referenceValue: null,
|
||||
reason: 'authorization_denied',
|
||||
message: UiTooltips::insufficientPermission(),
|
||||
),
|
||||
targetKind: 'direct_record',
|
||||
priority: 10,
|
||||
actionLabel: 'Edit',
|
||||
);
|
||||
}
|
||||
|
||||
if (static::viewerHasTenantCapability($tenant, Capabilities::PROVIDER_VIEW)) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'provider_connections',
|
||||
label: 'Provider connections',
|
||||
value: 'Open provider connections',
|
||||
secondaryValue: 'Inspect consent, credentials, and health for this tenant.',
|
||||
targetUrl: ProviderConnectionResource::getUrl('index', ['tenant_id' => $tenant->external_id], panel: 'admin'),
|
||||
targetKind: 'canonical_page',
|
||||
priority: 20,
|
||||
actionLabel: 'Open',
|
||||
contextBadge: 'Integrations',
|
||||
);
|
||||
} elseif (static::viewerCanInspectTenantContext($tenant)) {
|
||||
$entries[] = RelatedContextEntry::unavailable(
|
||||
key: 'provider_connections',
|
||||
label: 'Provider connections',
|
||||
state: new UnavailableRelationState(
|
||||
relationKey: 'provider_connections',
|
||||
referenceValue: null,
|
||||
reason: 'authorization_denied',
|
||||
message: UiTooltips::insufficientPermission(),
|
||||
),
|
||||
targetKind: 'canonical_page',
|
||||
priority: 20,
|
||||
actionLabel: 'Open',
|
||||
);
|
||||
}
|
||||
|
||||
$relatedOnboarding = static::relatedOnboardingDraftAction($tenant, TenantActionSurface::TenantViewHeader);
|
||||
|
||||
if ($relatedOnboarding instanceof TenantActionDescriptor && filled(static::relatedOnboardingDraftUrl($tenant))) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'related_onboarding',
|
||||
label: 'Onboarding draft',
|
||||
value: $relatedOnboarding->label,
|
||||
secondaryValue: 'Return to the linked onboarding workflow for this tenant.',
|
||||
targetUrl: (string) static::relatedOnboardingDraftUrl($tenant),
|
||||
targetKind: 'workflow',
|
||||
priority: 30,
|
||||
actionLabel: 'Open',
|
||||
contextBadge: 'Workflow',
|
||||
);
|
||||
}
|
||||
|
||||
return collect($entries)
|
||||
->sortBy('priority')
|
||||
->values()
|
||||
->map(static fn (RelatedContextEntry $entry): array => $entry->toArray())
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function tenantEditContextEntries(Tenant $tenant): array
|
||||
{
|
||||
$entries = [
|
||||
RelatedContextEntry::available(
|
||||
key: 'tenant_view',
|
||||
label: 'Tenant detail',
|
||||
value: 'Open tenant detail',
|
||||
secondaryValue: 'Review verification, RBAC, and lifecycle context without leaving the tenant resource.',
|
||||
targetUrl: static::getUrl('view', ['record' => $tenant]),
|
||||
targetKind: 'direct_record',
|
||||
priority: 10,
|
||||
actionLabel: 'Open',
|
||||
contextBadge: 'Inspection',
|
||||
),
|
||||
];
|
||||
|
||||
$relatedOnboarding = static::relatedOnboardingDraftAction($tenant, TenantActionSurface::TenantEditHeader);
|
||||
|
||||
if ($relatedOnboarding instanceof TenantActionDescriptor && filled(static::relatedOnboardingDraftUrl($tenant))) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'related_onboarding',
|
||||
label: 'Onboarding draft',
|
||||
value: $relatedOnboarding->label,
|
||||
secondaryValue: 'Return to the linked onboarding workflow for this tenant.',
|
||||
targetUrl: (string) static::relatedOnboardingDraftUrl($tenant),
|
||||
targetKind: 'workflow',
|
||||
priority: 20,
|
||||
actionLabel: 'Open',
|
||||
contextBadge: 'Workflow',
|
||||
);
|
||||
}
|
||||
|
||||
return collect($entries)
|
||||
->sortBy('priority')
|
||||
->values()
|
||||
->map(static fn (RelatedContextEntry $entry): array => $entry->toArray())
|
||||
->all();
|
||||
}
|
||||
|
||||
public static function tenantEditContextHtml(?Tenant $tenant): HtmlString
|
||||
{
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return new HtmlString('');
|
||||
}
|
||||
|
||||
$entries = static::tenantEditContextEntries($tenant);
|
||||
|
||||
if ($entries === []) {
|
||||
return new HtmlString('');
|
||||
}
|
||||
|
||||
return new HtmlString((string) view('filament.infolists.entries.related-context', [
|
||||
'entries' => $entries,
|
||||
])->render());
|
||||
}
|
||||
|
||||
public static function tenantViewLifecycleGroupVisible(Tenant $tenant): bool
|
||||
{
|
||||
return in_array(static::lifecycleActionDescriptor($tenant, TenantActionSurface::TenantViewHeader)?->key, ['archive', 'restore'], true);
|
||||
}
|
||||
|
||||
public static function tenantViewExternalGroupVisible(Tenant $tenant): bool
|
||||
{
|
||||
return static::adminConsentUrl($tenant) !== null || static::entraUrl($tenant) !== null;
|
||||
}
|
||||
|
||||
public static function tenantViewSetupGroupVisible(Tenant $tenant): bool
|
||||
{
|
||||
return $tenant->isActive();
|
||||
}
|
||||
|
||||
public static function verificationActionVisible(Tenant $tenant): bool
|
||||
{
|
||||
$outcome = static::verificationReadinessOutcome($tenant);
|
||||
@ -2284,6 +2460,28 @@ private static function tenantActionCatalogCacheKey(Tenant $tenant, TenantAction
|
||||
]);
|
||||
}
|
||||
|
||||
private static function viewerCanInspectTenantContext(Tenant $tenant): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User && $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
private static function viewerHasTenantCapability(Tenant $tenant, string $capability): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->isMember($user, $tenant)
|
||||
&& $resolver->can($user, $tenant, $capability);
|
||||
}
|
||||
|
||||
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -18,13 +18,8 @@ class EditTenant extends EditRecord
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\Action::make('related_onboarding')
|
||||
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantEditHeader) ?? 'View related onboarding')
|
||||
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-eye')
|
||||
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
||||
return array_values(array_filter([
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Action::make('restore')
|
||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
|
||||
@ -61,6 +56,16 @@ protected function getHeaderActions(): array
|
||||
->preserveVisibility()
|
||||
->destructive()
|
||||
->apply(),
|
||||
];
|
||||
])
|
||||
->label('Lifecycle')
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
&& in_array(
|
||||
TenantResource::lifecycleActionDescriptor($this->getRecord(), TenantActionSurface::TenantEditHeader)?->key,
|
||||
['archive', 'restore'],
|
||||
true,
|
||||
)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
|
||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||
@ -56,29 +55,8 @@ protected function getHeaderWidgets(): array
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
return array_values(array_filter([
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('provider_connections')
|
||||
->label('Provider connections')
|
||||
->icon('heroicon-o-link')
|
||||
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant_id' => $record->external_id], panel: 'admin'))
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_VIEW)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\Action::make('related_onboarding')
|
||||
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantViewHeader) ?? 'View related onboarding')
|
||||
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-eye')
|
||||
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
@ -91,6 +69,13 @@ protected function getHeaderActions(): array
|
||||
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
||||
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
])
|
||||
->label('External links')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
&& TenantResource::tenantViewExternalGroupVisible($this->getRecord())),
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('verify')
|
||||
->label(self::verificationHeaderActionLabel())
|
||||
@ -156,10 +141,6 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
if ($result->status === 'blocked') {
|
||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$actions = [
|
||||
Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
@ -283,6 +264,13 @@ protected function getHeaderActions(): array
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
])
|
||||
->label('Setup')
|
||||
->icon('heroicon-o-wrench-screwdriver')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
&& TenantResource::tenantViewSetupGroupVisible($this->getRecord())),
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
|
||||
@ -318,9 +306,11 @@ protected function getHeaderActions(): array
|
||||
->destructive()
|
||||
->apply(),
|
||||
])
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
];
|
||||
->label('Lifecycle')
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||
&& TenantResource::tenantViewLifecycleGroupVisible($this->getRecord())),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,7 +122,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Export executive pack remains the only inline row shortcut.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes one dominant lifecycle action, groups the remaining lifecycle actions under "More", keeps archive in a danger bucket, and renders operation/export/evidence navigation in contextual summary content.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
@ -570,6 +570,7 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
'context_links' => static::summaryContextLinks($record),
|
||||
'metrics' => [
|
||||
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||
@ -579,6 +580,43 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{title:string,label:string,url:string,description:string}>
|
||||
*/
|
||||
private static function summaryContextLinks(TenantReview $record): array
|
||||
{
|
||||
$links = [];
|
||||
|
||||
if (is_numeric($record->operation_run_id)) {
|
||||
$links[] = [
|
||||
'title' => 'Operation',
|
||||
'label' => 'Open operation',
|
||||
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
|
||||
'description' => 'Inspect the latest review composition or refresh run.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($record->currentExportReviewPack && $record->tenant) {
|
||||
$links[] = [
|
||||
'title' => 'Executive pack',
|
||||
'label' => 'View executive pack',
|
||||
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
|
||||
'description' => 'Open the current export that belongs to this review.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($record->evidenceSnapshot && $record->tenant) {
|
||||
$links[] = [
|
||||
'title' => 'Evidence snapshot',
|
||||
'label' => 'View evidence snapshot',
|
||||
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
||||
'description' => 'Return to the evidence basis behind this review.',
|
||||
];
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Filament\Actions;
|
||||
@ -53,35 +52,105 @@ protected function authorizeAccess(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->icon('heroicon-o-eye')
|
||||
$secondaryActions = $this->secondaryLifecycleActions();
|
||||
|
||||
return array_values(array_filter([
|
||||
$this->primaryLifecycleAction(),
|
||||
Actions\ActionGroup::make($secondaryActions)
|
||||
->label('More')
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray')
|
||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
|
||||
->url(fn (): ?string => $this->record->operation_run_id
|
||||
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
|
||||
: null),
|
||||
Actions\Action::make('view_export')
|
||||
->label('View executive pack')
|
||||
->icon('heroicon-o-document-arrow-down')
|
||||
->color('gray')
|
||||
->hidden(fn (): bool => ! $this->record->currentExportReviewPack)
|
||||
->url(fn (): ?string => $this->record->currentExportReviewPack
|
||||
? \App\Filament\Resources\ReviewPackResource::getUrl('view', ['record' => $this->record->currentExportReviewPack], tenant: $this->record->tenant)
|
||||
: null),
|
||||
Actions\Action::make('view_evidence')
|
||||
->label('View evidence snapshot')
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('gray')
|
||||
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
|
||||
->url(fn (): ?string => $this->record->evidenceSnapshot
|
||||
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
|
||||
: null),
|
||||
UiEnforcement::forAction(
|
||||
->visible(fn (): bool => $secondaryActions !== []),
|
||||
Actions\ActionGroup::make([
|
||||
$this->archiveReviewAction(),
|
||||
])
|
||||
->label('Danger')
|
||||
->icon('heroicon-o-archive-box')
|
||||
->color('danger')
|
||||
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
|
||||
]));
|
||||
}
|
||||
|
||||
private function primaryLifecycleAction(): ?Actions\Action
|
||||
{
|
||||
return match ($this->primaryLifecycleActionName()) {
|
||||
'refresh_review' => $this->refreshReviewAction(),
|
||||
'publish_review' => $this->publishReviewAction(),
|
||||
'export_executive_pack' => $this->exportExecutivePackAction(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function primaryLifecycleActionName(): ?string
|
||||
{
|
||||
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
|
||||
return 'export_executive_pack';
|
||||
}
|
||||
|
||||
if ((string) $this->record->status === TenantReviewStatus::Ready->value) {
|
||||
return 'publish_review';
|
||||
}
|
||||
|
||||
if ($this->record->isMutable()) {
|
||||
return 'refresh_review';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Actions\Action>
|
||||
*/
|
||||
private function secondaryLifecycleActions(): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
fn (string $name): ?Actions\Action => match ($name) {
|
||||
'refresh_review' => $this->refreshReviewAction(),
|
||||
'publish_review' => $this->publishReviewAction(),
|
||||
'export_executive_pack' => $this->exportExecutivePackAction(),
|
||||
'create_next_review' => $this->createNextReviewAction(),
|
||||
default => null,
|
||||
},
|
||||
$this->secondaryLifecycleActionNames(),
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function secondaryLifecycleActionNames(): array
|
||||
{
|
||||
$names = [];
|
||||
|
||||
if ($this->record->isMutable()) {
|
||||
$names[] = 'refresh_review';
|
||||
$names[] = 'publish_review';
|
||||
}
|
||||
|
||||
if (in_array((string) $this->record->status, [
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Published->value,
|
||||
], true)) {
|
||||
$names[] = 'export_executive_pack';
|
||||
}
|
||||
|
||||
if ($this->record->isPublished()) {
|
||||
$names[] = 'create_next_review';
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$names,
|
||||
fn (string $name): bool => $name !== $this->primaryLifecycleActionName(),
|
||||
));
|
||||
}
|
||||
|
||||
private function refreshReviewAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('refresh_review')
|
||||
->label('Refresh review')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
@ -104,11 +173,16 @@ protected function getHeaderActions(): array
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function publishReviewAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('publish_review')
|
||||
->label('Publish review')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
@ -132,12 +206,17 @@ protected function getHeaderActions(): array
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function exportExecutivePackAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->hidden(fn (): bool => ! in_array($this->record->status, [
|
||||
->color('primary')
|
||||
->hidden(fn (): bool => ! in_array((string) $this->record->status, [
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Published->value,
|
||||
], true))
|
||||
@ -145,9 +224,12 @@ protected function getHeaderActions(): array
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forAction(
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function createNextReviewAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('create_next_review')
|
||||
->label('Create next review')
|
||||
->icon('heroicon-o-document-duplicate')
|
||||
@ -172,8 +254,12 @@ protected function getHeaderActions(): array
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function archiveReviewAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('archive_review')
|
||||
->label('Archive review')
|
||||
->icon('heroicon-o-archive-box')
|
||||
@ -195,11 +281,6 @@ protected function getHeaderActions(): array
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-m-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
];
|
||||
->apply();
|
||||
}
|
||||
}
|
||||
|
||||
@ -184,7 +184,7 @@ public function panel(Panel $panel): Panel
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->databaseNotificationsPolling(null)
|
||||
->unsavedChangesAlerts()
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
|
||||
@ -37,7 +37,7 @@ public function panel(Panel $panel): Panel
|
||||
'primary' => Color::Blue,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->databaseNotificationsPolling(null)
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_START,
|
||||
fn () => view('filament.system.components.break-glass-banner')->render(),
|
||||
|
||||
@ -95,7 +95,7 @@ public function panel(Panel $panel): Panel
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->databaseNotificationsPolling(null)
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
|
||||
@ -4,6 +4,31 @@
|
||||
|
||||
namespace App\Support\Ui\ActionSurface;
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Pages\BaselineCompareMatrix;
|
||||
use App\Filament\Pages\Monitoring\Alerts;
|
||||
use App\Filament\Pages\Monitoring\AuditLog;
|
||||
use App\Filament\Pages\Monitoring\EvidenceOverview;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\TenantDiagnostics;
|
||||
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
||||
use App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination;
|
||||
use App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||
use App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot;
|
||||
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
||||
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
|
||||
use App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
|
||||
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
|
||||
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
||||
|
||||
final class ActionSurfaceExemptions
|
||||
@ -24,7 +49,6 @@ public static function baseline(): self
|
||||
'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.',
|
||||
'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.',
|
||||
'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.',
|
||||
'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts remains exempt because the active admin alerts surface resolves through the cluster entry at /admin/alerts, not this page-class route.',
|
||||
'App\\Filament\\Pages\\Tenancy\\RegisterTenant' => 'Tenant onboarding route is covered by onboarding/RBAC specs.',
|
||||
'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.',
|
||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests in spec 172 (OnboardingVerificationTest, OnboardingVerificationClustersTest, OnboardingVerificationV1_5UxTest) and remains exempt from blanket discovery.',
|
||||
@ -49,4 +73,472 @@ public function hasClass(string $className): bool
|
||||
{
|
||||
return array_key_exists($className, $this->componentReasons);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{
|
||||
* surfaceKey: string,
|
||||
* classification: string,
|
||||
* canonicalNoun: string,
|
||||
* panelScope: string,
|
||||
* ownerScope: string,
|
||||
* routeKind: string,
|
||||
* requiresHeaderRemediation: bool,
|
||||
* exceptionReason: ?string,
|
||||
* maxVisiblePrimaryActions: int,
|
||||
* allowsNoPrimaryAction: bool,
|
||||
* requiresGroupedSecondaryActions: bool,
|
||||
* requiresDangerSeparation: bool,
|
||||
* allowsPrimaryNavigation: bool,
|
||||
* browserSmokeRequired: bool
|
||||
* }>
|
||||
*/
|
||||
public static function spec192RecordPageInventory(): array
|
||||
{
|
||||
return [
|
||||
ViewBaselineProfile::class => [
|
||||
'surfaceKey' => 'baseline_profile_view',
|
||||
'classification' => 'remediation_required',
|
||||
'canonicalNoun' => 'Baseline profile',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => true,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => false,
|
||||
'requiresGroupedSecondaryActions' => true,
|
||||
'requiresDangerSeparation' => false,
|
||||
'allowsPrimaryNavigation' => false,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
ViewEvidenceSnapshot::class => [
|
||||
'surfaceKey' => 'evidence_snapshot_view',
|
||||
'classification' => 'remediation_required',
|
||||
'canonicalNoun' => 'Evidence snapshot',
|
||||
'panelScope' => 'tenant',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => true,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => false,
|
||||
'requiresGroupedSecondaryActions' => false,
|
||||
'requiresDangerSeparation' => true,
|
||||
'allowsPrimaryNavigation' => false,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
ViewFindingException::class => [
|
||||
'surfaceKey' => 'finding_exception_view',
|
||||
'classification' => 'remediation_required',
|
||||
'canonicalNoun' => 'Finding exception',
|
||||
'panelScope' => 'tenant',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => true,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => true,
|
||||
'requiresGroupedSecondaryActions' => false,
|
||||
'requiresDangerSeparation' => true,
|
||||
'allowsPrimaryNavigation' => false,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
ViewTenantReview::class => [
|
||||
'surfaceKey' => 'tenant_review_view',
|
||||
'classification' => 'remediation_required',
|
||||
'canonicalNoun' => 'Tenant review',
|
||||
'panelScope' => 'tenant',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => true,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => false,
|
||||
'requiresGroupedSecondaryActions' => true,
|
||||
'requiresDangerSeparation' => true,
|
||||
'allowsPrimaryNavigation' => false,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
EditTenant::class => [
|
||||
'surfaceKey' => 'tenant_edit',
|
||||
'classification' => 'remediation_required',
|
||||
'canonicalNoun' => 'Tenant',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'routeKind' => 'edit',
|
||||
'requiresHeaderRemediation' => true,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => true,
|
||||
'requiresGroupedSecondaryActions' => true,
|
||||
'requiresDangerSeparation' => true,
|
||||
'allowsPrimaryNavigation' => false,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
ViewTenant::class => [
|
||||
'surfaceKey' => 'tenant_view',
|
||||
'classification' => 'workflow_heavy_special_type',
|
||||
'canonicalNoun' => 'Tenant',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'exceptionReason' => 'Tenant detail remains a workflow-heavy hub for external links, verification/setup, and lifecycle operations. It may show one dominant next step, but it must never silently fall back to a flat multi-button strip.',
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => true,
|
||||
'requiresGroupedSecondaryActions' => true,
|
||||
'requiresDangerSeparation' => true,
|
||||
'allowsPrimaryNavigation' => false,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
ViewProviderConnection::class => [
|
||||
'surfaceKey' => 'provider_connection_view',
|
||||
'classification' => 'minor_alignment_only',
|
||||
'canonicalNoun' => 'Provider connection',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => true,
|
||||
'requiresGroupedSecondaryActions' => true,
|
||||
'requiresDangerSeparation' => true,
|
||||
'allowsPrimaryNavigation' => true,
|
||||
'browserSmokeRequired' => false,
|
||||
],
|
||||
ViewFinding::class => [
|
||||
'surfaceKey' => 'finding_view',
|
||||
'classification' => 'minor_alignment_only',
|
||||
'canonicalNoun' => 'Finding',
|
||||
'panelScope' => 'tenant',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => true,
|
||||
'requiresGroupedSecondaryActions' => true,
|
||||
'requiresDangerSeparation' => true,
|
||||
'allowsPrimaryNavigation' => true,
|
||||
'browserSmokeRequired' => false,
|
||||
],
|
||||
ViewReviewPack::class => [
|
||||
'surfaceKey' => 'review_pack_view',
|
||||
'classification' => 'compliant_reference',
|
||||
'canonicalNoun' => 'Review pack',
|
||||
'panelScope' => 'tenant',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => true,
|
||||
'requiresGroupedSecondaryActions' => false,
|
||||
'requiresDangerSeparation' => false,
|
||||
'allowsPrimaryNavigation' => true,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
ViewAlertDestination::class => [
|
||||
'surfaceKey' => 'alert_destination_view',
|
||||
'classification' => 'compliant_reference',
|
||||
'canonicalNoun' => 'Alert destination',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => true,
|
||||
'requiresGroupedSecondaryActions' => false,
|
||||
'requiresDangerSeparation' => false,
|
||||
'allowsPrimaryNavigation' => true,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
ViewPolicyVersion::class => [
|
||||
'surfaceKey' => 'policy_version_view',
|
||||
'classification' => 'compliant_reference',
|
||||
'canonicalNoun' => 'Policy version',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => true,
|
||||
'requiresGroupedSecondaryActions' => false,
|
||||
'requiresDangerSeparation' => false,
|
||||
'allowsPrimaryNavigation' => true,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
ViewWorkspace::class => [
|
||||
'surfaceKey' => 'workspace_view',
|
||||
'classification' => 'compliant_reference',
|
||||
'canonicalNoun' => 'Workspace',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => true,
|
||||
'requiresGroupedSecondaryActions' => false,
|
||||
'requiresDangerSeparation' => false,
|
||||
'allowsPrimaryNavigation' => true,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
ViewBaselineSnapshot::class => [
|
||||
'surfaceKey' => 'baseline_snapshot_view',
|
||||
'classification' => 'compliant_reference',
|
||||
'canonicalNoun' => 'Baseline snapshot',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => true,
|
||||
'requiresGroupedSecondaryActions' => false,
|
||||
'requiresDangerSeparation' => false,
|
||||
'allowsPrimaryNavigation' => true,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
ViewBackupSet::class => [
|
||||
'surfaceKey' => 'backup_set_view',
|
||||
'classification' => 'compliant_reference',
|
||||
'canonicalNoun' => 'Backup set',
|
||||
'panelScope' => 'tenant',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'routeKind' => 'view',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'exceptionReason' => null,
|
||||
'maxVisiblePrimaryActions' => 1,
|
||||
'allowsNoPrimaryAction' => true,
|
||||
'requiresGroupedSecondaryActions' => true,
|
||||
'requiresDangerSeparation' => true,
|
||||
'allowsPrimaryNavigation' => true,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{
|
||||
* surfaceKey: string,
|
||||
* classification: string,
|
||||
* canonicalNoun: string,
|
||||
* panelScope: string,
|
||||
* ownerScope: string,
|
||||
* surfaceKind: string,
|
||||
* primaryInspectModel: string,
|
||||
* sharedPattern: string,
|
||||
* requiresHeaderRemediation: bool,
|
||||
* requiresExplicitDeclaration: bool,
|
||||
* exceptionReason: ?string,
|
||||
* browserSmokeRequired: bool
|
||||
* }>
|
||||
*/
|
||||
public static function spec193MonitoringSurfaceInventory(): array
|
||||
{
|
||||
return [
|
||||
FindingExceptionsQueue::class => [
|
||||
'surfaceKey' => 'finding_exceptions_queue',
|
||||
'classification' => 'remediation_required',
|
||||
'canonicalNoun' => 'Finding exceptions',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-visible-tenant-owned',
|
||||
'surfaceKind' => 'queue_workbench',
|
||||
'primaryInspectModel' => 'explicit_inspect_action',
|
||||
'sharedPattern' => 'operate_hub_shell',
|
||||
'requiresHeaderRemediation' => true,
|
||||
'requiresExplicitDeclaration' => true,
|
||||
'exceptionReason' => null,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
TenantlessOperationRunViewer::class => [
|
||||
'surfaceKey' => 'tenantless_operation_run_viewer',
|
||||
'classification' => 'remediation_required',
|
||||
'canonicalNoun' => 'Operation run',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-owned',
|
||||
'surfaceKind' => 'monitoring_detail',
|
||||
'primaryInspectModel' => 'singleton_detail_surface',
|
||||
'sharedPattern' => 'operate_hub_shell',
|
||||
'requiresHeaderRemediation' => true,
|
||||
'requiresExplicitDeclaration' => true,
|
||||
'exceptionReason' => null,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
Operations::class => [
|
||||
'surfaceKey' => 'operations',
|
||||
'classification' => 'remediation_required',
|
||||
'canonicalNoun' => 'Operations',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-owned',
|
||||
'surfaceKind' => 'monitoring_landing',
|
||||
'primaryInspectModel' => 'clickable_row',
|
||||
'sharedPattern' => 'operate_hub_shell',
|
||||
'requiresHeaderRemediation' => true,
|
||||
'requiresExplicitDeclaration' => true,
|
||||
'exceptionReason' => null,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
Alerts::class => [
|
||||
'surfaceKey' => 'alerts',
|
||||
'classification' => 'minor_alignment_only',
|
||||
'canonicalNoun' => 'Alerts',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-owned',
|
||||
'surfaceKind' => 'monitoring_landing',
|
||||
'primaryInspectModel' => 'page_level_overview',
|
||||
'sharedPattern' => 'cluster_entry',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'requiresExplicitDeclaration' => true,
|
||||
'exceptionReason' => null,
|
||||
'browserSmokeRequired' => false,
|
||||
],
|
||||
AuditLog::class => [
|
||||
'surfaceKey' => 'audit_log',
|
||||
'classification' => 'minor_alignment_only',
|
||||
'canonicalNoun' => 'Audit log',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-visible-tenant-owned',
|
||||
'surfaceKind' => 'read_only_report',
|
||||
'primaryInspectModel' => 'explicit_inspect_action',
|
||||
'sharedPattern' => 'operate_hub_shell',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'requiresExplicitDeclaration' => true,
|
||||
'exceptionReason' => null,
|
||||
'browserSmokeRequired' => false,
|
||||
],
|
||||
ListAlertDeliveries::class => [
|
||||
'surfaceKey' => 'alert_deliveries',
|
||||
'classification' => 'minor_alignment_only',
|
||||
'canonicalNoun' => 'Alert deliveries',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-owned',
|
||||
'surfaceKind' => 'read_only_report',
|
||||
'primaryInspectModel' => 'clickable_row',
|
||||
'sharedPattern' => 'operate_hub_shell',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'requiresExplicitDeclaration' => false,
|
||||
'exceptionReason' => null,
|
||||
'browserSmokeRequired' => false,
|
||||
],
|
||||
EvidenceOverview::class => [
|
||||
'surfaceKey' => 'evidence_overview',
|
||||
'classification' => 'compliant_no_op',
|
||||
'canonicalNoun' => 'Evidence overview',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-visible-tenant-owned',
|
||||
'surfaceKind' => 'read_only_report',
|
||||
'primaryInspectModel' => 'clickable_row',
|
||||
'sharedPattern' => 'none',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'requiresExplicitDeclaration' => true,
|
||||
'exceptionReason' => null,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
BaselineCompareLanding::class => [
|
||||
'surfaceKey' => 'baseline_compare_landing',
|
||||
'classification' => 'compliant_no_op',
|
||||
'canonicalNoun' => 'Baseline compare',
|
||||
'panelScope' => 'tenant',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'surfaceKind' => 'monitoring_landing',
|
||||
'primaryInspectModel' => 'page_level_overview',
|
||||
'sharedPattern' => 'none',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'requiresExplicitDeclaration' => true,
|
||||
'exceptionReason' => null,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
BaselineCompareMatrix::class => [
|
||||
'surfaceKey' => 'baseline_compare_matrix',
|
||||
'classification' => 'compliant_no_op',
|
||||
'canonicalNoun' => 'Baseline compare matrix',
|
||||
'panelScope' => 'tenant',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'surfaceKind' => 'read_only_report',
|
||||
'primaryInspectModel' => 'matrix_itself',
|
||||
'sharedPattern' => 'none',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'requiresExplicitDeclaration' => true,
|
||||
'exceptionReason' => null,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
ReviewRegister::class => [
|
||||
'surfaceKey' => 'review_register',
|
||||
'classification' => 'compliant_no_op',
|
||||
'canonicalNoun' => 'Review register',
|
||||
'panelScope' => 'admin',
|
||||
'ownerScope' => 'workspace-visible-tenant-owned',
|
||||
'surfaceKind' => 'read_only_report',
|
||||
'primaryInspectModel' => 'clickable_row',
|
||||
'sharedPattern' => 'none',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'requiresExplicitDeclaration' => true,
|
||||
'exceptionReason' => null,
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
TenantDiagnostics::class => [
|
||||
'surfaceKey' => 'tenant_diagnostics',
|
||||
'classification' => 'special_type_acceptable',
|
||||
'canonicalNoun' => 'Tenant diagnostics',
|
||||
'panelScope' => 'tenant',
|
||||
'ownerScope' => 'tenant-owned',
|
||||
'surfaceKind' => 'diagnostic_exception',
|
||||
'primaryInspectModel' => 'singleton_detail_surface',
|
||||
'sharedPattern' => 'none',
|
||||
'requiresHeaderRemediation' => false,
|
||||
'requiresExplicitDeclaration' => true,
|
||||
'exceptionReason' => 'Tenant diagnostics is already the focused diagnostic surface for the active tenant and may expose repair actions only when a real defect exists.',
|
||||
'browserSmokeRequired' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* surfaceKey: string,
|
||||
* classification: string,
|
||||
* canonicalNoun: string,
|
||||
* panelScope: string,
|
||||
* ownerScope: string,
|
||||
* routeKind: string,
|
||||
* requiresHeaderRemediation: bool,
|
||||
* exceptionReason: ?string,
|
||||
* maxVisiblePrimaryActions: int,
|
||||
* allowsNoPrimaryAction: bool,
|
||||
* requiresGroupedSecondaryActions: bool,
|
||||
* requiresDangerSeparation: bool,
|
||||
* allowsPrimaryNavigation: bool,
|
||||
* browserSmokeRequired: bool
|
||||
* }|null
|
||||
*/
|
||||
public static function spec192RecordPageSurface(string $className): ?array
|
||||
{
|
||||
return self::spec192RecordPageInventory()[$className] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* surfaceKey: string,
|
||||
* classification: string,
|
||||
* canonicalNoun: string,
|
||||
* panelScope: string,
|
||||
* ownerScope: string,
|
||||
* surfaceKind: string,
|
||||
* primaryInspectModel: string,
|
||||
* sharedPattern: string,
|
||||
* requiresHeaderRemediation: bool,
|
||||
* requiresExplicitDeclaration: bool,
|
||||
* exceptionReason: ?string,
|
||||
* browserSmokeRequired: bool
|
||||
* }|null
|
||||
*/
|
||||
public static function spec193MonitoringSurface(string $className): ?array
|
||||
{
|
||||
return self::spec193MonitoringSurfaceInventory()[$className] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,6 +54,9 @@ public function validateComponents(array $components): ActionSurfaceValidationRe
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
$this->validateSpec193MonitoringSurfaceInventory($issues);
|
||||
$this->validateSpec192RecordPageInventory($issues);
|
||||
|
||||
foreach ($components as $component) {
|
||||
if (! class_exists($component->className)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
@ -106,6 +109,268 @@ className: $component->className,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||
*/
|
||||
private function validateSpec193MonitoringSurfaceInventory(array &$issues): void
|
||||
{
|
||||
$allowedClassifications = [
|
||||
'remediation_required',
|
||||
'minor_alignment_only',
|
||||
'compliant_no_op',
|
||||
'special_type_acceptable',
|
||||
];
|
||||
$allowedPanelScopes = ['admin', 'tenant'];
|
||||
$allowedOwnerScopes = ['workspace-owned', 'workspace-visible-tenant-owned', 'tenant-owned'];
|
||||
$allowedSurfaceKinds = ['queue_workbench', 'monitoring_detail', 'monitoring_landing', 'read_only_report', 'diagnostic_exception'];
|
||||
$allowedPrimaryInspectModels = ['explicit_inspect_action', 'clickable_row', 'page_level_overview', 'matrix_itself', 'singleton_detail_surface'];
|
||||
$allowedSharedPatterns = ['operate_hub_shell', 'cluster_entry', 'none'];
|
||||
$surfaceKeys = [];
|
||||
|
||||
foreach (ActionSurfaceExemptions::spec193MonitoringSurfaceInventory() as $className => $surface) {
|
||||
if (! class_exists($className)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 193 inventory references a surface class that does not exist.',
|
||||
hint: 'Keep ActionSurfaceExemptions::spec193MonitoringSurfaceInventory() aligned with the in-scope monitoring surface classes.',
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$surfaceKey = (string) ($surface['surfaceKey'] ?? '');
|
||||
|
||||
if ($surfaceKey === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 193 inventory entry is missing a non-empty surface key.',
|
||||
hint: 'Provide the stable spec surface key for this monitoring surface.',
|
||||
);
|
||||
} elseif (isset($surfaceKeys[$surfaceKey])) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: sprintf('Spec 193 surface key "%s" is declared more than once.', $surfaceKey),
|
||||
hint: 'Each in-scope monitoring surface must have a unique surface key.',
|
||||
);
|
||||
} else {
|
||||
$surfaceKeys[$surfaceKey] = true;
|
||||
}
|
||||
|
||||
if (! in_array($surface['classification'] ?? null, $allowedClassifications, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 193 classification is invalid or missing.',
|
||||
hint: 'Use remediation_required, minor_alignment_only, compliant_no_op, or special_type_acceptable.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['panelScope'] ?? null, $allowedPanelScopes, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 193 panel scope is invalid or missing.',
|
||||
hint: 'Use admin or tenant for each monitoring surface inventory entry.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['ownerScope'] ?? null, $allowedOwnerScopes, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 193 owner scope is invalid or missing.',
|
||||
hint: 'Use workspace-owned, workspace-visible-tenant-owned, or tenant-owned.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['surfaceKind'] ?? null, $allowedSurfaceKinds, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 193 surface kind is invalid or missing.',
|
||||
hint: 'Use queue_workbench, monitoring_detail, monitoring_landing, read_only_report, or diagnostic_exception.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['primaryInspectModel'] ?? null, $allowedPrimaryInspectModels, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 193 primary inspect model is invalid or missing.',
|
||||
hint: 'Use an allowed inspect model such as explicit_inspect_action, clickable_row, or page_level_overview.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['sharedPattern'] ?? null, $allowedSharedPatterns, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 193 shared pattern is invalid or missing.',
|
||||
hint: 'Use operate_hub_shell, cluster_entry, or none.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_string($surface['canonicalNoun'] ?? null) || trim((string) $surface['canonicalNoun']) === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 193 canonical noun must be non-empty.',
|
||||
hint: 'Use the stable operator-facing noun for the monitoring surface.',
|
||||
);
|
||||
}
|
||||
|
||||
$classification = (string) ($surface['classification'] ?? '');
|
||||
$exceptionReason = $surface['exceptionReason'] ?? null;
|
||||
|
||||
if ($classification === 'special_type_acceptable') {
|
||||
if (! is_string($exceptionReason) || trim($exceptionReason) === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Special-type acceptable Spec 193 surfaces require an explicit exception reason.',
|
||||
hint: 'Document why this surface intentionally differs from the standard monitoring hierarchy.',
|
||||
);
|
||||
}
|
||||
} elseif ($exceptionReason !== null && trim((string) $exceptionReason) !== '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Only special-type acceptable Spec 193 surfaces may carry an exception reason.',
|
||||
hint: 'Clear the exception reason for remediation, minor-alignment, and compliant surfaces.',
|
||||
);
|
||||
}
|
||||
|
||||
if ($classification === 'remediation_required' && ($surface['requiresHeaderRemediation'] ?? false) !== true) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Remediation-required Spec 193 surfaces must mark header remediation as required.',
|
||||
hint: 'Set requiresHeaderRemediation to true for remediation_required surfaces.',
|
||||
);
|
||||
}
|
||||
|
||||
if (($surface['requiresExplicitDeclaration'] ?? false) === true && ! method_exists($className, 'actionSurfaceDeclaration')) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 193 surface requires an explicit action-surface declaration, but the class does not define one.',
|
||||
hint: 'Add actionSurfaceDeclaration() to the page class or clear requiresExplicitDeclaration if the surface is intentionally declaration-free.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||
*/
|
||||
private function validateSpec192RecordPageInventory(array &$issues): void
|
||||
{
|
||||
$allowedClassifications = [
|
||||
'remediation_required',
|
||||
'minor_alignment_only',
|
||||
'compliant_reference',
|
||||
'workflow_heavy_special_type',
|
||||
];
|
||||
$allowedPanelScopes = ['admin', 'tenant'];
|
||||
$allowedOwnerScopes = ['workspace-owned', 'tenant-owned'];
|
||||
$allowedRouteKinds = ['view', 'edit'];
|
||||
$surfaceKeys = [];
|
||||
|
||||
foreach (ActionSurfaceExemptions::spec192RecordPageInventory() as $className => $surface) {
|
||||
if (! class_exists($className)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 192 inventory references a page class that does not exist.',
|
||||
hint: 'Keep ActionSurfaceExemptions::spec192RecordPageInventory() aligned with the in-scope page classes.',
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$surfaceKey = (string) ($surface['surfaceKey'] ?? '');
|
||||
|
||||
if ($surfaceKey === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 192 inventory entry is missing a non-empty surface key.',
|
||||
hint: 'Provide the stable spec surface key for this page.',
|
||||
);
|
||||
} elseif (isset($surfaceKeys[$surfaceKey])) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: sprintf('Spec 192 surface key "%s" is declared more than once.', $surfaceKey),
|
||||
hint: 'Each in-scope page must have a unique surface key.',
|
||||
);
|
||||
} else {
|
||||
$surfaceKeys[$surfaceKey] = true;
|
||||
}
|
||||
|
||||
if (! in_array($surface['classification'] ?? null, $allowedClassifications, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 192 classification is invalid or missing.',
|
||||
hint: 'Use remediation_required, minor_alignment_only, compliant_reference, or workflow_heavy_special_type.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['panelScope'] ?? null, $allowedPanelScopes, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 192 panel scope is invalid or missing.',
|
||||
hint: 'Use the concrete panel scope for the record page inventory entry.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['ownerScope'] ?? null, $allowedOwnerScopes, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 192 owner scope is invalid or missing.',
|
||||
hint: 'Use workspace-owned or tenant-owned.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($surface['routeKind'] ?? null, $allowedRouteKinds, true)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 192 route kind is invalid or missing.',
|
||||
hint: 'Use view or edit.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! is_string($surface['canonicalNoun'] ?? null) || trim((string) $surface['canonicalNoun']) === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 192 canonical noun must be non-empty.',
|
||||
hint: 'Use the stable operator-facing noun for the surface.',
|
||||
);
|
||||
}
|
||||
|
||||
$classification = (string) ($surface['classification'] ?? '');
|
||||
$exceptionReason = $surface['exceptionReason'] ?? null;
|
||||
|
||||
if ($classification === 'workflow_heavy_special_type') {
|
||||
if (! is_string($exceptionReason) || trim($exceptionReason) === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Workflow-heavy Spec 192 pages require an explicit exception reason.',
|
||||
hint: 'Document why this surface is intentionally exempt from the standard record-page rule.',
|
||||
);
|
||||
}
|
||||
} elseif ($exceptionReason !== null && trim((string) $exceptionReason) !== '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Only workflow-heavy Spec 192 pages may carry an exception reason.',
|
||||
hint: 'Clear the exception reason for standard, minor-alignment, and compliant-reference surfaces.',
|
||||
);
|
||||
}
|
||||
|
||||
if (($surface['maxVisiblePrimaryActions'] ?? null) !== 1) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Spec 192 maxVisiblePrimaryActions must stay pinned to 1.',
|
||||
hint: 'The bounded header contract allows at most one visible primary header action.',
|
||||
);
|
||||
}
|
||||
|
||||
if ($classification === 'remediation_required' && ($surface['allowsPrimaryNavigation'] ?? false) === true) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Remediation-required Spec 192 surfaces must not allow primary navigation.',
|
||||
hint: 'Move pure navigation into contextual placement outside the header.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||
*/
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
|
||||
|
||||
const recentKeys = new Map();
|
||||
const recentTransportFailures = [];
|
||||
|
||||
const cleanupRecentKeys = (nowMs) => {
|
||||
for (const [key, timestampMs] of recentKeys.entries()) {
|
||||
@ -19,6 +20,215 @@
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupRecentTransportFailures = (nowMs) => {
|
||||
while (recentTransportFailures.length > 0 && nowMs - recentTransportFailures[0].timestampMs > 15_000) {
|
||||
recentTransportFailures.shift();
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeUrl = (value) => {
|
||||
if (typeof value !== 'string' || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value, window.location.href).href;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const toBodySnippet = (body) => {
|
||||
if (typeof body !== 'string' || body === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return body.slice(0, 1_000);
|
||||
};
|
||||
|
||||
const recordTransportFailure = ({ requestUrl, method, status, body, transportType }) => {
|
||||
const nowMs = Date.now();
|
||||
|
||||
cleanupRecentTransportFailures(nowMs);
|
||||
|
||||
recentTransportFailures.push({
|
||||
requestUrl: normalizeUrl(requestUrl),
|
||||
method: typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET',
|
||||
status: Number.isFinite(status) ? status : null,
|
||||
bodySnippet: toBodySnippet(body),
|
||||
transportType: typeof transportType === 'string' && transportType !== '' ? transportType : 'unknown',
|
||||
timestampMs: nowMs,
|
||||
});
|
||||
|
||||
if (recentTransportFailures.length > 30) {
|
||||
recentTransportFailures.shift();
|
||||
}
|
||||
};
|
||||
|
||||
const resolveTransportMetadata = (reason) => {
|
||||
if (reason === null || typeof reason !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directRequestUrl = typeof reason.requestUrl === 'string'
|
||||
? normalizeUrl(reason.requestUrl)
|
||||
: (typeof reason.url === 'string' ? normalizeUrl(reason.url) : null);
|
||||
|
||||
if (directRequestUrl) {
|
||||
return {
|
||||
requestUrl: directRequestUrl,
|
||||
method: typeof reason.method === 'string' ? reason.method.toUpperCase() : null,
|
||||
transportType: 'reason',
|
||||
};
|
||||
}
|
||||
|
||||
if (!isTransportEnvelope(reason)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const reasonBodySnippet = toBodySnippet(reason.body);
|
||||
|
||||
cleanupRecentTransportFailures(nowMs);
|
||||
|
||||
for (let index = recentTransportFailures.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = recentTransportFailures[index];
|
||||
|
||||
if (nowMs - candidate.timestampMs > 5_000) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (candidate.status !== reason.status) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reasonBodySnippet !== null && candidate.bodySnippet !== null && candidate.bodySnippet !== reasonBodySnippet) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
requestUrl: candidate.requestUrl,
|
||||
method: candidate.method,
|
||||
transportType: candidate.transportType,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractRequestMetadata = (input, init) => {
|
||||
if (input instanceof Request) {
|
||||
return {
|
||||
requestUrl: normalizeUrl(input.url),
|
||||
method: typeof input.method === 'string' && input.method !== ''
|
||||
? input.method.toUpperCase()
|
||||
: 'GET',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requestUrl: normalizeUrl(typeof input === 'string' ? input : String(input ?? '')),
|
||||
method: typeof init?.method === 'string' && init.method !== ''
|
||||
? init.method.toUpperCase()
|
||||
: 'GET',
|
||||
};
|
||||
};
|
||||
|
||||
if (typeof window.fetch === 'function' && !window.__tenantpilotUnhandledRejectionFetchInstrumented) {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
|
||||
window.fetch = async (...args) => {
|
||||
const [input, init] = args;
|
||||
const transport = extractRequestMetadata(input, init);
|
||||
|
||||
try {
|
||||
const response = await originalFetch(...args);
|
||||
|
||||
if (!response.ok) {
|
||||
const clonedResponse = typeof response.clone === 'function' ? response.clone() : null;
|
||||
|
||||
if (clonedResponse && typeof clonedResponse.text === 'function') {
|
||||
clonedResponse.text()
|
||||
.then((body) => {
|
||||
recordTransportFailure({
|
||||
requestUrl: transport.requestUrl,
|
||||
method: transport.method,
|
||||
status: response.status,
|
||||
body,
|
||||
transportType: 'fetch',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
recordTransportFailure({
|
||||
requestUrl: transport.requestUrl,
|
||||
method: transport.method,
|
||||
status: response.status,
|
||||
body: null,
|
||||
transportType: 'fetch',
|
||||
});
|
||||
});
|
||||
} else {
|
||||
recordTransportFailure({
|
||||
requestUrl: transport.requestUrl,
|
||||
method: transport.method,
|
||||
status: response.status,
|
||||
body: null,
|
||||
transportType: 'fetch',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
recordTransportFailure({
|
||||
requestUrl: transport.requestUrl,
|
||||
method: transport.method,
|
||||
status: null,
|
||||
body: error instanceof Error ? error.message : String(error ?? ''),
|
||||
transportType: 'fetch',
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
window.__tenantpilotUnhandledRejectionFetchInstrumented = true;
|
||||
}
|
||||
|
||||
if (typeof XMLHttpRequest !== 'undefined' && !window.__tenantpilotUnhandledRejectionXhrInstrumented) {
|
||||
const originalOpen = XMLHttpRequest.prototype.open;
|
||||
const originalSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||||
this.__tenantpilotRequestMethod = typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET';
|
||||
this.__tenantpilotRequestUrl = normalizeUrl(typeof url === 'string' ? url : String(url ?? ''));
|
||||
|
||||
return originalOpen.call(this, method, url, ...rest);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function (...args) {
|
||||
if (!this.__tenantpilotTransportFailureListenerApplied) {
|
||||
this.addEventListener('loadend', () => {
|
||||
if (typeof this.status === 'number' && this.status >= 400) {
|
||||
recordTransportFailure({
|
||||
requestUrl: this.__tenantpilotRequestUrl,
|
||||
method: this.__tenantpilotRequestMethod,
|
||||
status: this.status,
|
||||
body: typeof this.responseText === 'string' ? this.responseText : null,
|
||||
transportType: 'xhr',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.__tenantpilotTransportFailureListenerApplied = true;
|
||||
}
|
||||
|
||||
return originalSend.apply(this, args);
|
||||
};
|
||||
|
||||
window.__tenantpilotUnhandledRejectionXhrInstrumented = true;
|
||||
}
|
||||
|
||||
const isTransportEnvelope = (value) => {
|
||||
return value !== null
|
||||
&& typeof value === 'object'
|
||||
@ -101,6 +311,9 @@
|
||||
'errors',
|
||||
'reason',
|
||||
'code',
|
||||
'url',
|
||||
'requestUrl',
|
||||
'method',
|
||||
];
|
||||
|
||||
for (const key of allowedKeys) {
|
||||
@ -139,10 +352,14 @@
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const normalizedReason = normalizeReason(event.reason);
|
||||
const transport = resolveTransportMetadata(normalizedReason);
|
||||
const payload = {
|
||||
source: 'window.unhandledrejection',
|
||||
href: window.location.href,
|
||||
timestamp: new Date().toISOString(),
|
||||
requestUrl: transport?.requestUrl ?? null,
|
||||
requestMethod: transport?.method ?? null,
|
||||
transportType: transport?.transportType ?? null,
|
||||
reason: normalizedReason,
|
||||
};
|
||||
|
||||
@ -155,6 +372,7 @@
|
||||
const dedupeKey = toStableJson({
|
||||
source: payload.source,
|
||||
href: payload.href,
|
||||
requestUrl: payload.requestUrl,
|
||||
reason: payload.reason,
|
||||
});
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
$metrics = is_array($state['metrics'] ?? null) ? $state['metrics'] : [];
|
||||
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
|
||||
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
|
||||
$contextLinks = is_array($state['context_links'] ?? null) ? $state['context_links'] : [];
|
||||
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
|
||||
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
|
||||
@endphp
|
||||
@ -72,6 +73,37 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($contextLinks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Related context</div>
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
@foreach ($contextLinks as $link)
|
||||
@php
|
||||
$title = is_string($link['title'] ?? null) ? $link['title'] : null;
|
||||
$label = is_string($link['label'] ?? null) ? $link['label'] : null;
|
||||
$url = is_string($link['url'] ?? null) ? $link['url'] : null;
|
||||
$description = is_string($link['description'] ?? null) ? $link['description'] : null;
|
||||
@endphp
|
||||
|
||||
@continue($title === null || $label === null || $url === null)
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $title }}</div>
|
||||
<div class="mt-2">
|
||||
<x-filament::link :href="$url" icon="heroicon-m-arrow-top-right-on-square">
|
||||
{{ $label }}
|
||||
</x-filament::link>
|
||||
</div>
|
||||
|
||||
@if ($description !== null && trim($description) !== '')
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $description }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div>
|
||||
|
||||
|
||||
@ -1,4 +1,20 @@
|
||||
<x-filament-panels::page>
|
||||
@php($navigationContext = \App\Support\Navigation\CanonicalNavigationContext::fromRequest(request()))
|
||||
|
||||
@if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null)
|
||||
<x-filament::section class="mb-6">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Return path stays quiet while this overview remains focused on alert health and downstream drilldowns.
|
||||
</div>
|
||||
|
||||
<x-filament::button tag="a" color="gray" :href="$navigationContext->backLinkUrl">
|
||||
{{ $navigationContext->backLinkLabel }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
|
||||
@ -14,10 +14,51 @@
|
||||
</x-filament::section>
|
||||
|
||||
@if ($this->showSelectedExceptionSummary && $selectedException)
|
||||
<x-filament::section>
|
||||
<x-filament::section heading="Focused review lane">
|
||||
<x-slot name="description">
|
||||
Selection-bound decisions now define the active work lane. Scope, filters, and drilldowns stay visible without competing with the current review step.
|
||||
</x-slot>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(22rem,26rem)]">
|
||||
@include('filament.pages.monitoring.partials.finding-exception-queue-sidebar', [
|
||||
'selectedException' => $selectedException,
|
||||
])
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="rounded-2xl border border-primary-200 bg-primary-50/80 p-4 shadow-sm dark:border-primary-500/30 dark:bg-primary-500/10">
|
||||
<div class="text-sm font-semibold text-primary-900 dark:text-primary-100">
|
||||
Decision lane
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-primary-800 dark:text-primary-200">
|
||||
@if ($selectedException->isPending())
|
||||
Approve exception and Reject exception are the only promoted next steps while this request remains pending.
|
||||
@else
|
||||
This exception is no longer decision-ready. Use the selected context group to close details or drill into related records.
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Related drilldown
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Open tenant detail and Open finding stay available for context, but they no longer share the same semantic lane as the review decision.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@else
|
||||
<x-filament::section heading="Quiet monitoring mode">
|
||||
<x-slot name="description">
|
||||
Inspect an exception to enter the focused review lane. Scope, filters, and tenant drilldowns stay secondary until one request is actively under review.
|
||||
</x-slot>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No exception is selected right now. Use Inspect exception from the queue to review one request in context.
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
|
||||
@ -1,8 +1,45 @@
|
||||
<x-filament-panels::page>
|
||||
@php($landingHierarchy = $this->landingHierarchySummary())
|
||||
@php($lifecycleSummary = $this->lifecycleVisibilitySummary())
|
||||
@php($staleAttentionTab = \App\Models\OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION)
|
||||
@php($terminalFollowUpTab = \App\Models\OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP)
|
||||
|
||||
<x-filament::section heading="Monitoring landing" class="mb-6">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Tabs, filters, and row inspection define the active work lane. Scope context and return navigation stay secondary.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $landingHierarchy['scope_label'] }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $landingHierarchy['scope_body'] }}</p>
|
||||
</div>
|
||||
|
||||
@if ($landingHierarchy['return_label'] !== null && $landingHierarchy['return_body'] !== null)
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Return path</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $landingHierarchy['return_label'] }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $landingHierarchy['return_body'] }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($landingHierarchy['scope_reset_label'] !== null && $landingHierarchy['scope_reset_body'] !== null)
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope reset</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $landingHierarchy['scope_reset_label'] }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $landingHierarchy['scope_reset_body'] }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Inspect flow</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open run detail</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $landingHierarchy['inspect_body'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::tabs label="Operations tabs">
|
||||
<x-filament::tabs.item
|
||||
:active="$this->activeTab === 'all'"
|
||||
@ -57,3 +94,4 @@
|
||||
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
$blockedBanner = $this->blockedExecutionBanner();
|
||||
$lifecycleBanner = $this->lifecycleBanner();
|
||||
$restoreContinuationBanner = $this->restoreContinuationBanner();
|
||||
$monitoringDetail = $this->monitoringDetailSummary();
|
||||
$pollInterval = $this->pollInterval();
|
||||
@endphp
|
||||
|
||||
@ -10,6 +11,44 @@
|
||||
<div
|
||||
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
|
||||
>
|
||||
<x-filament::section heading="Monitoring detail" class="mb-6">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Navigation lane</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['navigation_label'] }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['navigation_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Utility lane</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Refresh</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['utility_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Related drilldown</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['related_body'] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Follow-up lane</p>
|
||||
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['follow_up_body'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($contextBanner !== null)
|
||||
@php
|
||||
$bannerClasses = match ($contextBanner['tone']) {
|
||||
|
||||
@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\AlertDestinationResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use App\Models\AlertDestination;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
pest()->browser()->timeout(20_000);
|
||||
|
||||
function spec192ApprovedFindingException(Tenant $tenant, User $requester)
|
||||
{
|
||||
$approver = User::factory()->create();
|
||||
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
]);
|
||||
|
||||
/** @var FindingExceptionService $service */
|
||||
$service = app(FindingExceptionService::class);
|
||||
|
||||
$requested = $service->request($finding, $tenant, $requester, [
|
||||
'owner_user_id' => (int) $requester->getKey(),
|
||||
'request_reason' => 'Browser smoke test exception request.',
|
||||
'review_due_at' => now()->addDays(7)->toDateTimeString(),
|
||||
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||
]);
|
||||
|
||||
return $service->approve($requested, $approver, [
|
||||
'effective_from' => now()->subDay()->toDateTimeString(),
|
||||
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||
'approval_reason' => 'Browser smoke approval.',
|
||||
]);
|
||||
}
|
||||
|
||||
it('smokes remediated standard record pages with contextual navigation and one clear next step', function (): void {
|
||||
$tenant = Tenant::factory()->active()->create([
|
||||
'name' => 'Spec192 Browser Tenant',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Spec192 Browser Onboarding Tenant',
|
||||
]);
|
||||
createUserWithTenant(
|
||||
tenant: $onboardingTenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $onboardingTenant->workspace,
|
||||
'tenant' => $onboardingTenant,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'entra_tenant_id' => (string) $onboardingTenant->tenant_id,
|
||||
'tenant_name' => (string) $onboardingTenant->name,
|
||||
],
|
||||
]);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Spec192 Browser Baseline',
|
||||
]);
|
||||
|
||||
$baselineSnapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $baselineSnapshot->getKey()]);
|
||||
|
||||
\App\Models\BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 2],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$exception = spec192ApprovedFindingException($tenant, $user);
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
|
||||
$this->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
visit(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||
->assertSee('Review compare matrix')
|
||||
->assertSee('Compare now');
|
||||
|
||||
visit(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||
->assertSee('Review pack')
|
||||
->assertSee('Refresh evidence');
|
||||
|
||||
visit(FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||
->assertSee('Open finding')
|
||||
->assertSee('Renew exception');
|
||||
|
||||
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee('Evidence snapshot');
|
||||
|
||||
visit(TenantResource::getUrl('edit', ['record' => $onboardingTenant], panel: 'admin'))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||
->assertSee('Resume onboarding')
|
||||
->assertSee('Open tenant detail');
|
||||
});
|
||||
|
||||
it('smokes the explicit workflow-heavy tenant detail exception without javascript errors', function (): void {
|
||||
$tenant = Tenant::factory()->active()->create([
|
||||
'name' => 'Spec192 Workflow Tenant',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
$this->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||
->assertSee('Edit tenant')
|
||||
->assertSee('Open provider connections');
|
||||
});
|
||||
|
||||
it('smokes the compliant reference baseline without header regressions or javascript errors', function (): void {
|
||||
$tenant = Tenant::factory()->active()->create([
|
||||
'name' => 'Spec192 Reference Tenant',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Spec192 Reference Baseline',
|
||||
]);
|
||||
|
||||
$baselineSnapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $baselineSnapshot->getKey()]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'display_name' => 'Spec192 Browser Policy',
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'version_number' => 4,
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $baselineSnapshot->getKey(),
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Spec192 Browser Policy',
|
||||
'version_reference' => [
|
||||
'policy_version_id' => (int) $version->getKey(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec192 Browser Backup',
|
||||
]);
|
||||
|
||||
OperationRun::factory()->forTenant($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$reviewSnapshot = seedTenantReviewEvidence($tenant);
|
||||
$review = composeTenantReviewForTest($tenant, $user, $reviewSnapshot);
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $reviewSnapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => 'Spec192 Browser Destination',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
visit(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||
->waitForText('Download')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee('Regenerate');
|
||||
|
||||
visit(AlertDestinationResource::getUrl('view', ['record' => $destination], panel: 'admin'))
|
||||
->waitForText('Send test message')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee('Details');
|
||||
|
||||
visit(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee('View snapshot');
|
||||
|
||||
visit(WorkspaceResource::getUrl('view', ['record' => $workspace], panel: 'admin'))
|
||||
->waitForText('Memberships')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee('Edit');
|
||||
|
||||
visit(BaselineSnapshotResource::getUrl('view', ['record' => $baselineSnapshot], panel: 'admin'))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length > 0", true)
|
||||
->assertSee('Spec192 Browser Policy');
|
||||
|
||||
visit(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
|
||||
->waitForText('Related context')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length > 0", true)
|
||||
->assertSee('Operations');
|
||||
});
|
||||
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
pest()->browser()->timeout(15_000);
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('smokes remediated, calm-reference, and explicit-exception monitoring surfaces', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Browser hierarchy smoke',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => null,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
$diagnosticsTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(
|
||||
tenant: $diagnosticsTenant,
|
||||
user: $user,
|
||||
role: 'readonly',
|
||||
workspaceRole: 'manager',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
TenantMembership::query()
|
||||
->where('tenant_id', (int) $diagnosticsTenant->getKey())
|
||||
->update(['role' => 'readonly']);
|
||||
|
||||
$this->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
visit(FindingExceptionsQueue::getUrl(panel: 'admin'))
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Quiet monitoring mode');
|
||||
|
||||
visit(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Monitoring detail')
|
||||
->assertSee('Follow-up lane');
|
||||
|
||||
visit('/admin/alerts')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Alert deliveries');
|
||||
|
||||
visit('/admin/t/'.$diagnosticsTenant->external_id.'/diagnostics')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Missing owner');
|
||||
});
|
||||
@ -150,6 +150,55 @@ function alertDeliveryFilterIndicatorLabels($component): array
|
||||
->assertCanNotSeeTableRecords([$deliveryB]);
|
||||
});
|
||||
|
||||
it('keeps deep-linked delivery filters while surfacing origin context quietly', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$destination = AlertDestination::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
$rule = AlertRule::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
]);
|
||||
|
||||
$sentDelivery = AlertDelivery::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'alert_rule_id' => (int) $rule->getKey(),
|
||||
'alert_destination_id' => (int) $destination->getKey(),
|
||||
'status' => AlertDelivery::STATUS_SENT,
|
||||
]);
|
||||
|
||||
$failedDelivery = AlertDelivery::factory()->create([
|
||||
'workspace_id' => $workspaceId,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'alert_rule_id' => (int) $rule->getKey(),
|
||||
'alert_destination_id' => (int) $destination->getKey(),
|
||||
'status' => AlertDelivery::STATUS_FAILED,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'nav' => [
|
||||
'source_surface' => 'alerts.overview',
|
||||
'canonical_route_name' => 'admin.alert-deliveries.index',
|
||||
'back_label' => 'Back to alerts',
|
||||
'back_url' => \App\Filament\Clusters\Monitoring\AlertsCluster::getUrl(panel: 'admin'),
|
||||
],
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(ListAlertDeliveries::class)
|
||||
->assertActionVisible('operate_hub_back_to_origin_alert_deliveries')
|
||||
->filterTable('status', AlertDelivery::STATUS_SENT)
|
||||
->assertCanSeeTableRecords([$sentDelivery])
|
||||
->assertCanNotSeeTableRecords([$failedDelivery]);
|
||||
});
|
||||
|
||||
it('replaces the persisted tenant filter when canonical tenant context changes', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
@ -49,7 +49,10 @@
|
||||
->assertSee('Artifact truth')
|
||||
->assertSee($tenantA->name)
|
||||
->assertSee($tenantB->name)
|
||||
->assertDontSee($foreignWorkspaceTenant->name);
|
||||
->assertDontSee($foreignWorkspaceTenant->name)
|
||||
->assertDontSee('Monitoring landing')
|
||||
->assertDontSee('Navigation lane')
|
||||
->assertDontSee('Focused review lane');
|
||||
});
|
||||
|
||||
it('returns 404 for users without workspace membership on the evidence overview', function (): void {
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -20,6 +21,7 @@
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
|
||||
@ -53,6 +55,17 @@ function seedEvidenceDomain(Tenant $tenant): void
|
||||
OperationRun::factory()->forTenant($tenant)->create();
|
||||
}
|
||||
|
||||
function evidenceSnapshotHeaderActions(Testable $component): array
|
||||
{
|
||||
$instance = $component->instance();
|
||||
|
||||
if ($instance->getCachedHeaderActions() === []) {
|
||||
$instance->cacheInteractsWithHeaderActions();
|
||||
}
|
||||
|
||||
return $instance->getCachedHeaderActions();
|
||||
}
|
||||
|
||||
it('renders the evidence list page for an authorized user', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
@ -125,27 +138,47 @@ function seedEvidenceDomain(Tenant $tenant): void
|
||||
it('renders the view page for an active snapshot', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create();
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 2],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||
->assertOk();
|
||||
->assertOk()
|
||||
->assertSee('Related context')
|
||||
->assertSee('Review pack');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||
->assertActionVisible('refresh_snapshot')
|
||||
->assertActionVisible('expire_snapshot');
|
||||
|
||||
expect(collect(evidenceSnapshotHeaderActions($component))
|
||||
->map(static fn ($action): ?string => method_exists($action, 'getName') ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all())
|
||||
->toEqualCanonicalizing(['refresh_snapshot', 'expire_snapshot'])
|
||||
->and(collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))->pluck('key')->all())
|
||||
->toContain('operation_run', 'review_pack');
|
||||
});
|
||||
|
||||
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
|
||||
|
||||
@ -136,6 +136,26 @@ function getAlertDeliveryHeaderAction(Testable $component, string $name): ?Actio
|
||||
expect(getAlertDeliveryHeaderAction($component, 'view_alert_rules'))->toBeNull();
|
||||
});
|
||||
|
||||
it('shows quiet origin navigation on alert deliveries when deep-linked from alerts overview', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'nav' => [
|
||||
'source_surface' => 'alerts.overview',
|
||||
'canonical_route_name' => 'admin.alert-deliveries.index',
|
||||
'back_label' => 'Back to alerts',
|
||||
'back_url' => \App\Filament\Clusters\Monitoring\AlertsCluster::getUrl(panel: 'admin'),
|
||||
],
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(ListAlertDeliveries::class)
|
||||
->assertActionVisible('operate_hub_back_to_origin_alert_deliveries');
|
||||
});
|
||||
|
||||
it('returns 404 when a member from another workspace tries to view a delivery', function (): void {
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -53,6 +53,22 @@ function auditLogPageTestRecord(?Tenant $tenant, array $attributes = []): AuditL
|
||||
->assertSee('Review governance, operational, and workspace-admin events in reverse chronological order');
|
||||
});
|
||||
|
||||
it('keeps preselected audit detail subordinate to the summary-first route', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$audit = auditLogPageTestRecord($tenant, [
|
||||
'summary' => 'Preselected audit detail',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.monitoring.audit-log', ['event' => (int) $audit->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Summary-first audit history')
|
||||
->assertSee('Preselected audit detail')
|
||||
->assertDontSee('Focused review lane');
|
||||
});
|
||||
|
||||
it('loads the audit page with populated filter options', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -61,6 +61,8 @@
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertActionVisible('compareNow')
|
||||
->assertActionDisabled('compareNow')
|
||||
->assertDontSee('Monitoring landing')
|
||||
->assertDontSee('Navigation lane')
|
||||
->callAction('compareNow')
|
||||
->assertStatus(200);
|
||||
|
||||
|
||||
@ -41,6 +41,8 @@
|
||||
->assertOk()
|
||||
->assertSee('Visible-set baseline')
|
||||
->assertSee('Requested: Auto mode. Resolved: Dense mode.')
|
||||
->assertDontSee('Monitoring landing')
|
||||
->assertDontSee('Focused review lane')
|
||||
->assertDontSee('fonts/filament/filament/inter/inter-latin-wght-normal', false)
|
||||
->assertDontSee('Passive auto-refresh every 5 seconds')
|
||||
->assertSee('Grouped legend')
|
||||
|
||||
@ -9,12 +9,26 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function baselineProfileCaptureHeaderActions(Testable $component): array
|
||||
{
|
||||
$instance = $component->instance();
|
||||
|
||||
if ($instance->getCachedHeaderActions() === []) {
|
||||
$instance->cacheInteractsWithHeaderActions();
|
||||
}
|
||||
|
||||
return $instance->getCachedHeaderActions();
|
||||
}
|
||||
|
||||
it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -78,7 +92,7 @@
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('capture')
|
||||
->assertActionHasLabel('capture', 'Capture baseline (full content)')
|
||||
@ -86,6 +100,16 @@
|
||||
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
||||
->assertStatus(200);
|
||||
|
||||
$topLevelActionNames = collect(baselineProfileCaptureHeaderActions($component))
|
||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($topLevelActionNames)->toBe(['capture']);
|
||||
|
||||
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
||||
|
||||
$run = OperationRun::query()
|
||||
|
||||
@ -10,9 +10,22 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function baselineProfileHeaderActions(Testable $component): array
|
||||
{
|
||||
$instance = $component->instance();
|
||||
|
||||
if ($instance->getCachedHeaderActions() === []) {
|
||||
$instance->cacheInteractsWithHeaderActions();
|
||||
}
|
||||
|
||||
return $instance->getCachedHeaderActions();
|
||||
}
|
||||
|
||||
it('does not start baseline compare for workspace members missing tenant.sync', function (): void {
|
||||
Queue::fake();
|
||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||
@ -135,7 +148,7 @@
|
||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('shows open-compare-matrix and compare-assigned-tenants header actions with simulation-only copy', function (): void {
|
||||
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -158,16 +171,35 @@
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionExists('openCompareMatrix', fn (Action $action): bool => $action->getLabel() === 'Open compare matrix'
|
||||
&& $action->getUrl() === BaselineProfileResource::compareMatrixUrl($profile))
|
||||
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getLabel() === 'Compare assigned tenants'
|
||||
&& $action->isConfirmationRequired()
|
||||
&& str_contains((string) $action->getModalDescription(), 'Simulation only.'));
|
||||
|
||||
$topLevelActionNames = collect(baselineProfileHeaderActions($component))
|
||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
$moreGroup = collect(baselineProfileHeaderActions($component))
|
||||
->first(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible());
|
||||
$moreActionNames = collect($moreGroup?->getActions() ?? [])
|
||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($topLevelActionNames)->toBe(['compareNow'])
|
||||
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
||||
->and($moreActionNames)->toEqualCanonicalizing(['compareAssignedTenants', 'edit'])
|
||||
->and(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
|
||||
->toContain('compare_matrix', 'baseline_snapshot');
|
||||
});
|
||||
|
||||
it('keeps compare-assigned-tenants visible but disabled for readonly workspace members', function (): void {
|
||||
it('keeps compare-assigned-tenants visible but disabled for readonly workspace members after the navigation move', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
@ -191,7 +223,9 @@
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||
->assertActionVisible('openCompareMatrix')
|
||||
->assertActionVisible('compareAssignedTenants')
|
||||
->assertActionDisabled('compareAssignedTenants');
|
||||
|
||||
expect(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
|
||||
->toContain('compare_matrix');
|
||||
});
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('keeps database notifications enabled without background polling on every panel', function (): void {
|
||||
foreach (['admin', 'tenant', 'system'] as $panelId) {
|
||||
$panel = Filament::getPanel($panelId);
|
||||
|
||||
expect($panel->hasDatabaseNotifications())->toBeTrue();
|
||||
expect($panel->getDatabaseNotificationsPollingInterval())->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the admin notifications modal without a polling attribute', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin');
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$html = $response->getContent();
|
||||
|
||||
expect($html)->toContain('wire:name="Filament\\Livewire\\DatabaseNotifications"');
|
||||
|
||||
preg_match('/<[^>]+wire:name="Filament\\\\Livewire\\\\DatabaseNotifications"[^>]*>/', $html, $matches);
|
||||
|
||||
expect($matches)->not->toBeEmpty('Expected the admin page to render the database notifications Livewire root element.');
|
||||
expect($matches[0])->not->toContain('wire:poll');
|
||||
expect($matches[0])->not->toContain('wire:poll.30s');
|
||||
});
|
||||
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function editTenantHeaderActions(Testable $component): array
|
||||
{
|
||||
$instance = $component->instance();
|
||||
|
||||
if ($instance->getCachedHeaderActions() === []) {
|
||||
$instance->cacheInteractsWithHeaderActions();
|
||||
}
|
||||
|
||||
return $instance->getCachedHeaderActions();
|
||||
}
|
||||
|
||||
function editTenantHeaderGroupLabels(Testable $component): array
|
||||
{
|
||||
return collect(editTenantHeaderActions($component))
|
||||
->filter(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible())
|
||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
function editTenantHeaderPrimaryNames(Testable $component): array
|
||||
{
|
||||
return collect(editTenantHeaderActions($component))
|
||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
it('keeps related links in contextual placement and reserves the header for lifecycle actions', function (): void {
|
||||
$tenant = Tenant::factory()->onboarding()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||
|
||||
createOnboardingDraft([
|
||||
'workspace' => $tenant->workspace,
|
||||
'tenant' => $tenant,
|
||||
'started_by' => $user,
|
||||
'updated_by' => $user,
|
||||
'state' => [
|
||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||
'tenant_name' => (string) $tenant->name,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertSee('Related context')
|
||||
->assertSee('Open tenant detail')
|
||||
->assertSee('Resume onboarding');
|
||||
|
||||
expect(editTenantHeaderPrimaryNames($component))->toBe([])
|
||||
->and(editTenantHeaderGroupLabels($component))->toBe([]);
|
||||
});
|
||||
|
||||
it('keeps tenant lifecycle mutations available under the lifecycle header group with confirmation intact', function (): void {
|
||||
$tenant = Tenant::factory()->active()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('archive')
|
||||
->assertActionEnabled('archive')
|
||||
->assertActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired());
|
||||
|
||||
expect(editTenantHeaderPrimaryNames($component))->toBe([])
|
||||
->and(editTenantHeaderGroupLabels($component))->toBe(['Lifecycle']);
|
||||
});
|
||||
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function findingExceptionHeaderActions(Testable $component): array
|
||||
{
|
||||
$instance = $component->instance();
|
||||
|
||||
if ($instance->getCachedHeaderActions() === []) {
|
||||
$instance->cacheInteractsWithHeaderActions();
|
||||
}
|
||||
|
||||
return $instance->getCachedHeaderActions();
|
||||
}
|
||||
|
||||
function findingExceptionHeaderNames(Testable $component): array
|
||||
{
|
||||
return collect(findingExceptionHeaderActions($component))
|
||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
it('keeps finding navigation out of the header while preserving renewal and revocation actions', function (): void {
|
||||
[$requester, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$approver = User::factory()->create();
|
||||
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
]);
|
||||
|
||||
/** @var FindingExceptionService $service */
|
||||
$service = app(FindingExceptionService::class);
|
||||
|
||||
$requested = $service->request($finding, $tenant, $requester, [
|
||||
'owner_user_id' => (int) $requester->getKey(),
|
||||
'request_reason' => 'Existing compensating controls remain in place.',
|
||||
'review_due_at' => now()->addDays(7)->toDateTimeString(),
|
||||
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$exception = $service->approve($requested, $approver, [
|
||||
'effective_from' => now()->subDay()->toDateTimeString(),
|
||||
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||
'approval_reason' => 'Accepted while remediation is scheduled.',
|
||||
]);
|
||||
|
||||
$this->actingAs($requester);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(ViewFindingException::class, ['record' => $exception->getKey()])
|
||||
->assertActionVisible('renew_exception')
|
||||
->assertActionVisible('revoke_exception')
|
||||
->assertActionExists('revoke_exception', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||
->assertSee('Related context')
|
||||
->assertSee('Approval queue')
|
||||
->assertSee('Open finding');
|
||||
|
||||
expect(findingExceptionHeaderNames($component))
|
||||
->toEqualCanonicalizing(['renew_exception', 'revoke_exception'])
|
||||
->not->toContain('open_finding', 'open_approval_queue');
|
||||
});
|
||||
@ -16,6 +16,19 @@
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Tenant diagnostics repairs', function () {
|
||||
it('hides repair actions when no defect is present', function () {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(TenantDiagnostics::class)
|
||||
->assertSee('All good')
|
||||
->assertActionHidden('bootstrapOwner')
|
||||
->assertActionHidden('mergeDuplicateMemberships');
|
||||
});
|
||||
|
||||
it('allows an authorized member to bootstrap an owner when a tenant has no owners', function () {
|
||||
[$manager, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||
use App\Models\TenantReview;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function tenantReviewHeaderActions(Testable $component): array
|
||||
{
|
||||
$instance = $component->instance();
|
||||
|
||||
if ($instance->getCachedHeaderActions() === []) {
|
||||
$instance->cacheInteractsWithHeaderActions();
|
||||
}
|
||||
|
||||
return $instance->getCachedHeaderActions();
|
||||
}
|
||||
|
||||
function tenantReviewHeaderPrimaryNames(Testable $component): array
|
||||
{
|
||||
return collect(tenantReviewHeaderActions($component))
|
||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
function tenantReviewHeaderGroupLabels(Testable $component): array
|
||||
{
|
||||
return collect(tenantReviewHeaderActions($component))
|
||||
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
it('keeps ready reviews to one primary action and renders related navigation in the summary context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$review = composeTenantReviewForTest($tenant, $user);
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->assertSee('Related context')
|
||||
->assertSee('Evidence snapshot');
|
||||
|
||||
expect(tenantReviewHeaderPrimaryNames($component))->toBe(['publish_review'])
|
||||
->and(tenantReviewHeaderGroupLabels($component))->toBe(['More', 'Danger']);
|
||||
});
|
||||
|
||||
it('promotes executive-pack export as the only visible primary action after publication', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$review = composeTenantReviewForTest($tenant, $user);
|
||||
|
||||
$review->forceFill([
|
||||
'status' => TenantReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||
->assertActionVisible('export_executive_pack')
|
||||
->assertActionEnabled('export_executive_pack');
|
||||
|
||||
expect(tenantReviewHeaderPrimaryNames($component))->toBe(['export_executive_pack'])
|
||||
->and(tenantReviewHeaderGroupLabels($component))->toContain('More')
|
||||
->and(tenantReviewHeaderPrimaryNames($component))->not->toContain('refresh_review', 'publish_review');
|
||||
});
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
@ -14,7 +15,7 @@
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Tenant View header action UI enforcement', function () {
|
||||
it('shows edit and archive actions as visible but disabled for readonly members', function () {
|
||||
it('keeps archive visible in the workflow header and moves edit/provider navigation into contextual unavailable entries for readonly members', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->actingAs($user);
|
||||
@ -23,19 +24,19 @@
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('edit')
|
||||
->assertActionDisabled('edit')
|
||||
->assertActionExists('edit', function (Action $action): bool {
|
||||
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
||||
})
|
||||
->assertActionVisible('archive')
|
||||
->assertActionDisabled('archive')
|
||||
->assertActionExists('archive', function (Action $action): bool {
|
||||
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
||||
});
|
||||
|
||||
$contextEntries = collect(TenantResource::tenantViewContextEntries($tenant))->keyBy('key');
|
||||
|
||||
expect($contextEntries->get('tenant_edit')['availability'] ?? null)->toBe('authorization_denied')
|
||||
->and($contextEntries->get('provider_connections')['availability'] ?? null)->toBe('available');
|
||||
});
|
||||
|
||||
it('shows edit and archive actions as enabled for owner members', function () {
|
||||
it('keeps archive enabled for owner members and exposes edit/provider navigation in contextual related content', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
@ -44,10 +45,13 @@
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('edit')
|
||||
->assertActionEnabled('edit')
|
||||
->assertActionVisible('archive')
|
||||
->assertActionEnabled('archive');
|
||||
|
||||
$contextEntries = collect(TenantResource::tenantViewContextEntries($tenant))->keyBy('key');
|
||||
|
||||
expect($contextEntries->get('tenant_edit')['availability'] ?? null)->toBe('available')
|
||||
->and($contextEntries->get('provider_connections')['availability'] ?? null)->toBe('available');
|
||||
});
|
||||
|
||||
it('does not execute the archive action for readonly members (silently blocked by Filament)', function () {
|
||||
@ -85,11 +89,9 @@
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('related_onboarding')
|
||||
->assertActionExists('related_onboarding', function (Action $action): bool {
|
||||
return $action->getLabel() === 'Resume onboarding';
|
||||
});
|
||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))
|
||||
->firstWhere('key', 'related_onboarding')['value'] ?? null)
|
||||
->toBe('Resume onboarding');
|
||||
});
|
||||
|
||||
it('shows a cancelled-onboarding label and repairs stale onboarding tenant status when the linked draft was cancelled', function () {
|
||||
@ -126,10 +128,8 @@
|
||||
->where('action', \App\Support\Audit\AuditActionId::TenantReturnedToDraft->value)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('related_onboarding')
|
||||
->assertActionExists('related_onboarding', function (Action $action): bool {
|
||||
return $action->getLabel() === 'View cancelled onboarding draft';
|
||||
});
|
||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))
|
||||
->firstWhere('key', 'related_onboarding')['value'] ?? null)
|
||||
->toBe('View cancelled onboarding draft');
|
||||
});
|
||||
});
|
||||
|
||||
@ -15,6 +15,13 @@
|
||||
expect($js)
|
||||
->toContain('__tenantpilotUnhandledRejectionLoggerApplied')
|
||||
->toContain("window.addEventListener('unhandledrejection'")
|
||||
->toContain('window.fetch = async (...args) =>')
|
||||
->toContain('XMLHttpRequest.prototype.open = function (method, url, ...rest)')
|
||||
->toContain('const transport = resolveTransportMetadata(normalizedReason)')
|
||||
->toContain('requestUrl: transport?.requestUrl ?? null')
|
||||
->toContain('requestMethod: transport?.method ?? null')
|
||||
->toContain('transportType: transport?.transportType ?? null')
|
||||
->toContain('requestUrl: payload.requestUrl')
|
||||
->toContain('isExpectedBackgroundTransportFailure')
|
||||
->toContain("document.visibilityState !== 'visible'")
|
||||
->toContain('document.hasFocus')
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Pages\BaselineCompareMatrix;
|
||||
use App\Filament\Pages\Monitoring\Alerts;
|
||||
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
||||
use App\Filament\Pages\Monitoring\EvidenceOverview;
|
||||
@ -817,9 +819,8 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
expect(method_exists(TenantRequiredPermissions::class, 'actionSurfaceDeclaration'))->toBeTrue()
|
||||
->and($baselineExemptions->hasClass(TenantRequiredPermissions::class))->toBeFalse();
|
||||
|
||||
expect(method_exists(Alerts::class, 'actionSurfaceDeclaration'))->toBeFalse()
|
||||
->and($baselineExemptions->hasClass(Alerts::class))->toBeTrue()
|
||||
->and((string) $baselineExemptions->reasonForClass(Alerts::class))->toContain('cluster entry');
|
||||
expect(method_exists(Alerts::class, 'actionSurfaceDeclaration'))->toBeTrue()
|
||||
->and($baselineExemptions->hasClass(Alerts::class))->toBeFalse();
|
||||
|
||||
expect($baselineExemptions->hasClass(ManagedTenantOnboardingWizard::class))->toBeTrue()
|
||||
->and((string) $baselineExemptions->reasonForClass(ManagedTenantOnboardingWizard::class))->toContain('dedicated conformance tests')
|
||||
@ -1996,3 +1997,185 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
|
||||
expect((string) $run?->initiator_name)->toBe((string) $user->name);
|
||||
});
|
||||
|
||||
it('documents the spec 192 workflow-heavy exception and reference inventory', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec192RecordPageInventory();
|
||||
$workflowHeavy = collect($inventory)
|
||||
->filter(fn (array $surface): bool => $surface['classification'] === 'workflow_heavy_special_type')
|
||||
->keys()
|
||||
->values()
|
||||
->all();
|
||||
$referencePages = collect($inventory)
|
||||
->filter(fn (array $surface): bool => $surface['classification'] === 'compliant_reference')
|
||||
->keys()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($workflowHeavy)->toBe([\App\Filament\Resources\TenantResource\Pages\ViewTenant::class])
|
||||
->and($referencePages)->toEqualCanonicalizing([
|
||||
\App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack::class,
|
||||
\App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination::class,
|
||||
\App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion::class,
|
||||
\App\Filament\Resources\Workspaces\Pages\ViewWorkspace::class,
|
||||
\App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot::class,
|
||||
\App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
|
||||
]);
|
||||
});
|
||||
|
||||
it('documents the spec 193 monitoring hierarchy inventory and explicit exception', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec193MonitoringSurfaceInventory();
|
||||
$baselineExemptions = ActionSurfaceExemptions::baseline();
|
||||
|
||||
$remediationRequired = collect($inventory)
|
||||
->filter(fn (array $surface): bool => $surface['classification'] === 'remediation_required')
|
||||
->keys()
|
||||
->values()
|
||||
->all();
|
||||
$calmReferences = collect($inventory)
|
||||
->filter(fn (array $surface): bool => $surface['classification'] === 'compliant_no_op')
|
||||
->keys()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect(array_keys($inventory))->toEqualCanonicalizing([
|
||||
FindingExceptionsQueue::class,
|
||||
TenantlessOperationRunViewer::class,
|
||||
Operations::class,
|
||||
Alerts::class,
|
||||
AuditLogPage::class,
|
||||
ListAlertDeliveries::class,
|
||||
EvidenceOverview::class,
|
||||
BaselineCompareLanding::class,
|
||||
BaselineCompareMatrix::class,
|
||||
ReviewRegister::class,
|
||||
TenantDiagnostics::class,
|
||||
])
|
||||
->and($baselineExemptions->hasClass(Alerts::class))->toBeFalse()
|
||||
->and(method_exists(Alerts::class, 'actionSurfaceDeclaration'))->toBeTrue()
|
||||
->and($remediationRequired)->toEqualCanonicalizing([
|
||||
FindingExceptionsQueue::class,
|
||||
TenantlessOperationRunViewer::class,
|
||||
Operations::class,
|
||||
])
|
||||
->and($calmReferences)->toEqualCanonicalizing([
|
||||
EvidenceOverview::class,
|
||||
BaselineCompareLanding::class,
|
||||
BaselineCompareMatrix::class,
|
||||
ReviewRegister::class,
|
||||
])
|
||||
->and(ActionSurfaceExemptions::spec193MonitoringSurface(TenantDiagnostics::class)['classification'] ?? null)->toBe('special_type_acceptable')
|
||||
->and(ActionSurfaceExemptions::spec193MonitoringSurface(TenantDiagnostics::class)['exceptionReason'] ?? null)->toContain('diagnostic');
|
||||
});
|
||||
|
||||
it('keeps spec 193 hierarchy work from expanding confirmation, reason capture, or compare-start semantics', function (): void {
|
||||
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$mountedActionFieldNames = static function (mixed $component): array {
|
||||
$method = new \ReflectionMethod($component->instance(), 'getMountedActionForm');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$form = $method->invoke($component->instance());
|
||||
|
||||
return collect($form?->getFlatFields(withHidden: true) ?? [])
|
||||
->map(static fn (mixed $field): ?string => method_exists($field, 'getName') ? $field->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
};
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $approver->getKey(),
|
||||
'owner_user_id' => (int) $approver->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Guarded spec 193 review',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$this->actingAs($approver);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$approveComponent = Livewire::withQueryParams([
|
||||
'exception' => (int) $exception->getKey(),
|
||||
])
|
||||
->actingAs($approver)
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertActionExists('approve_selected_exception', function (Action $action): bool {
|
||||
return $action->isConfirmationRequired();
|
||||
})
|
||||
->mountAction('approve_selected_exception');
|
||||
|
||||
expect($mountedActionFieldNames($approveComponent))->toBe([
|
||||
'effective_from',
|
||||
'expires_at',
|
||||
'approval_reason',
|
||||
]);
|
||||
|
||||
$rejectComponent = Livewire::withQueryParams([
|
||||
'exception' => (int) $exception->getKey(),
|
||||
])
|
||||
->actingAs($approver)
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertActionExists('reject_selected_exception', function (Action $action): bool {
|
||||
return $action->isConfirmationRequired();
|
||||
})
|
||||
->mountAction('reject_selected_exception');
|
||||
|
||||
expect($mountedActionFieldNames($rejectComponent))->toBe([
|
||||
'rejection_reason',
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'capture_mode' => \App\Support\Baselines\BaselineCaptureMode::FullContent->value,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($approver)
|
||||
->test(BaselineCompareLanding::class)
|
||||
->assertActionExists('compareNow', function (Action $action): bool {
|
||||
return $action->isConfirmationRequired()
|
||||
&& $action->getModalDescription() === 'This will refresh content evidence on demand (redacted) before comparing the current tenant inventory against the assigned baseline snapshot.';
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps spec 192 remediated pages out of the enterprise-detail layout rollout', function (): void {
|
||||
foreach ([
|
||||
\App\Filament\Resources\BaselineProfileResource::class,
|
||||
\App\Filament\Resources\EvidenceSnapshotResource::class,
|
||||
\App\Filament\Resources\FindingExceptionResource::class,
|
||||
\App\Filament\Resources\TenantReviewResource::class,
|
||||
\App\Filament\Resources\TenantResource::class,
|
||||
\App\Filament\Resources\TenantResource\Pages\EditTenant::class,
|
||||
\App\Filament\Resources\TenantResource\Pages\ViewTenant::class,
|
||||
] as $className) {
|
||||
$source = file_get_contents((string) (new ReflectionClass($className))->getFileName()) ?: '';
|
||||
|
||||
expect($source)
|
||||
->not->toContain('EnterpriseDetail')
|
||||
->not->toContain('enterprise-detail/header');
|
||||
}
|
||||
});
|
||||
|
||||
@ -229,3 +229,19 @@ className: $className,
|
||||
expect($result->hasIssues())->toBeTrue();
|
||||
expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty');
|
||||
});
|
||||
|
||||
it('accepts the repository spec 192 inventory even when only inventory validation runs', function (): void {
|
||||
$validator = ActionSurfaceValidator::withBaselineExemptions();
|
||||
|
||||
$result = $validator->validateComponents([]);
|
||||
|
||||
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
||||
});
|
||||
|
||||
it('accepts the repository spec 193 monitoring inventory even when only inventory validation runs', function (): void {
|
||||
$validator = ActionSurfaceValidator::withBaselineExemptions();
|
||||
|
||||
$result = $validator->validateComponents([]);
|
||||
|
||||
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
||||
});
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
||||
|
||||
function spec192RecordPageSource(string $className): string
|
||||
{
|
||||
$reflection = new ReflectionClass($className);
|
||||
$path = $reflection->getFileName();
|
||||
|
||||
expect($path)->toBeString();
|
||||
|
||||
return file_get_contents((string) $path) ?: '';
|
||||
}
|
||||
|
||||
it('keeps the spec 192 record-page inventory complete and explicitly classified', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec192RecordPageInventory();
|
||||
|
||||
expect(array_keys($inventory))->toEqualCanonicalizing([
|
||||
\App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile::class,
|
||||
\App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot::class,
|
||||
\App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException::class,
|
||||
\App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview::class,
|
||||
EditTenant::class,
|
||||
ViewTenant::class,
|
||||
\App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection::class,
|
||||
\App\Filament\Resources\FindingResource\Pages\ViewFinding::class,
|
||||
\App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack::class,
|
||||
\App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination::class,
|
||||
\App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion::class,
|
||||
\App\Filament\Resources\Workspaces\Pages\ViewWorkspace::class,
|
||||
\App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot::class,
|
||||
\App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
|
||||
]);
|
||||
|
||||
expect(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class))
|
||||
->not->toBeNull()
|
||||
->and(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class)['classification'] ?? null)->toBe('workflow_heavy_special_type')
|
||||
->and(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class)['exceptionReason'] ?? null)->toContain('workflow-heavy hub')
|
||||
->and(ActionSurfaceExemptions::spec192RecordPageSurface(EditTenant::class)['classification'] ?? null)->toBe('remediation_required')
|
||||
->and(ActionSurfaceExemptions::spec192RecordPageSurface(EditTenant::class)['allowsNoPrimaryAction'] ?? null)->toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps the spec 192 inventory valid inside the action-surface validator', function (): void {
|
||||
$result = ActionSurfaceValidator::withBaselineExemptions()->validateComponents([]);
|
||||
|
||||
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
||||
});
|
||||
|
||||
it('keeps remediated spec 192 pages off the enterprise-detail body-layout builder', function (): void {
|
||||
foreach ([
|
||||
BaselineProfileResource::class,
|
||||
EvidenceSnapshotResource::class,
|
||||
FindingExceptionResource::class,
|
||||
TenantReviewResource::class,
|
||||
TenantResource::class,
|
||||
EditTenant::class,
|
||||
ViewTenant::class,
|
||||
] as $className) {
|
||||
expect(spec192RecordPageSource($className))
|
||||
->not->toContain('EnterpriseDetail')
|
||||
->not->toContain('enterprise-detail/header');
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Pages\BaselineCompareMatrix;
|
||||
use App\Filament\Pages\Monitoring\Alerts;
|
||||
use App\Filament\Pages\Monitoring\AuditLog as AuditLogPage;
|
||||
use App\Filament\Pages\Monitoring\EvidenceOverview;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\Monitoring\Operations;
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\TenantDiagnostics;
|
||||
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
||||
|
||||
it('keeps the spec 193 monitoring inventory complete and explicitly classified', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec193MonitoringSurfaceInventory();
|
||||
|
||||
expect(array_keys($inventory))->toEqualCanonicalizing([
|
||||
FindingExceptionsQueue::class,
|
||||
TenantlessOperationRunViewer::class,
|
||||
Operations::class,
|
||||
Alerts::class,
|
||||
AuditLogPage::class,
|
||||
ListAlertDeliveries::class,
|
||||
EvidenceOverview::class,
|
||||
BaselineCompareLanding::class,
|
||||
BaselineCompareMatrix::class,
|
||||
ReviewRegister::class,
|
||||
TenantDiagnostics::class,
|
||||
])
|
||||
->and(ActionSurfaceExemptions::spec193MonitoringSurface(FindingExceptionsQueue::class)['classification'] ?? null)->toBe('remediation_required')
|
||||
->and(ActionSurfaceExemptions::spec193MonitoringSurface(FindingExceptionsQueue::class)['surfaceKind'] ?? null)->toBe('queue_workbench')
|
||||
->and(ActionSurfaceExemptions::spec193MonitoringSurface(FindingExceptionsQueue::class)['primaryInspectModel'] ?? null)->toBe('explicit_inspect_action')
|
||||
->and(ActionSurfaceExemptions::spec193MonitoringSurface(Alerts::class)['classification'] ?? null)->toBe('minor_alignment_only')
|
||||
->and(ActionSurfaceExemptions::spec193MonitoringSurface(EvidenceOverview::class)['classification'] ?? null)->toBe('compliant_no_op')
|
||||
->and(ActionSurfaceExemptions::spec193MonitoringSurface(TenantDiagnostics::class)['classification'] ?? null)->toBe('special_type_acceptable');
|
||||
});
|
||||
|
||||
it('keeps tenant diagnostics as the only explicit spec 193 exception surface', function (): void {
|
||||
$inventory = ActionSurfaceExemptions::spec193MonitoringSurfaceInventory();
|
||||
|
||||
$exceptionPages = collect($inventory)
|
||||
->filter(fn (array $surface): bool => $surface['classification'] === 'special_type_acceptable')
|
||||
->keys()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($exceptionPages)->toBe([
|
||||
TenantDiagnostics::class,
|
||||
])
|
||||
->and(ActionSurfaceExemptions::spec193MonitoringSurface(TenantDiagnostics::class)['exceptionReason'] ?? null)->toContain('diagnostic')
|
||||
->and(ActionSurfaceExemptions::spec193MonitoringSurface(TenantDiagnostics::class)['surfaceKind'] ?? null)->toBe('diagnostic_exception')
|
||||
->and(ActionSurfaceExemptions::spec193MonitoringSurface(TenantDiagnostics::class)['primaryInspectModel'] ?? null)->toBe('singleton_detail_surface')
|
||||
->and(collect($inventory)
|
||||
->except([TenantDiagnostics::class])
|
||||
->every(fn (array $surface): bool => ($surface['exceptionReason'] ?? null) === null))->toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps the spec 193 monitoring inventory valid inside the action-surface validator', function (): void {
|
||||
$result = ActionSurfaceValidator::withBaselineExemptions()->validateComponents([]);
|
||||
|
||||
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
||||
});
|
||||
|
||||
it('keeps spec 193 monitoring surfaces out of record-page header layouts', function (): void {
|
||||
foreach (array_keys(ActionSurfaceExemptions::spec193MonitoringSurfaceInventory()) as $className) {
|
||||
$source = file_get_contents((string) (new \ReflectionClass($className))->getFileName()) ?: '';
|
||||
|
||||
expect($source)
|
||||
->not->toContain('EnterpriseDetail')
|
||||
->not->toContain('enterprise-detail/header');
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\Alerts;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('keeps alerts as a quiet overview with downstream drilldown entry points', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->followingRedirects()
|
||||
->get('/admin/alerts')
|
||||
->assertOk()
|
||||
->assertSee('Alert targets')
|
||||
->assertSee('Alert rules')
|
||||
->assertSee('Alert deliveries')
|
||||
->assertDontSee('Focused review lane')
|
||||
->assertDontSee('Follow-up lane');
|
||||
});
|
||||
|
||||
it('surfaces origin context quietly on the alerts overview', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
setAdminPanelContext();
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'nav' => [
|
||||
'source_surface' => 'backup_set.detail_section',
|
||||
'canonical_route_name' => 'admin.alerts.overview',
|
||||
'back_label' => 'Back to backup set',
|
||||
'back_url' => '/admin/tenant/backup-sets/1',
|
||||
],
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(Alerts::class)
|
||||
->assertSee('Back to backup set')
|
||||
->assertSee('/admin/tenant/backup-sets/1', false);
|
||||
});
|
||||
@ -165,3 +165,39 @@
|
||||
->assertDontSee('Close details')
|
||||
->assertDontSee('Open operation');
|
||||
});
|
||||
|
||||
it('surfaces origin context quietly when deep-linked to a selected audit event', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$audit = AuditLog::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_email' => 'owner@example.com',
|
||||
'actor_name' => 'Owner',
|
||||
'actor_type' => 'human',
|
||||
'action' => 'workspace.selected',
|
||||
'status' => 'success',
|
||||
'resource_type' => 'workspace',
|
||||
'resource_id' => (string) $tenant->workspace_id,
|
||||
'target_label' => 'Workspace 1',
|
||||
'summary' => 'Workspace selected for Workspace 1',
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'event' => (int) $audit->getKey(),
|
||||
'nav' => [
|
||||
'source_surface' => 'alerts.overview',
|
||||
'canonical_route_name' => 'admin.monitoring.audit-log',
|
||||
'back_label' => 'Back to alerts',
|
||||
'back_url' => '/admin/alerts',
|
||||
],
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(AuditLogPage::class)
|
||||
->assertSet('selectedAuditLogId', (int) $audit->getKey())
|
||||
->assertActionVisible('operate_hub_back_to_origin_audit_log');
|
||||
});
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders a quiet monitoring state when no exception is selected', function (): void {
|
||||
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $approver->getKey(),
|
||||
'owner_user_id' => (int) $approver->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Queue hierarchy review lane',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$this->actingAs($approver);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::test(FindingExceptionsQueue::class)
|
||||
->assertSee('Quiet monitoring mode')
|
||||
->assertSee('Inspect an exception to enter the focused review lane.')
|
||||
->assertDontSee('Focused review lane')
|
||||
->assertActionHidden('approve_selected_exception')
|
||||
->assertActionHidden('reject_selected_exception');
|
||||
});
|
||||
|
||||
it('renders a focused review lane when a pending exception is selected', function (): void {
|
||||
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $approver->getKey(),
|
||||
'owner_user_id' => (int) $approver->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Focused review lane request',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$this->actingAs($approver);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'exception' => (int) $exception->getKey(),
|
||||
])
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertSee('Focused review lane')
|
||||
->assertSee('Decision lane')
|
||||
->assertSee('Related drilldown')
|
||||
->assertDontSee('Quiet monitoring mode')
|
||||
->assertActionVisible('approve_selected_exception')
|
||||
->assertActionVisible('reject_selected_exception');
|
||||
});
|
||||
@ -202,6 +202,8 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(OperationRunLinks::index($tenant, $context))
|
||||
->assertOk()
|
||||
->assertSee('Monitoring landing')
|
||||
->assertSee('Return path')
|
||||
->assertSee('Back to backup set')
|
||||
->assertSee('/admin/tenant/backup-sets/1', false);
|
||||
});
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders the operations landing as a quiet monitoring surface', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.index'))
|
||||
->assertOk()
|
||||
->assertSee('Monitoring landing')
|
||||
->assertSee('Tabs, filters, and row inspection define the active work lane.')
|
||||
->assertSee('Scope context')
|
||||
->assertSee('Scope reset')
|
||||
->assertSee('Inspect flow')
|
||||
->assertSee('Show all tenants');
|
||||
});
|
||||
|
||||
it('surfaces canonical return context separately from the operations work lane', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$context = new CanonicalNavigationContext(
|
||||
sourceSurface: 'backup_set.detail_section',
|
||||
canonicalRouteName: 'admin.operations.index',
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
backLinkLabel: 'Back to backup set',
|
||||
backLinkUrl: '/admin/tenant/backup-sets/1',
|
||||
);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(OperationRunLinks::index($tenant, $context))
|
||||
->assertOk()
|
||||
->assertSee('Monitoring landing')
|
||||
->assertSee('Return path')
|
||||
->assertSee('Back to backup set')
|
||||
->assertSee('/admin/tenant/backup-sets/1', false)
|
||||
->assertSee('Inspect flow');
|
||||
});
|
||||
@ -37,6 +37,8 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(OperationRunLinks::tenantlessView($run, $context))
|
||||
->assertOk()
|
||||
->assertSee('Monitoring detail')
|
||||
->assertSee('Related drilldown')
|
||||
->assertSee('Back to backup set')
|
||||
->assertSee(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant), false)
|
||||
->assertSee('Related context')
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -559,6 +562,117 @@
|
||||
->assertActionVisible('operate_hub_back_to_origin_run_detail');
|
||||
});
|
||||
|
||||
it('renders the canonical tenantless viewer as a layered monitoring detail surface', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => null,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Monitoring detail')
|
||||
->assertSee('Navigation lane')
|
||||
->assertSee('Utility lane')
|
||||
->assertSee('Related drilldown')
|
||||
->assertSee('Follow-up lane')
|
||||
->assertSee('Refresh keeps the current run state accurate without changing scope.')
|
||||
->assertSee('No run-specific follow-up is currently available.')
|
||||
->assertDontSee('Resume capture');
|
||||
});
|
||||
|
||||
it('surfaces resumable follow-up separately from navigation and drilldown lanes', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'context' => [
|
||||
'baseline_compare' => [
|
||||
'resume_token' => 'resume-spec-193',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Monitoring detail')
|
||||
->assertSee('Follow-up lane')
|
||||
->assertSee('Resume capture')
|
||||
->assertSee('Resume capture only appears when this run supports additional evidence collection.')
|
||||
->assertSee('Related drilldown');
|
||||
});
|
||||
|
||||
it('keeps operations-list navigation out of the related drilldown lane', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->complete()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertActionVisible('view_baseline_profile')
|
||||
->assertActionVisible('view_snapshot')
|
||||
->assertActionDoesNotExist('operations')
|
||||
->assertActionDoesNotExist('open_operations');
|
||||
|
||||
$this
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Monitoring detail')
|
||||
->assertSee('Open keeps secondary drilldowns grouped under one control: View baseline profile, View snapshot.');
|
||||
});
|
||||
|
||||
it('renders shared polling markup for active tenantless runs', function (string $status, int $ageSeconds): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -170,6 +170,37 @@
|
||||
->assertDontSee('Show all operations');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('renders shared scope and return copy as secondary monitoring context on operations surfaces', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'policy.sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])->get(route('admin.operations.index'))
|
||||
->assertOk()
|
||||
->assertSee('Monitoring landing')
|
||||
->assertSee('Scope context')
|
||||
->assertSee('Scope reset');
|
||||
|
||||
$this->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Monitoring detail')
|
||||
->assertSee('Navigation lane')
|
||||
->assertSee('Follow-up lane');
|
||||
})->group('ops-ux');
|
||||
|
||||
it('returns 404 for non-member workspace access on /admin/operations', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
@ -2,11 +2,16 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('returns 404 for non-members on representative action-surface route', function (): void {
|
||||
@ -38,3 +43,45 @@
|
||||
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('keeps queue approval and rejection actions behind the approval capability', function (): void {
|
||||
[$approver, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
$readonly = User::factory()->create();
|
||||
createUserWithTenant(tenant: $tenant, user: $readonly, role: 'readonly', workspaceRole: 'readonly');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $approver->getKey(),
|
||||
'owner_user_id' => (int) $approver->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Authorization continuity test',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$this->actingAs($approver);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'exception' => (int) $exception->getKey(),
|
||||
])
|
||||
->actingAs($approver)
|
||||
->test(FindingExceptionsQueue::class)
|
||||
->assertActionVisible('approve_selected_exception')
|
||||
->assertActionVisible('reject_selected_exception');
|
||||
|
||||
$this->actingAs($readonly)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(FindingExceptionsQueue::getUrl(panel: 'admin'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
@ -5,9 +5,22 @@
|
||||
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function editTenantUiHeaderActions(Testable $component): array
|
||||
{
|
||||
$instance = $component->instance();
|
||||
|
||||
if ($instance->getCachedHeaderActions() === []) {
|
||||
$instance->cacheInteractsWithHeaderActions();
|
||||
}
|
||||
|
||||
return $instance->getCachedHeaderActions();
|
||||
}
|
||||
|
||||
describe('Edit tenant archive action UI enforcement', function () {
|
||||
it('shows archive action as visible but disabled for manager members', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
@ -18,7 +31,7 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('archive')
|
||||
->assertActionDisabled('archive')
|
||||
->assertActionExists('archive', function (Action $action): bool {
|
||||
@ -30,6 +43,12 @@
|
||||
->callMountedAction()
|
||||
->assertSuccessful();
|
||||
|
||||
expect(collect(editTenantUiHeaderActions($component))
|
||||
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||
->values()
|
||||
->all())->toBe(['Lifecycle']);
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->trashed())->toBeFalse();
|
||||
});
|
||||
@ -43,7 +62,7 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('archive')
|
||||
->assertActionEnabled('archive')
|
||||
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired())
|
||||
@ -51,6 +70,12 @@
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
expect(collect(editTenantUiHeaderActions($component))
|
||||
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||
->values()
|
||||
->all())->toBe(['Lifecycle']);
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->trashed())->toBeTrue();
|
||||
});
|
||||
@ -75,10 +100,16 @@
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('restore')
|
||||
->assertActionEnabled('restore')
|
||||
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore' && $action->isConfirmationRequired())
|
||||
->assertActionHidden('archive');
|
||||
|
||||
expect(collect(editTenantUiHeaderActions($component))
|
||||
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||
->values()
|
||||
->all())->toBe(['Lifecycle']);
|
||||
});
|
||||
});
|
||||
|
||||
@ -48,15 +48,19 @@ function tenantActionSurfaceSearchTitles($results): array
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('related_onboarding')
|
||||
->assertActionHidden('archive')
|
||||
->assertActionHidden('restore');
|
||||
|
||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
||||
->toContain('related_onboarding');
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('related_onboarding')
|
||||
->assertActionHidden('archive')
|
||||
->assertActionHidden('restore');
|
||||
|
||||
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
|
||||
->toContain('related_onboarding');
|
||||
});
|
||||
|
||||
it('keeps active lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||
@ -76,14 +80,18 @@ function tenantActionSurfaceSearchTitles($results): array
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('archive')
|
||||
->assertActionHidden('restore')
|
||||
->assertActionHidden('related_onboarding');
|
||||
->assertActionHidden('restore');
|
||||
|
||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
||||
->not->toContain('related_onboarding');
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('archive')
|
||||
->assertActionHidden('restore')
|
||||
->assertActionHidden('related_onboarding');
|
||||
->assertActionHidden('restore');
|
||||
|
||||
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
|
||||
->not->toContain('related_onboarding');
|
||||
});
|
||||
|
||||
it('keeps draft lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||
@ -113,15 +121,19 @@ function tenantActionSurfaceSearchTitles($results): array
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('related_onboarding')
|
||||
->assertActionHidden('archive')
|
||||
->assertActionHidden('restore');
|
||||
|
||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
||||
->toContain('related_onboarding');
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('related_onboarding')
|
||||
->assertActionHidden('archive')
|
||||
->assertActionHidden('restore');
|
||||
|
||||
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
|
||||
->toContain('related_onboarding');
|
||||
});
|
||||
|
||||
it('keeps archived lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||
|
||||
@ -45,10 +45,12 @@
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('related_onboarding')
|
||||
->assertActionExists('related_onboarding', fn (Action $action): bool => $action->getLabel() === 'Resume onboarding')
|
||||
->assertActionHidden('archive')
|
||||
->assertActionHidden('restore');
|
||||
|
||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))
|
||||
->firstWhere('key', 'related_onboarding')['value'] ?? null)
|
||||
->toBe('Resume onboarding');
|
||||
})->with([
|
||||
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
|
||||
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
|
||||
@ -91,8 +93,10 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('restore')
|
||||
->assertActionHidden('archive')
|
||||
->assertActionHidden('related_onboarding');
|
||||
->assertActionHidden('archive');
|
||||
|
||||
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
||||
->not->toContain('related_onboarding');
|
||||
});
|
||||
|
||||
it('shows verification only for active tenants on administrative list and detail surfaces', function (
|
||||
@ -190,7 +194,10 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $onboardingTenant->getRouteKey()])
|
||||
->assertActionHidden('archive')
|
||||
->assertActionVisible('related_onboarding');
|
||||
->assertActionHidden('restore');
|
||||
|
||||
expect(collect(TenantResource::tenantViewContextEntries($onboardingTenant))->pluck('key')->all())
|
||||
->toContain('related_onboarding');
|
||||
});
|
||||
|
||||
it('returns 404 on tenant detail routes for non-members regardless of lifecycle state', function (\Closure $tenantFactory): void {
|
||||
@ -228,8 +235,10 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewTenant::class, ['record' => $archivedTenant->getRouteKey()])
|
||||
->assertActionVisible('restore')
|
||||
->assertActionHidden('archive')
|
||||
->assertActionHidden('related_onboarding');
|
||||
->assertActionHidden('archive');
|
||||
|
||||
expect(collect(TenantResource::tenantViewContextEntries($archivedTenant))->pluck('key')->all())
|
||||
->not->toContain('related_onboarding');
|
||||
});
|
||||
|
||||
it('refuses lifecycle-invalid archive and restore mutations without changing tenant state', function (): void {
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\TenantDiagnostics;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@ -22,3 +28,21 @@
|
||||
->get("/admin/t/{$tenant->external_id}/diagnostics")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('shows disabled repair affordances to readonly members when a defect exists', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->update(['role' => 'readonly']);
|
||||
|
||||
Livewire::test(TenantDiagnostics::class)
|
||||
->assertActionVisible('bootstrapOwner')
|
||||
->assertActionDisabled('bootstrapOwner')
|
||||
->assertActionExists('bootstrapOwner', function (Action $action): bool {
|
||||
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
||||
});
|
||||
});
|
||||
|
||||
@ -41,6 +41,8 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->assertSee('Artifact truth')
|
||||
->assertDontSee('Monitoring landing')
|
||||
->assertDontSee('Navigation lane')
|
||||
->assertCanSeeTableRecords([$reviewA, $reviewB])
|
||||
->assertCanNotSeeTableRecords([$reviewC])
|
||||
->filterTable('tenant_id', (string) $tenantB->getKey())
|
||||
@ -65,6 +67,42 @@
|
||||
->assertSee('Clear filters');
|
||||
});
|
||||
|
||||
it('clears the remembered tenant prefilter from the review register', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'Beta Tenant',
|
||||
]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$reviewA = composeTenantReviewForTest($tenantA, $user);
|
||||
$reviewB = composeTenantReviewForTest($tenantB, $user);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->assertActionVisible('clear_filters')
|
||||
->assertCanSeeTableRecords([$reviewA])
|
||||
->assertCanNotSeeTableRecords([$reviewB]);
|
||||
|
||||
expect(app(WorkspaceContext::class)->lastTenantId())->toBe((int) $tenantA->getKey());
|
||||
|
||||
$component
|
||||
->callAction('clear_filters')
|
||||
->assertActionHidden('clear_filters')
|
||||
->assertCanSeeTableRecords([$reviewA, $reviewB]);
|
||||
|
||||
expect(app(WorkspaceContext::class)->lastTenantId())->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps stale and partial review rows aligned with tenant review detail trust', function (): void {
|
||||
$staleTenant = Tenant::factory()->create(['name' => 'Stale Tenant']);
|
||||
[$user, $staleTenant] = createUserWithTenant(tenant: $staleTenant, role: 'owner');
|
||||
|
||||
@ -11,8 +11,22 @@
|
||||
use App\Models\User;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function tenantReviewContractHeaderActions(Testable $component): array
|
||||
{
|
||||
$instance = $component->instance();
|
||||
|
||||
if ($instance->getCachedHeaderActions() === []) {
|
||||
$instance->cacheInteractsWithHeaderActions();
|
||||
}
|
||||
|
||||
return $instance->getCachedHeaderActions();
|
||||
}
|
||||
|
||||
it('disables tenant-review global search while keeping the view page available for resource inspection', function (): void {
|
||||
$reflection = new ReflectionClass(TenantReviewResource::class);
|
||||
|
||||
@ -103,6 +117,37 @@
|
||||
->assertActionMounted('archive_review');
|
||||
});
|
||||
|
||||
it('keeps tenant review header hierarchy to one primary action and moves related links into summary context', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$review = composeTenantReviewForTest($tenant, $owner);
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$this->actingAs($owner)
|
||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Related context')
|
||||
->assertSee('Evidence snapshot');
|
||||
|
||||
$component = Livewire::actingAs($owner)
|
||||
->test(ViewTenantReview::class, ['record' => $review->getKey()]);
|
||||
|
||||
$topLevelActionNames = collect(tenantReviewContractHeaderActions($component))
|
||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
$groupLabels = collect(tenantReviewContractHeaderActions($component))
|
||||
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($topLevelActionNames)->toBe(['publish_review'])
|
||||
->and($groupLabels)->toBe(['More', 'Danger']);
|
||||
});
|
||||
|
||||
it('shows publication truth and next-step guidance when a review is not yet publishable', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
@ -3,7 +3,7 @@ # Product Roadmap
|
||||
> Strategic thematic blocks and release trajectory.
|
||||
> This is the "big picture" — not individual specs.
|
||||
|
||||
**Last updated**: 2026-04-09
|
||||
**Last updated**: 2026-04-12
|
||||
|
||||
---
|
||||
|
||||
@ -90,10 +90,24 @@ ### Platform Operations Maturity
|
||||
|
||||
## Mid-term (2–3 Quarters)
|
||||
|
||||
### Decision-Based Operating Foundations
|
||||
Constitution hardening for decision-first governance, workflow-first navigation, surface taxonomy, and primary-vs-evidence surface refactoring.
|
||||
**Goal**: Prepare TenantPilot for a quieter, decision-centered operating model where primary surfaces ask for action and deeper technical detail stays available on demand.
|
||||
**Why it matters**: Governance inboxes, actionable alerts, and later autonomous-governance features will fail if they land on top of detail-heavy, entity-first navigation. This is the UX/product prerequisite layer for the later MSP Portfolio OS direction.
|
||||
**Depends on**: Current constitution and action-surface hardening, operator-truth work, existing navigation/context specs.
|
||||
**Scope direction**: First the constitution/rule delta, then a surface / IA classification of current product surfaces, then bounded retrofits that demote detail-first flows behind progressive disclosure instead of creating more top-level pages.
|
||||
|
||||
### MSP Portfolio & Operations (Multi-Tenant)
|
||||
Multi-tenant health dashboard, SLA/compliance reports (PDF), cross-tenant troubleshooting center.
|
||||
**Source**: 0800-future-features brainstorming, identified as highest priority pillar.
|
||||
**Prerequisite**: Cross-tenant compare (Spec 043 — draft only).
|
||||
**Prerequisites**: Decision-Based Operating Foundations, Cross-tenant compare (Spec 043 — draft only).
|
||||
|
||||
### Human-in-the-Loop Autonomous Governance (Decision-Based Operating)
|
||||
Continuous detection, triage, decision drafting, approval-driven execution, and closed-loop evidence for governance actions across the workspace portfolio.
|
||||
**Goal**: Reduce operator work from searching and correlating to approving, rejecting, deferring, or time-boxing deviations while TenantPilot handles the mechanical follow-through.
|
||||
**Why it matters**: This is the longer-term MSP Portfolio OS layer. TenantPilot becomes the decision control plane for accountable governance, not just a browser for runs, evidence, and tenant state.
|
||||
**Depends on**: Decision-Based Operating Foundations, MSP Portfolio & Operations surfaces, drift/findings/exception maturity, actionable alerts with structured payloads, canonical operation/evidence truth.
|
||||
**Scope direction**: Start with governance inbox + decision packs + actionable alerts. Later add automation policies, guardrails, maintenance windows, dual approval, and before/after evidence automation. Keep human approval and auditability central; avoid blind autopilot remediation.
|
||||
|
||||
### Drift & Change Governance ("Revenue Lever #1")
|
||||
Change approval workflows (DEV→PROD with audit pack), guardrails/policy freeze windows, tamper detection.
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
**Last reviewed**: 2026-04-10 (added Compliance Control Catalog & Interpretation Foundation)
|
||||
**Last reviewed**: 2026-04-12 (added decision-based operating foundations and autonomous governance track)
|
||||
|
||||
---
|
||||
|
||||
@ -708,6 +708,36 @@ ### Recovery Confidence — Automated Restore Testing & Readiness Reporting
|
||||
- **Dependencies**: Restore pipeline stable (Spec 011 and follow-ups), backup infrastructure mature, dry-run/preview infrastructure (restore preview), audit log foundation (Spec 134), RBAC/capability system (066+), evidence/reporting direction for downstream consumption
|
||||
- **Priority**: medium (high strategic value and strong product differentiation potential, but depends on restore pipeline maturity and is realistically sequenced after current hardening work)
|
||||
|
||||
### Decision-First Operating Constitution Hardening
|
||||
- **Type**: foundation
|
||||
- **Source**: product strategy discussion 2026-04-12, constitution delta analysis against the decision-first operating model
|
||||
- **Problem**: TenantPilot already has strong constitution rules around truth, action surfaces, and progressive disclosure, but the product identity and review gates needed for a decision-first governance model are still distributed across separate rules. Upcoming MSP portfolio and governance-inbox work could otherwise reproduce entity-first, detail-heavy surfaces under a new label.
|
||||
- **Why it matters**: This is the rules-before-features step. Without it, later governance automation lands on the wrong UX contract and the product keeps growing by adding more pages instead of reducing operator attention load.
|
||||
- **Proposed direction**:
|
||||
- Make TenantPilot's decision-first governance identity explicit in the constitution
|
||||
- Add workflow-first navigation and "one case, one decision context" as binding principles
|
||||
- Add an automation guardrail: automation must reduce attention load, not create more UI
|
||||
- Extend spec/PR review gates with surface-role classification, human-in-the-loop justification, immediate-vs-on-demand information checks, and explicit search/click-load questions
|
||||
- Treat this as a targeted evolution of the existing constitution, not as a second parallel manifesto
|
||||
- **Explicit non-goals**: Not a visual redesign. Not a rewrite of every current screen. Not a second constitution document.
|
||||
- **Dependencies**: Existing constitution baseline, action surface contract work, operator-truth vocabulary, current navigation/context hardening specs
|
||||
- **Priority**: high
|
||||
|
||||
### Surface Taxonomy & Workflow-First IA Classification
|
||||
- **Type**: foundation
|
||||
- **Source**: product strategy discussion 2026-04-12, decision-first operating follow-up
|
||||
- **Problem**: TenantPilot has grown strong per-domain depth across operations, evidence, baselines, reviews, alerts, and tenant detail surfaces, but there is no canonical classification of which screens are primary decision surfaces, which are secondary context surfaces, and which are tertiary evidence/diagnostic surfaces. Existing navigation and prominence can still mirror entities and implementation structure more than operator workflows.
|
||||
- **Why it matters**: Without a classification-first audit, retrofits will stay local and future governance automation will just add another layer on top of the current detail-page landscape. This candidate produces the target IA truth before new inbox or portfolio-operating surfaces land.
|
||||
- **Proposed direction**:
|
||||
- Catalogue major existing surfaces and classify them as primary decision, secondary context, or tertiary evidence/diagnostic
|
||||
- Evaluate which information must be immediate versus on-demand per surface
|
||||
- Identify where multiple pages should collapse into one decision context, and where detail should move behind tabs, drawers, or expansion
|
||||
- Produce a workflow-first target IA and a bounded retrofit map rather than one umbrella redesign
|
||||
- Reuse existing implemented/draft specs where they already solve local parts of the problem instead of inventing a second parallel IA program
|
||||
- **Explicit non-goals**: Not immediate implementation of all retrofits. Not a mass rewrite of every navigation group in one release. Not a justification for deleting audit or diagnostic depth.
|
||||
- **Dependencies**: Decision-First Operating Constitution Hardening, existing navigation/context/action-surface specs, product surface inventory
|
||||
- **Priority**: high
|
||||
|
||||
### MSP Multi-Tenant Portfolio Dashboard & SLA Reporting
|
||||
- **Type**: feature
|
||||
- **Source**: roadmap-to-spec coverage audit 2026-03-18, 0800-future-features brainstorming (pillar #1 — MSP Portfolio & Operations), product positioning for MSP portfolio owners
|
||||
@ -723,9 +753,26 @@ ### MSP Multi-Tenant Portfolio Dashboard & SLA Reporting
|
||||
- **Explicit non-goals**: Not a replacement for per-tenant dashboards or detail views (those remain the primary tenant-level surfaces). Not a generic BI/data warehouse initiative or a drag-and-drop report builder. Not a customer-facing analytics suite — this is an operator/MSP-internal tool. Not a cross-tenant compare/diff/promotion surface (that is the Cross-Tenant Compare & Promotion candidate). Not a system-console-level platform triage view (that is the System Console Multi-Workspace Operator UX candidate). Not a replacement for alerting (Specs 099/100 handle event-driven notifications; this is a review/monitoring surface).
|
||||
- **Boundary with Cross-Tenant Compare & Promotion**: Portfolio Dashboard = fleet-level monitoring, health aggregation, SLA reporting, operational overview. Cross-Tenant Compare = policy-level diff, staging-to-production promotion, configuration comparison. They share the multi-tenant dimension but solve fundamentally different problems.
|
||||
- **Boundary with System Console Multi-Workspace Operator UX**: Portfolio Dashboard = workspace-scoped MSP operator view, health/SLA/governance focus. System Console = platform-level triage, cross-workspace operator tooling, infrastructure focus. Different audiences, different panels.
|
||||
- **Dependencies**: Per-tenant operational health signals (backup, sync, drift, findings, provider connection status), workspace model, tenant inventory, alerting foundations (Specs 099/100), RBAC/capability system (066+)
|
||||
- **Dependencies**: Decision-First Operating Constitution Hardening, Surface Taxonomy & Workflow-First IA Classification, per-tenant operational health signals (backup, sync, drift, findings, provider connection status), workspace model, tenant inventory, alerting foundations (Specs 099/100), RBAC/capability system (066+)
|
||||
- **Priority**: medium (high strategic value, significant data aggregation effort; depends on per-tenant signal maturity)
|
||||
|
||||
### Human-in-the-Loop Autonomous Governance / Governance Inbox
|
||||
- **Type**: feature
|
||||
- **Source**: product strategy discussion 2026-04-12, MSP Portfolio OS direction
|
||||
- **Problem**: Operators still have to pull together drift, evidence, operation runs, policy versions, alerts, and exception history themselves to decide what to do next. Alerting can notify, but the product does not yet provide a workspace-level decision inbox, structured action payloads, recommended next steps, or controlled execution contracts that let the system continue after approval.
|
||||
- **Why it matters**: This is the long-term decision-based operating moat. TenantPilot stops being just a place to inspect tenant state and becomes the system that detects, triages, drafts the decision, collects approval, executes within guardrails, and preserves the full evidence chain. That is much harder for generic AI tooling to replace than simple configuration explainability.
|
||||
- **Proposed direction**:
|
||||
- Governance inbox for pending decisions across tenants and workflows
|
||||
- Actionable alerts/events with structured payloads that open directly into a decision context rather than a generic detail page
|
||||
- Continuous detection and auto-triage against baselines, findings, accepted deviations, run history, and risk signals
|
||||
- Decision packs: what changed, why it matters, recommended action, blast radius, confidence, approvals required, rollback path
|
||||
- Controlled execution after approval: approve, reject, defer, or accept deviation, with automation policies, maintenance windows, dual approval, and scope guardrails where needed
|
||||
- Closed-loop evidence: before snapshot, approval record, execution run, after snapshot, audit trail, review-pack linkage
|
||||
- **Explicit non-goals**: Not blind autopilot remediation. Not a chat-first admin experience. Not a replacement for drift/change governance, findings, or exception workflows; it orchestrates across them.
|
||||
- **Boundary with Drift Change Governance**: Drift Change Governance owns drift-specific approval, freeze-window, and tamper rules. This candidate owns the broader operating model: inbox, decision routing, recommended actions, controlled execution, and evidence closure across drift, evidence, review, and operational workflows.
|
||||
- **Dependencies**: Decision-First Operating Constitution Hardening, Surface Taxonomy & Workflow-First IA Classification, MSP Multi-Tenant Portfolio Dashboard & SLA Reporting, drift/findings/exception workflow maturity, actionable alert/event payloads, canonical operation/evidence truth
|
||||
- **Priority**: medium (high strategic value, intentionally sequenced after the decision-first foundations)
|
||||
|
||||
### Policy Lifecycle / Ghost Policies (Spec 900 refresh)
|
||||
- **Type**: feature
|
||||
- **Source**: Spec 900 draft (2025-12-22), HANDOVER risk #9
|
||||
|
||||
@ -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-28
|
||||
**Last reviewed**: 2026-04-12
|
||||
|
||||
---
|
||||
|
||||
@ -42,7 +42,7 @@ ## Related Docs
|
||||
|
||||
| Document | Location | Purpose |
|
||||
|---|---|---|
|
||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, UI-CONST-001, UI-SURF-001, UI-HARD-001, UI-EX-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
|
||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, UI-CONST-001, DECIDE-001, UI-SURF-001, ACTSURF-001, UI-HARD-001, UI-EX-001, HDR-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
|
||||
| Product Principles | `docs/product/principles.md` | High-level product decisions |
|
||||
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
|
||||
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Record Page Header Discipline & Contextual Navigation
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-11
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated on first pass against the completed spec.
|
||||
- No clarification markers remain.
|
||||
- The spec keeps the cleanup bounded to classic record/detail/edit surfaces and documents the tenant admin resource view as the only explicit special-type exception.
|
||||
@ -0,0 +1,252 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Record Page Header Discipline Internal Surface Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for Spec 192 record-page header discipline
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 192. The affected
|
||||
surfaces continue to render HTML through Filament and Livewire. The schemas
|
||||
below define the bounded render contract and regression expectations for
|
||||
standard record/detail/edit headers, grouped secondary actions, contextual
|
||||
navigation outside the header, and the explicit workflow-heavy exception.
|
||||
servers:
|
||||
- url: /internal
|
||||
x-record-header-discipline-consumers:
|
||||
- surface: standard-record-pages
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
|
||||
- apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php
|
||||
- apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php
|
||||
- apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php
|
||||
- apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php
|
||||
mustRender:
|
||||
- at_most_one_primary_header_action
|
||||
- grouped_secondary_actions
|
||||
- contextual_navigation_or_related_context_outside_header
|
||||
- separated_danger_actions_when_present
|
||||
mustNotRender:
|
||||
- flat_navigation_mutation_strip
|
||||
- multiple_competing_primary_actions
|
||||
- empty_action_group_placeholders
|
||||
- surface: workflow-heavy-special-type
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php
|
||||
mustRender:
|
||||
- explicit_exception_reason
|
||||
- grouped_and_ordered_actions
|
||||
- optional_single_primary_only_when_dominant
|
||||
mustNotRender:
|
||||
- silent_exception
|
||||
- flat_multi_button_primary_strip
|
||||
- surface: regression-guards
|
||||
sourceFiles:
|
||||
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
|
||||
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php
|
||||
- apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
- apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php
|
||||
paths:
|
||||
/internal/action-surfaces/record-pages/{surface}:
|
||||
get:
|
||||
summary: Return the logical header-discipline contract for an in-scope record page
|
||||
operationId: getRecordPageHeaderDisciplineContract
|
||||
parameters:
|
||||
- name: surface
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/SurfaceKey'
|
||||
responses:
|
||||
'200':
|
||||
description: Logical render contract and regression expectations for the requested surface
|
||||
content:
|
||||
application/vnd.tenantpilot.record-header-discipline+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RecordHeaderSurfaceContract'
|
||||
'404':
|
||||
description: Requested surface is not in the Spec 192 inventory
|
||||
components:
|
||||
schemas:
|
||||
SurfaceKey:
|
||||
type: string
|
||||
enum:
|
||||
- baseline_profile_view
|
||||
- evidence_snapshot_view
|
||||
- finding_exception_view
|
||||
- tenant_review_view
|
||||
- tenant_edit
|
||||
- tenant_view
|
||||
- provider_connection_view
|
||||
- finding_view
|
||||
- review_pack_view
|
||||
- alert_destination_view
|
||||
- policy_version_view
|
||||
- workspace_view
|
||||
- baseline_snapshot_view
|
||||
- backup_set_view
|
||||
SurfaceClassification:
|
||||
type: string
|
||||
enum:
|
||||
- remediation_required
|
||||
- minor_alignment_only
|
||||
- compliant_reference
|
||||
- workflow_heavy_special_type
|
||||
ActionKind:
|
||||
type: string
|
||||
enum:
|
||||
- navigation
|
||||
- mutation
|
||||
- external_link
|
||||
- lifecycle
|
||||
- danger
|
||||
Placement:
|
||||
type: string
|
||||
enum:
|
||||
- primary_visible
|
||||
- secondary_grouped
|
||||
- contextual
|
||||
- danger_grouped
|
||||
OperationScope:
|
||||
type: string
|
||||
enum:
|
||||
- TenantPilot only
|
||||
- Microsoft tenant
|
||||
- simulation only
|
||||
- read-only
|
||||
HeaderActionDescriptor:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- actionKey
|
||||
- label
|
||||
- actionKind
|
||||
- placement
|
||||
- requiresConfirmation
|
||||
- usesUiEnforcement
|
||||
- operationScope
|
||||
properties:
|
||||
actionKey:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
actionKind:
|
||||
$ref: '#/components/schemas/ActionKind'
|
||||
placement:
|
||||
$ref: '#/components/schemas/Placement'
|
||||
requiresConfirmation:
|
||||
type: boolean
|
||||
usesUiEnforcement:
|
||||
type: boolean
|
||||
capabilityKey:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
writesAuditLog:
|
||||
type: boolean
|
||||
operationScope:
|
||||
$ref: '#/components/schemas/OperationScope'
|
||||
ContextualNavigationEntry:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- label
|
||||
- sourceSection
|
||||
- isAvailable
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
targetUrl:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
sourceSection:
|
||||
type: string
|
||||
enum:
|
||||
- summary
|
||||
- related_context
|
||||
- field_context
|
||||
- status_context
|
||||
isAvailable:
|
||||
type: boolean
|
||||
SecondaryActionGroup:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- label
|
||||
- orderedBuckets
|
||||
- actions
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
orderedBuckets:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
actions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/HeaderActionDescriptor'
|
||||
HeaderRegressionExpectation:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- maxVisiblePrimaryActions
|
||||
- requiresGroupedSecondaryActions
|
||||
- allowsPrimaryNavigation
|
||||
- requiresExplicitExceptionReason
|
||||
properties:
|
||||
maxVisiblePrimaryActions:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 1
|
||||
requiresGroupedSecondaryActions:
|
||||
type: boolean
|
||||
allowsPrimaryNavigation:
|
||||
type: boolean
|
||||
requiresDangerSeparation:
|
||||
type: boolean
|
||||
requiresExplicitExceptionReason:
|
||||
type: boolean
|
||||
browserSmokeRequired:
|
||||
type: boolean
|
||||
RecordHeaderSurfaceContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- surfaceKey
|
||||
- classification
|
||||
- canonicalNoun
|
||||
- primaryQuestion
|
||||
- actions
|
||||
- contextualNavigation
|
||||
- regressionExpectation
|
||||
properties:
|
||||
surfaceKey:
|
||||
$ref: '#/components/schemas/SurfaceKey'
|
||||
classification:
|
||||
$ref: '#/components/schemas/SurfaceClassification'
|
||||
canonicalNoun:
|
||||
type: string
|
||||
primaryQuestion:
|
||||
type: string
|
||||
primaryAction:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/HeaderActionDescriptor'
|
||||
- type: 'null'
|
||||
secondaryActionGroup:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/SecondaryActionGroup'
|
||||
- type: 'null'
|
||||
dangerActions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/HeaderActionDescriptor'
|
||||
contextualNavigation:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ContextualNavigationEntry'
|
||||
explicitExceptionReason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
regressionExpectation:
|
||||
$ref: '#/components/schemas/HeaderRegressionExpectation'
|
||||
155
specs/192-record-header-discipline/data-model.md
Normal file
155
specs/192-record-header-discipline/data-model.md
Normal file
@ -0,0 +1,155 @@
|
||||
# Data Model: Record Page Header Discipline & Contextual Navigation
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new persisted entity, table, enum, or long-lived artifact. It reuses existing Filament pages, existing action definitions, and existing authorization helpers, while adding a derived planning model for how record-page headers are classified, rendered, and regression-tested.
|
||||
|
||||
## Existing Source Truths Reused Without Change
|
||||
|
||||
The following truths remain authoritative and are not redefined by this feature:
|
||||
|
||||
- existing resource and page routes
|
||||
- existing model ownership and scope semantics
|
||||
- existing capability checks and `UiEnforcement` behavior
|
||||
- existing confirmation, audit, and `OperationRun` behavior for underlying actions
|
||||
- existing related-navigation truth from `RelatedNavigationResolver` and related helper methods
|
||||
|
||||
This feature changes action hierarchy and placement only.
|
||||
|
||||
## New Derived Planning Models
|
||||
|
||||
### HeaderSurfaceInventoryEntry
|
||||
|
||||
**Type**: spec and guard inventory entry
|
||||
**Source**: explicit Spec 192 classification matrix + action-surface regression guard
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Stable identifier such as `baseline_profile_view` or `tenant_edit` |
|
||||
| `pageClass` | string | Concrete Filament page class under review |
|
||||
| `panelScope` | string | `admin`, `tenant`, or explicit special context |
|
||||
| `ownerScope` | string | `workspace-owned` or `tenant-owned` |
|
||||
| `classification` | string | `remediation_required`, `minor_alignment_only`, `compliant_reference`, or `workflow_heavy_special_type` |
|
||||
| `canonicalNoun` | string | Stable operator-facing object noun |
|
||||
| `routeKind` | string | `view` or `edit` |
|
||||
| `requiresHeaderRemediation` | boolean | Whether the page must change under Spec 192 |
|
||||
| `exceptionReason` | string or null | Required when classification is special-type |
|
||||
|
||||
### RecordHeaderLayoutState
|
||||
|
||||
**Type**: derived page render contract
|
||||
**Source**: existing page action methods + page state + explicit Spec 192 rules
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Links the render state back to the inventory entry |
|
||||
| `classification` | string | Same classification used by the inventory |
|
||||
| `primaryActionKey` | string or null | The one visible primary action, if any |
|
||||
| `primaryActionLabel` | string or null | Operator-facing label for the visible primary action |
|
||||
| `primaryActionReason` | string | Why this action is primary for the current page state |
|
||||
| `secondaryGroupLabel` | string or null | Usually `More`, `Actions`, or equivalent grouped-secondary label |
|
||||
| `hasContextualNavigation` | boolean | Whether pure navigation moved to contextual placement outside the header |
|
||||
| `hasSeparatedDangerActions` | boolean | Whether dangerous actions are structurally separated |
|
||||
| `allowsNoPrimaryAction` | boolean | True for compliant reference pages or special types without one dominant next step |
|
||||
|
||||
### HeaderActionDescriptor
|
||||
|
||||
**Type**: derived action classification entry
|
||||
**Source**: existing Filament action definitions on the target page
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `actionKey` | string | Action name such as `capture`, `refresh_review`, or `archive` |
|
||||
| `label` | string | Visible operator-facing label |
|
||||
| `actionKind` | string | `navigation`, `mutation`, `external_link`, `lifecycle`, or `danger` |
|
||||
| `placement` | string | `primary_visible`, `secondary_grouped`, `contextual`, or `danger_grouped` |
|
||||
| `requiresConfirmation` | boolean | Mirrors existing destructive or governance friction |
|
||||
| `usesUiEnforcement` | boolean | Whether the action is wrapped with a central enforcement helper |
|
||||
| `capabilityKey` | string or null | Canonical capability requirement when applicable |
|
||||
| `writesAuditLog` | boolean | Whether the underlying mutation writes audit truth |
|
||||
| `operationScope` | string | `TenantPilot only`, `Microsoft tenant`, `simulation only`, or `read-only` |
|
||||
|
||||
### ContextualNavigationContract
|
||||
|
||||
**Type**: derived related-navigation placement entry
|
||||
**Source**: existing related-navigation resolver output or page-local related links
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `entryKey` | string | Stable identifier for a related destination |
|
||||
| `label` | string | Operator-facing related-link label |
|
||||
| `targetUrl` | string or null | Existing target destination |
|
||||
| `sourceSection` | string | `summary`, `related_context`, `field_context`, or `status_context` |
|
||||
| `isAvailable` | boolean | Mirrors existing helper availability logic |
|
||||
| `leaksScopeIfMisplaced` | boolean | True when placing this in the wrong layer could imply broader access |
|
||||
|
||||
### WorkflowHeavyHeaderGroup
|
||||
|
||||
**Type**: derived grouped-action contract for special-type pages
|
||||
**Source**: explicit exception handling for `ViewTenant`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Expected to map to the special-type surface |
|
||||
| `groupLabel` | string | Visible grouped-action label, usually `Actions` |
|
||||
| `orderedBuckets` | array<string> | Ordered buckets such as `external_links`, `verification`, `setup`, and `lifecycle`; pure navigation remains contextual outside the header |
|
||||
| `visiblePrimaryActionKey` | string or null | Optional visible primary action when a dominant next step exists |
|
||||
| `requiresExplicitException` | boolean | Always true for workflow-heavy special types |
|
||||
|
||||
### HeaderRegressionExpectation
|
||||
|
||||
**Type**: guard and test expectation entry
|
||||
**Source**: Spec 192 regression-protection requirements
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | The page under regression protection |
|
||||
| `maxVisiblePrimaryActions` | integer | `1` for standard pages, `0..1` for special types, `0..1` for references as documented |
|
||||
| `requiresGroupedSecondaryActions` | boolean | Whether secondaries must be grouped |
|
||||
| `allowsPrimaryNavigation` | boolean | Usually false for remediated standard pages |
|
||||
| `requiresDangerSeparation` | boolean | True when the page contains destructive or governance-sensitive actions |
|
||||
| `requiresExplicitExceptionReason` | boolean | True for special-type pages |
|
||||
| `browserSmokeRequired` | boolean | True for remediation-required pages, the explicit special-type exception, and the compliant reference baseline set |
|
||||
|
||||
## Resolution Rules
|
||||
|
||||
### Standard-page rules
|
||||
|
||||
1. A remediation-required standard record/detail/edit page resolves to at most one `primary_visible` action.
|
||||
2. Pure navigation actions resolve to `contextual`, not to `primary_visible` or header-grouped placement.
|
||||
3. Rare or administrative mutations resolve to `secondary_grouped`.
|
||||
4. Destructive or governance-sensitive actions resolve to `danger_grouped` and keep confirmation.
|
||||
|
||||
### State-sensitive primary-action rules
|
||||
|
||||
- `baseline_profile_view` resolves `capture` as primary when no consumable snapshot exists.
|
||||
- `baseline_profile_view` resolves `compareNow` as primary when a consumable snapshot exists.
|
||||
- `tenant_review_view` resolves one of `refresh_review`, `publish_review`, or `export_executive_pack` as primary based on lifecycle state.
|
||||
- `finding_exception_view` may resolve `renew_exception` as primary only when renewal is valid and visible.
|
||||
- `tenant_edit` does not introduce a second page-header primary because save/cancel remain the true edit-surface primary affordance.
|
||||
|
||||
### Special-type rules
|
||||
|
||||
1. `tenant_view` may expose zero visible primaries when no single dominant next step exists.
|
||||
2. If `tenant_view` exposes one visible primary, all remaining actions still stay grouped and internally ordered.
|
||||
3. `tenant_view` must always carry an explicit exception reason in the inventory and regression expectations.
|
||||
|
||||
### Reference-page rules
|
||||
|
||||
1. A compliant reference page may keep a single contextual related-record link or single primary safe action without further restructuring.
|
||||
2. Reference pages must not be rebuilt only to mimic the remediated pages.
|
||||
|
||||
## Relationships
|
||||
|
||||
- One `HeaderSurfaceInventoryEntry` maps to one `RecordHeaderLayoutState`.
|
||||
- One `RecordHeaderLayoutState` contains many `HeaderActionDescriptor` entries.
|
||||
- A page may contain zero or many `ContextualNavigationContract` entries.
|
||||
- Only special-type pages use a `WorkflowHeavyHeaderGroup`.
|
||||
- Every in-scope page must map to one `HeaderRegressionExpectation`.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- No derived header model may widen tenant or workspace visibility beyond existing route and helper semantics.
|
||||
- No action may lose `UiEnforcement`, confirmation, audit, or `OperationRun` behavior when it changes placement.
|
||||
- No grouped secondary structure may become an undocumented exception or empty placeholder.
|
||||
- No regression expectation may silently exempt a page; every exception must be explicit and justified.
|
||||
325
specs/192-record-header-discipline/plan.md
Normal file
325
specs/192-record-header-discipline/plan.md
Normal file
@ -0,0 +1,325 @@
|
||||
# Implementation Plan: Record Page Header Discipline & Contextual Navigation
|
||||
|
||||
**Branch**: `192-record-header-discipline` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/spec.md`
|
||||
|
||||
**Note**: This plan keeps the work inside the existing Filament v5 / Livewire v4 resource pages, existing related-navigation helpers, and the existing action-surface guard infrastructure. It explicitly avoids introducing a new header-action framework.
|
||||
|
||||
## Summary
|
||||
|
||||
Codify one bounded header-discipline contract for classic record/detail/edit pages in the admin panel. Reuse existing Filament header actions, `ActionGroup`, `UiEnforcement`, and `RelatedNavigationResolver` patterns to inventory all in-scope surfaces, remediate the five standard pages that currently have noisy headers, preserve already-clean reference pages, explicitly document `ViewTenant` as a workflow-heavy special type, and extend the existing action-surface and browser regression layers so new header sprawl does not re-enter the repo.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders
|
||||
**Storage**: PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned
|
||||
**Testing**: Pest feature tests, existing guard tests, and browser smoke tests run through Laravel Sail
|
||||
**Target Platform**: Laravel monolith web application under `apps/platform`, with workspace/admin routes under `/admin`, tenant-context routes under `/admin/t/{tenant}/...`, and no panel expansion planned
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: Preserve the 5-second scan rule on record pages, keep all affected pages DB-only at render time, avoid new polling or asset work, and prevent header cleanup from adding extra query churn or remote calls
|
||||
**Constraints**: No new action framework, no new persistence, no route or panel changes, no authorization-plane changes, no new status language, no silent special-type exemptions, and no expansion of Spec 133 body-composition requirements beyond current scope
|
||||
**Scale/Scope**: 14 in-scope record/detail/edit surfaces, 5 remediation-required standard pages, 1 explicit workflow-heavy special-type exception, 2 minor-alignment audits, 6 compliant/no-op reference pages, and focused guard plus feature plus browser regression coverage
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Inventory-first / snapshots-second | PASS | PASS | The feature does not alter inventory or snapshot truth; it only reorganizes page headers. |
|
||||
| Read/write separation | PASS | PASS | Existing mutations keep their current confirmation, audit, and test behavior. No new writes are introduced. |
|
||||
| Graph contract path | N/A | N/A | No new Microsoft Graph call path or contract-registry change is planned. |
|
||||
| Deterministic capabilities | PASS | PASS | Capability checks remain in canonical registries and `UiEnforcement`; regrouping actions does not change entitlement logic. |
|
||||
| Workspace + tenant isolation | PASS | PASS | Existing route scopes and related-navigation availability rules remain authoritative. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`, in-scope capability denial remains `403`, and server-side authorization stays unchanged. |
|
||||
| Run observability / Ops-UX | PASS | PASS | Underlying long-running actions such as capture, compare, refresh, and verification keep their existing `OperationRun` semantics. |
|
||||
| Data minimization | PASS | PASS | No new persistence, caches, or header-state artifacts are introduced. |
|
||||
| Proportionality / anti-bloat | PASS | PASS | The work stays inside existing pages and guard infrastructure instead of creating a new framework. |
|
||||
| UI semantics / few layers | PASS | PASS | The feature relies on direct action placement and classification rather than a new presenter or semantic layer. |
|
||||
| Filament-native UI | PASS | PASS | Native Filament header actions and `ActionGroup` remain the implementation path. |
|
||||
| Surface taxonomy / HDR-001 | PASS | PASS | The plan explicitly classifies every in-scope page and documents the one workflow-heavy special type. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched pages remain inside the existing Filament v5 + Livewire v4 stack. |
|
||||
| Provider registration location | PASS | PASS | No provider change is needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No new globally searchable resource is introduced; touched resources already have View/Edit pages where needed. |
|
||||
| Destructive action safety | PASS | PASS | Existing destructive or governance-changing actions keep `->requiresConfirmation()` and stay authorization-gated. |
|
||||
| Asset strategy | PASS | PASS | No new assets or lazy-load registrations are needed; existing deploy handling of `cd apps/platform && php artisan filament:assets` remains unchanged. |
|
||||
|
||||
## Filament-Specific Compliance Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: The plan remains on Filament v5 + Livewire v4 and introduces no legacy or mixed-version API usage.
|
||||
- **Provider registration location**: No panel or provider changes are required; Laravel 11+ panel providers remain registered in `bootstrap/providers.php`.
|
||||
- **Global search**: The feature does not add a new globally searchable resource. Touched resources continue to satisfy the Filament hard rule because they already have View and/or Edit pages; search behavior is otherwise unchanged.
|
||||
- **Destructive actions**: `Expire snapshot`, `Revoke exception`, `Archive review`, tenant lifecycle actions, backup lifecycle actions, and provider credential danger actions remain routed through `Action::make(...)->action(...)` with `->requiresConfirmation()` and existing authorization.
|
||||
- **Asset strategy**: No new global or on-demand asset registration is planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains sufficient.
|
||||
- **Testing plan**: Extend the existing action-surface guard layer, add focused Livewire/Pest tests for the remediated pages and the explicit special type, and add a browser smoke suite that proves visible hierarchy on remediated headers.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Reuse existing page-local action builders, `UiEnforcement`, `ActionGroup`, and `RelatedNavigationResolver` instead of introducing a header-action framework.
|
||||
- Move pure navigation to contextual placement outside the header instead of equal-weight header placement.
|
||||
- Treat `ViewTenant` as an explicit workflow-heavy special-type exception rather than a standard record page.
|
||||
- Preserve already-clean pages as reference patterns instead of cosmetically normalizing them.
|
||||
- Build regression protection on top of the existing `ActionSurfaceValidator`, focused page tests, and browser smoke patterns.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/`:
|
||||
|
||||
- `research.md`: decisions and rejected alternatives for bounded header discipline
|
||||
- `data-model.md`: derived header-surface inventory, render contract, and regression expectation models
|
||||
- `contracts/record-header-discipline.logical.openapi.yaml`: internal logical contract for standard-page headers, the special-type exception, and regression expectations
|
||||
- `quickstart.md`: implementation and verification sequence for the feature
|
||||
|
||||
Design highlights:
|
||||
|
||||
- Keep all classification and render rules derived, not persisted.
|
||||
- Represent each in-scope surface through one explicit inventory entry and one explicit regression expectation.
|
||||
- Keep state-sensitive primary-action decisions local to the existing pages rather than moving them into a shared runtime resolver.
|
||||
- Treat `ViewTenant` as the only explicit special type and require an exception reason in the regression layer.
|
||||
- Extend the existing guard system rather than creating a new validation framework.
|
||||
|
||||
## Phase 1 — Agent Context Update
|
||||
|
||||
Planned command:
|
||||
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
This feature does not introduce a new technology stack, but the required agent-context refresh still runs to keep the planning workflow complete.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/192-record-header-discipline/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── spec.md
|
||||
├── contracts/
|
||||
│ └── record-header-discipline.logical.openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ └── Resources/
|
||||
│ │ ├── BaselineProfileResource.php # MODIFY
|
||||
│ │ ├── BaselineProfileResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ViewBaselineProfile.php # MODIFY
|
||||
│ │ ├── EvidenceSnapshotResource.php # MODIFY
|
||||
│ │ ├── EvidenceSnapshotResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ViewEvidenceSnapshot.php # MODIFY
|
||||
│ │ ├── FindingExceptionResource.php # MODIFY
|
||||
│ │ ├── FindingExceptionResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ViewFindingException.php # MODIFY
|
||||
│ │ ├── TenantReviewResource.php # MODIFY
|
||||
│ │ ├── TenantReviewResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ViewTenantReview.php # MODIFY
|
||||
│ │ ├── TenantResource.php # MODIFY
|
||||
│ │ ├── TenantResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ ├── EditTenant.php # MODIFY
|
||||
│ │ │ └── ViewTenant.php # MODIFY (special type ordering only)
|
||||
│ │ ├── ProviderConnectionResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ViewProviderConnection.php # AUDIT / possible minor alignment
|
||||
│ │ ├── FindingResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ViewFinding.php # AUDIT / possible minor alignment
|
||||
│ │ ├── BackupSetResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ViewBackupSet.php # REFERENCE only
|
||||
│ │ ├── BaselineSnapshotResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ViewBaselineSnapshot.php # REFERENCE only
|
||||
│ │ ├── ReviewPackResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ViewReviewPack.php # REFERENCE only
|
||||
│ │ ├── AlertDestinationResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ViewAlertDestination.php # REFERENCE only
|
||||
│ │ ├── PolicyVersionResource/
|
||||
│ │ │ └── Pages/
|
||||
│ │ │ └── ViewPolicyVersion.php # REFERENCE only
|
||||
│ │ └── Workspaces/
|
||||
│ │ └── Pages/
|
||||
│ │ └── ViewWorkspace.php # REFERENCE only
|
||||
│ ├── Support/
|
||||
│ │ ├── Navigation/
|
||||
│ │ │ └── RelatedNavigationResolver.php # REUSE
|
||||
│ │ ├── Rbac/
|
||||
│ │ │ └── UiEnforcement.php # REUSE
|
||||
│ │ └── Ui/
|
||||
│ │ └── ActionSurface/
|
||||
│ │ ├── ActionSurfaceValidator.php # MODIFY
|
||||
│ │ ├── ActionSurfaceExemptions.php # MODIFY
|
||||
│ │ ├── ActionSurfaceProfileDefinition.php # POSSIBLE MODIFY
|
||||
│ │ └── Enums/
|
||||
│ │ └── ActionSurfaceProfile.php # POSSIBLE MODIFY
|
||||
├── resources/
|
||||
│ └── views/
|
||||
│ └── filament/
|
||||
│ └── infolists/
|
||||
│ └── entries/
|
||||
│ └── tenant-review-summary.blade.php # MODIFY
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Guards/
|
||||
│ │ ├── ActionSurfaceContractTest.php # MODIFY
|
||||
│ │ ├── ActionSurfaceValidatorTest.php # MODIFY
|
||||
│ │ └── Spec192RecordPageHeaderDisciplineGuardTest.php # NEW
|
||||
│ └── Filament/
|
||||
│ ├── BaselineProfileCaptureStartSurfaceTest.php # MODIFY or REUSE
|
||||
│ ├── BaselineProfileCompareStartSurfaceTest.php # MODIFY or REUSE
|
||||
│ ├── TenantViewHeaderUiEnforcementTest.php # MODIFY or REUSE
|
||||
│ ├── FindingExceptionHeaderDisciplineTest.php # NEW
|
||||
│ ├── TenantReviewHeaderDisciplineTest.php # NEW
|
||||
│ └── EditTenantHeaderDisciplineTest.php # NEW
|
||||
└── Browser/
|
||||
├── Spec174EvidenceFreshnessPublicationTrustSmokeTest.php # REUSE for patterns
|
||||
├── Spec190BaselineCompareMatrixSmokeTest.php # REUSE for patterns
|
||||
└── Spec192RecordPageHeaderDisciplineSmokeTest.php # NEW
|
||||
```
|
||||
|
||||
Additional reused test files referenced in `tasks.md`, such as `EvidenceSnapshotResourceTest.php`, `TenantReviewUiContractTest.php`, and RBAC regression suites, remain in scope even when they are not repeated in the summary tree above.
|
||||
|
||||
**Structure Decision**: Keep the work entirely inside the existing Laravel/Filament monolith under `apps/platform`. Modify only the affected resource classes, page classes, the tenant-review contextual Blade partial, the existing action-surface validation layer, and focused tests. Do not create a new support framework beyond the minimum needed to extend the existing guard patterns.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| Cross-page header taxonomy and explicit exception catalog (BLOAT-001 trigger) | The feature must distinguish standard record pages, reference pages, minor-alignment pages, and the single workflow-heavy exception in a way CI can validate. | Pure page-local cleanup would reduce immediate noise but would not prevent future drift or document why `ViewTenant` is intentionally different. |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Several classic record/detail/edit pages still present navigation, routine mutations, and danger as flat peers, slowing interpretation and weakening action hierarchy.
|
||||
- **Existing structure is insufficient because**: The constitution now carries HDR-001, but the repo lacks a concrete inventory, a bounded classification model, and a regression hook that can distinguish standard pages from an allowed special type.
|
||||
- **Narrowest correct implementation**: Keep all changes inside existing page classes and the existing action-surface validation layer, classify only the explicitly named pages, remediate only the pages that need it, and document exactly one special-type exception.
|
||||
- **Ownership cost created**: A small amount of guard configuration, a few focused page tests, one smoke suite, and ongoing review discipline for future record pages.
|
||||
- **Alternative intentionally rejected**: A new header-action framework, resolver, or interface layer was rejected because the current repo already has enough primitives to implement the discipline directly.
|
||||
- **Release truth**: current-release operator clarity and action-surface discipline
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Codify the inventory and the regression contract
|
||||
|
||||
Goal: turn the spec inventory into an enforceable project-level contract without introducing a new framework.
|
||||
|
||||
Changes:
|
||||
|
||||
- Extend the existing action-surface validation and exemption layer with Spec 192 surface expectations.
|
||||
- Encode the explicit workflow-heavy exception for `ViewTenant`.
|
||||
- Record which pages are remediation-required, which are audit-only, and which are compliant references.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add `Spec192RecordPageHeaderDisciplineGuardTest.php`.
|
||||
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` with Spec 192 expectations.
|
||||
|
||||
### Phase B — Remediate the highest-noise standard record pages
|
||||
|
||||
Goal: implement the clearest header-discipline wins first on the pages with the most obvious peer-action sprawl.
|
||||
|
||||
Changes:
|
||||
|
||||
- Refactor `ViewBaselineProfile` to expose one state-sensitive primary action and move navigation plus secondary actions out of the flat primary lane.
|
||||
- Refactor `ViewTenantReview` so only one lifecycle action stays primary and the rest become grouped or contextual.
|
||||
- Keep all existing action semantics, notifications, `OperationRun` links, confirmations, and authorization unchanged.
|
||||
|
||||
Tests:
|
||||
|
||||
- Reuse or extend existing baseline profile feature tests.
|
||||
- Add runtime assertions for visible primary action count, grouped secondary placement, and preserved authorization behavior.
|
||||
|
||||
### Phase C — Remediate the remaining standard record and edit surfaces
|
||||
|
||||
Goal: finish the standard-page cleanup with lower-complexity but still important record surfaces.
|
||||
|
||||
Changes:
|
||||
|
||||
- Refactor `ViewEvidenceSnapshot` to keep one central next step and separate lifecycle danger from related navigation.
|
||||
- Refactor `ViewFindingException` so navigation becomes secondary while governance lifecycle remains clear.
|
||||
- Refactor `EditTenant` so the header no longer competes with the edit task.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add focused feature tests for `EvidenceSnapshot`, `FindingException`, and `EditTenant`.
|
||||
- Preserve existing capability gating and confirmation behavior in all assertions.
|
||||
|
||||
### Phase D — Tighten the explicit exception and audit-only pages
|
||||
|
||||
Goal: make the special type explicit and ensure audit-only pages stay calm.
|
||||
|
||||
Changes:
|
||||
|
||||
- Move pure navigation on `ViewTenant` into contextual placement outside the header and reorder grouped header actions by explicit buckets: external links, verification, setup, and lifecycle.
|
||||
- Audit `ViewProviderConnection` and `ViewFinding` for minor alignment only and change them only if they still present real header-noise issues.
|
||||
- Confirm `ViewBaselineSnapshot`, `ViewBackupSet`, `ViewReviewPack`, `ViewAlertDestination`, `ViewPolicyVersion`, and `ViewWorkspace` remain compliant references.
|
||||
|
||||
Tests:
|
||||
|
||||
- Extend `TenantViewHeaderUiEnforcementTest.php` or add a dedicated special-type feature test.
|
||||
- Cover the explicit exception reason in the guard layer.
|
||||
|
||||
### Phase E — Browser verification and final regression protection
|
||||
|
||||
Goal: prove the new hierarchy in a real browser and keep CI from accepting future header sprawl.
|
||||
|
||||
Changes:
|
||||
|
||||
- Add `Spec192RecordPageHeaderDisciplineSmokeTest.php` covering the remediated pages, the workflow-heavy exception, and a no-regression baseline over the compliant reference set.
|
||||
- Ensure the guard layer fails on multiple competing primaries, missing grouped secondary structure where required, or silent exceptions.
|
||||
- Re-run formatting and the focused Sail test pack.
|
||||
|
||||
Tests:
|
||||
|
||||
- Browser smoke coverage for visible hierarchy and no JavaScript errors.
|
||||
- Focused guard and page-level tests for each remediated or exceptional surface.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Cleanup grows into a new action framework | Medium | Low | Keep all changes inside existing page classes and the current action-surface guard layer. |
|
||||
| `More` groups become junk drawers | Medium | Medium | Treat internal order as part of the contract and test it on the special-type and remediated pages. |
|
||||
| State-driven primary action is chosen incorrectly | High | Medium | Add focused runtime assertions for BaselineProfile and TenantReview state transitions. |
|
||||
| `ViewTenant` silently bypasses the standard-page rule | Medium | Medium | Encode the special-type exception in the guard layer with an explicit reason and browser coverage. |
|
||||
| Reference pages get unnecessary churn | Medium | Low | Keep a documented compliant-reference set and use it as a regression baseline. |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` so Spec 192 becomes an explicit CI-enforced rule rather than a manual review note.
|
||||
- Add `Spec192RecordPageHeaderDisciplineGuardTest.php` to validate remediation-required pages, the explicit special type, and any whitelisted references.
|
||||
- Reuse existing baseline profile tests where possible and add focused feature tests for `EvidenceSnapshot`, `FindingException`, `TenantReview`, and `EditTenant` where no dedicated header-discipline tests exist yet.
|
||||
- Extend `TenantViewHeaderUiEnforcementTest.php` or add a dedicated special-type test so grouped ordering and exception semantics stay covered.
|
||||
- Add `Spec192RecordPageHeaderDisciplineSmokeTest.php` using the existing browser-smoke infrastructure and fixture traits already used by Spec 174 and Spec 190, including a no-regression pass over the compliant reference set.
|
||||
- Add explicit regression assertions that this feature does not force Spec 133 body-layout rollout and does not expand confirmation depth, reason capture, or provider-dispatch semantics.
|
||||
- Run the focused Sail verification commands from `quickstart.md`, then run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS.
|
||||
|
||||
- Livewire v4.0+ compliance remains intact because all touched surfaces stay inside the existing Filament v5 + Livewire v4 stack.
|
||||
- Provider registration remains unchanged in `bootstrap/providers.php`.
|
||||
- No new globally searchable resource is introduced; touched resources already have View and/or Edit pages where relevant.
|
||||
- Destructive actions remain confirmation-gated and authorization-gated.
|
||||
- No new asset strategy is required; deploy handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
- The testing plan covers the remediated standard pages, the explicit workflow-heavy exception, and the project-level regression guard for future record-page header drift.
|
||||
88
specs/192-record-header-discipline/quickstart.md
Normal file
88
specs/192-record-header-discipline/quickstart.md
Normal file
@ -0,0 +1,88 @@
|
||||
# Quickstart: Record Page Header Discipline & Contextual Navigation
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the in-scope record/detail/edit surfaces under one bounded header-action discipline: one clear next step on standard pages, contextual navigation near related content and outside the header, grouped secondary actions for non-navigation actions, separated danger, and one explicit workflow-heavy exception.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
1. Confirm the in-scope inventory in code.
|
||||
- Map each in-scope page class to the Spec 192 classification.
|
||||
- Confirm which pages are remediation-required, minor-alignment only, compliant reference, or workflow-heavy special type.
|
||||
|
||||
2. Remediate the standard pages first.
|
||||
- Refactor `ViewBaselineProfile` to expose only one visible primary action based on snapshot readiness.
|
||||
- Refactor `ViewEvidenceSnapshot` so run and review-pack navigation move to contextual placement outside the header and `Expire snapshot` remains separated.
|
||||
- Refactor `ViewFindingException` so navigation moves to contextual placement outside the header, `Renew exception` may remain primary, and `Revoke exception` stays isolated.
|
||||
- Refactor `ViewTenantReview` so only one lifecycle action is primary, navigation becomes contextual outside the header, and infrequent lifecycle actions stay grouped.
|
||||
- Refactor `EditTenant` so the header stops competing with the edit task and view/onboarding links move into contextual tenant-meta placement.
|
||||
|
||||
3. Tighten the explicit exception and minor-alignment pages.
|
||||
- Audit `ViewTenant` as the workflow-heavy special type and order its grouped actions deliberately.
|
||||
- Review `ViewProviderConnection` and `ViewFinding` for minor alignment only, and change them only if the audit proves real header noise.
|
||||
- Confirm that `ViewBaselineSnapshot`, `ViewBackupSet`, `ViewReviewPack`, `ViewAlertDestination`, `ViewPolicyVersion`, and `ViewWorkspace` remain valid references.
|
||||
|
||||
4. Add regression protection.
|
||||
- Extend the existing action-surface guard or exemption mapping with Spec 192 expectations.
|
||||
- Add focused Livewire/Pest page tests for remediated surfaces.
|
||||
- Add one browser smoke suite covering the remediated pages, the explicit special-type exception, and a no-regression baseline over the compliant reference set.
|
||||
- Add explicit regression checks that this feature does not force Spec 133 body-layout rollout and does not expand confirmation depth, reason capture, or provider-dispatch semantics.
|
||||
|
||||
5. Run focused verification.
|
||||
- Run the guard tests, the remediated page tests, the browser smoke suite, and formatting through Sail.
|
||||
|
||||
## Suggested Source Files
|
||||
|
||||
- `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
|
||||
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||
- `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
|
||||
- `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||
- `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
|
||||
- `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
|
||||
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`
|
||||
- `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
|
||||
|
||||
## Suggested Test Files
|
||||
|
||||
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||
- `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`
|
||||
- `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php`
|
||||
- `apps/platform/tests/Browser/Spec174EvidenceFreshnessPublicationTrustSmokeTest.php`
|
||||
- `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
||||
- `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||
|
||||
## Minimum Verification Commands
|
||||
|
||||
Run all commands through Sail from `apps/platform`.
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceValidatorTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual Acceptance Checklist
|
||||
|
||||
1. Open each remediated standard page and confirm the header shows at most one visible primary action.
|
||||
2. Confirm pure navigation is no longer presented as an equal-weight peer to the primary mutation on remediated pages and now lives in contextual placement outside the header.
|
||||
3. Confirm rare administrative actions live in a grouped secondary structure instead of a flat peer row.
|
||||
4. Confirm destructive or governance-sensitive actions remain visually separated and keep confirmation.
|
||||
5. Confirm the tenant edit page still reads as an edit surface first.
|
||||
6. Confirm the tenant admin resource view stays grouped and ordered as an explicit workflow-heavy exception.
|
||||
7. Confirm compliant reference pages do not regress or receive unnecessary cosmetic churn.
|
||||
8. Confirm browser smoke checks show no JavaScript errors on the remediated pages, the workflow-heavy exception page, and the compliant reference baseline pages.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- No migration is expected.
|
||||
- No new provider registration is expected; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
||||
- No new asset registration is expected. Existing deploy handling of `cd apps/platform && php artisan filament:assets` remains sufficient.
|
||||
94
specs/192-record-header-discipline/research.md
Normal file
94
specs/192-record-header-discipline/research.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Research: Record Page Header Discipline & Contextual Navigation
|
||||
|
||||
## Decision: Reuse existing page-local action builders, `UiEnforcement`, `ActionGroup`, and related-navigation helpers instead of adding a new header-action framework
|
||||
|
||||
### Rationale
|
||||
|
||||
The codebase already has the right implementation primitives for this cleanup: page-private action builders, `UiEnforcement` for per-action RBAC handling, `ActionGroup` for secondary grouping, and `RelatedNavigationResolver` for contextual navigation. The spec needs discipline and regression protection, not a new registry, interface, or placement engine.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add a `HeaderActionResolver` or `HeaderActionRegistry`: rejected because the repo already has several coordination abstractions and this feature does not justify another one.
|
||||
- Add an `ActionPlacement` enum or interface hierarchy: rejected because the placement decisions remain page- and state-sensitive and would ossify too early.
|
||||
|
||||
## Decision: Keep contextual navigation close to the relevant content instead of leaving it in the flat header lane
|
||||
|
||||
### Rationale
|
||||
|
||||
Several touched pages already use related-navigation helpers or related-context rendering. The narrowest fix is to move pure navigation out of equal-weight header placement and into summary, field, badge, status, or related-context placement outside the header, using the existing related-navigation patterns rather than inventing new local UI concepts.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Keep `Open ...` and `View ...` actions in the primary header but restyle them: rejected because the problem is semantic weight, not only button color.
|
||||
- Remove related navigation entirely: rejected because the navigation is still useful; it just belongs closer to its context.
|
||||
|
||||
## Decision: Treat the tenant admin resource view as an explicit workflow-heavy special-type exception
|
||||
|
||||
### Rationale
|
||||
|
||||
`ViewTenant` is not a simple record detail. It already acts as a workflow hub for verification, RBAC refresh, onboarding recovery, provider entry points, and lifecycle actions. The correct move is to keep it grouped and internally ordered, document it as a special type, and prevent it from silently bypassing the standard-page rule.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Force `ViewTenant` into the same single-next-step shape as simple record pages: rejected because it would misrepresent a multi-purpose operational hub.
|
||||
- Leave `ViewTenant` undocumented as an exception: rejected because silent exceptions cause future drift and inconsistent review standards.
|
||||
|
||||
## Decision: Use existing calm pages as reference patterns and preserve no-op surfaces
|
||||
|
||||
### Rationale
|
||||
|
||||
The repo already contains pages that model the intended calmness well enough for this spec: `ViewBackupSet`, `ViewBaselineSnapshot`, `ViewReviewPack`, `ViewAlertDestination`, `ViewPolicyVersion`, and `ViewWorkspace`. These pages should be preserved unless a minor alignment issue is found, because the goal is discipline, not cosmetic uniformity.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Rebuild every in-scope page to the same visible pattern: rejected because it would create churn without additional operator value.
|
||||
- Ignore no-op pages and only document the problem pages: rejected because the spec requires an explicit project-wide classification matrix.
|
||||
|
||||
## Decision: Drive standard-page remediation with page-local state and existing action methods
|
||||
|
||||
### Rationale
|
||||
|
||||
The heaviest remediations, especially `ViewBaselineProfile` and `ViewTenantReview`, already express their action logic through page-private methods or explicit action blocks. The cleanest implementation is to keep that local state logic and only change placement and grouping. This preserves existing authorization, notifications, run links, and confirmations.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Rebuild header logic around a shared presenter object: rejected because the same state machine is not yet shared across enough pages to justify a new layer.
|
||||
- Push all actions into a single generic `More` dropdown: rejected because the spec requires structured grouping and a visible next step, not a junk drawer.
|
||||
|
||||
## Decision: Build regression protection on top of the existing action-surface guard and focused page tests
|
||||
|
||||
### Rationale
|
||||
|
||||
The repo already has `ActionSurfaceValidator`, `ActionSurfaceContractTest`, `ActionSurfaceValidatorTest`, `FilamentTableStandardsGuardTest`, focused Livewire page tests, and browser smoke coverage. The narrowest regression strategy is to extend those existing layers with Spec 192 expectations: a whitelisted header-discipline guard, page-level visibility assertions on the remediated screens, and one browser smoke path for visible hierarchy.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add a new standalone framework for header-discipline validation: rejected because the existing `ActionSurfaceValidator` and guard test style already provide the right enforcement hook.
|
||||
- Rely on manual review only: rejected because header-sprawl regressions are exactly the kind of drift CI should catch.
|
||||
|
||||
## Decision: Keep testing layered but lightweight
|
||||
|
||||
### Rationale
|
||||
|
||||
Three test layers are enough for this feature:
|
||||
|
||||
- static or discovery-based guard coverage for the classification and header-discipline contract,
|
||||
- focused Livewire/Pest page tests for state-driven primary-action visibility and grouped-secondary behavior,
|
||||
- one browser smoke suite proving the visible hierarchy on remediated pages, the explicit special-type exception, and a no-regression baseline over the compliant reference set.
|
||||
|
||||
This matches existing repo patterns and avoids over-testing presentation indirection.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Browser-test every permutation of every page: rejected because it would be expensive and redundant with feature tests.
|
||||
- Add only guard tests: rejected because page-level state transitions still need runtime assertions.
|
||||
|
||||
## Decision: No new asset or provider work is needed
|
||||
|
||||
### Rationale
|
||||
|
||||
The cleanup stays entirely within existing Filament v5 header action surfaces. No panel providers, custom assets, or lazy-loaded scripts are required. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Introduce custom header components or new assets for grouped actions: rejected because native Filament header actions and `ActionGroup` already satisfy the need.
|
||||
341
specs/192-record-header-discipline/spec.md
Normal file
341
specs/192-record-header-discipline/spec.md
Normal file
@ -0,0 +1,341 @@
|
||||
# Feature Specification: Record Page Header Discipline & Contextual Navigation
|
||||
|
||||
**Feature Branch**: `192-record-header-discipline`
|
||||
**Created**: 2026-04-11
|
||||
**Status**: Proposed
|
||||
**Input**: User description: "Spec 192 - Record Page Header Discipline & Contextual Navigation"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Several classic admin record/detail/edit pages still expose flat header button rows where navigation, routine mutation, infrequent administration, and danger all compete at the same visual weight.
|
||||
- **Today's failure**: Operators reach the right pages, but the header often does not signal the next best step. Navigation occupies prime action space, multiple mutations compete visibly, and rare or destructive actions appear too close to routine actions.
|
||||
- **User-visible improvement**: Standard record pages become calmer and easier to scan. One next step becomes obvious, contextual navigation moves closer to the content it belongs to, and risky or rare actions stop cluttering the primary header lane.
|
||||
- **Smallest enterprise-capable version**: Classify all in-scope record/detail/edit surfaces, remediate the clearly problematic standard pages with one shared header discipline, explicitly catalog the one workflow-heavy exception, and add lightweight regression protection.
|
||||
- **Explicit non-goals**: No new global action framework for every surface class, no monitoring or workbench action cleanup, no new confirmation-depth policy, no provider-dispatch redesign, and no forced Spec 133 body-layout rollout onto simple pages.
|
||||
- **Permanent complexity imported**: A narrow cross-page header-discipline contract, an explicit surface-classification matrix, a documented special-type exception, and focused regression coverage for record-page header sprawl.
|
||||
- **Why now**: The constitution now contains HDR-001, and the repo already has both good and bad examples. Without a concrete inventory and rule rollout, drift will continue page by page.
|
||||
- **Why not local**: Isolated page-by-page cleanup would reduce noise on one page at a time but would not create a stable repo-wide rule for when navigation belongs in context, when secondary actions must be grouped, or when a page deserves a special-type exception.
|
||||
- **Approval class**: Cleanup
|
||||
- **Red flags triggered**: Cross-domain UI rule risk if this grows into a generalized action framework. Defense: the spec stays limited to classic record/detail/edit surfaces, forbids a new engine, and explicitly excludes other surface classes.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- Existing BaselineProfile resource view route
|
||||
- Existing EvidenceSnapshot, FindingException, and TenantReview resource view routes
|
||||
- Existing Tenant resource view and edit routes in the tenant admin plane
|
||||
- Existing ProviderConnection and Finding resource view routes
|
||||
- Existing ReviewPack, AlertDestination, PolicyVersion, Workspace resource view routes
|
||||
- Existing BaselineSnapshot and BackupSet resource view routes
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned records touched by this spec remain workspace-owned: BaselineProfile, BaselineSnapshot, AlertDestination, PolicyVersion, and Workspace views.
|
||||
- Tenant-owned records touched by this spec remain tenant-owned: EvidenceSnapshot, Finding, FindingException, TenantReview, Tenant, ProviderConnection, ReviewPack, and BackupSet views.
|
||||
- This spec introduces no new tables, persisted entities, route semantics, or record truth. It changes only action hierarchy, placement, and classification on existing pages.
|
||||
- **RBAC**:
|
||||
- Existing workspace membership plus capability checks continue to govern workspace-owned pages.
|
||||
- Existing tenant membership plus capability checks continue to govern tenant-owned pages.
|
||||
- Header regrouping does not change authorization semantics: non-members remain `404`, members lacking a capability remain `403`, and destructive actions retain confirmation plus server-side authorization.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: This feature adds no new filter behavior. Tenant-scoped record pages remain bound to the active tenant context, while workspace-owned record pages remain workspace-scoped even if a tenant was previously active. Moving navigation out of the primary header must not broaden scope or imply cross-tenant search.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Any contextual link that survives the header cleanup must still be built through existing related-navigation and capability-aware helpers. Inaccessible related records remain suppressed, non-members remain deny-as-not-found, and moving an action from primary header placement to contextual or grouped placement must not reveal a destination that was previously inaccessible.
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline profile detail | Workspace record detail | Existing BaselineProfile inspect flow into the view page | allowed | Summary context and grouped secondary header actions after remediation | none | Existing BaselineProfile collection route | Existing BaselineProfile view route | Workspace, active snapshot state, visible assigned-tenant count | Baseline profile | Whether a consumable snapshot exists and whether capture or compare is the next step | remediation required |
|
||||
| Evidence snapshot detail | Tenant record detail | Existing EvidenceSnapshot inspect flow into the view page | allowed | Related-context links and grouped secondary header actions | Separated lifecycle danger action | Existing EvidenceSnapshot collection route | Existing EvidenceSnapshot view route | Tenant, freshness, expiry, latest review-pack relationship | Evidence snapshot | Whether the snapshot is current, reusable, or needs refresh/expiry handling | remediation required |
|
||||
| Finding exception detail | Tenant governance detail | Existing FindingException inspect flow into the view page | allowed | Related finding and approval-queue links move to contextual placement outside the header | Revocation remains isolated danger | Existing FindingException collection or approval-queue route | Existing FindingException view route | Tenant, validity state, owner, review due state | Finding exception | Whether the exception remains valid and what governance step is next | remediation required |
|
||||
| Tenant review detail | Tenant record detail | Existing TenantReview inspect flow into the view page | allowed | Related export/evidence/run links move to contextual placement outside the header, while infrequent lifecycle actions stay grouped secondary | Archive remains separated inside secondary danger placement | Existing TenantReview collection route | Existing TenantReview view route | Tenant, review status, evidence/export linkage, operation context | Tenant review | Which lifecycle step is next and whether the review is ready to refresh, publish, or export | remediation required |
|
||||
| Tenant edit surface | Tenant edit record | Existing Tenant edit entry from tenant list or tenant detail | allowed | Related view/context links move to contextual placement outside the header | Archive or restore stays separate from ordinary edit context | Existing Tenant collection route | Existing Tenant edit route | Tenant lifecycle, workspace context, related onboarding status | Tenant | That this surface is for editing and not for multi-purpose workflow dispatch | remediation required |
|
||||
| Tenant detail (tenant admin resource view) | Workflow-heavy tenant detail hub | Existing Tenant inspect flow into the resource view page | allowed | Contextual navigation remains outside the header, while internal grouped header actions remain mandatory for external links, verification, setup, and lifecycle work | Lifecycle actions remain isolated inside grouped danger placement | Existing Tenant collection route | Existing Tenant resource view route | Tenant lifecycle, verification state, RBAC health, recent operations, onboarding context | Tenant | What setup, verification, or lifecycle step currently deserves attention | workflow-heavy special-type exception |
|
||||
| Provider connection detail | Tenant configuration detail | Existing ProviderConnection inspect flow into the view page | allowed | Secondary admin actions stay in a deliberate grouped override-management structure | Dangerous credential actions stay inside separated danger slot within the group | Existing ProviderConnection collection route | Existing ProviderConnection view route | Tenant, provider, connection type, consent and verification state | Provider connection | Whether the connection is usable and which override or consent step is relevant | minor alignment only |
|
||||
| Finding detail | Tenant record detail | Existing Finding inspect flow into the view page | allowed | Back-link, related-context link, and approval-queue navigation stay secondary or grouped | Workflow danger stays inside the workflow group | Existing Finding collection route | Existing Finding view route | Tenant, severity or health context, related approval state | Finding | What the finding means and where the next governance or remediation path lives | minor alignment only |
|
||||
| Review pack detail | Tenant export detail | Existing ReviewPack inspect flow into the view page | allowed | Regenerate remains secondary to the pack outcome | none | Existing ReviewPack collection route | Existing ReviewPack view route | Tenant, pack status, export options, readiness | Review pack | Whether the pack is ready for download | compliant / no-op reference |
|
||||
| Alert destination detail | Workspace configuration detail | Existing AlertDestination inspect flow into the view page | allowed | Deep-link navigation stays secondary | none | Existing AlertDestination collection route | Existing AlertDestination view route | Workspace, destination type, enabled state, last-test state | Alert destination | Whether delivery is enabled and whether the last test succeeded | compliant / no-op reference |
|
||||
| Policy version detail | Workspace detail | Existing PolicyVersion inspect flow into the view page | allowed | Single related-record navigation remains contextual | none | Existing PolicyVersion collection route | Existing PolicyVersion view route | Workspace, version identity, policy relationship | Policy version | What version is being inspected and what record it belongs to | compliant / no-op reference |
|
||||
| Workspace resource detail | Workspace detail | Existing Workspace inspect flow into the resource view page | allowed | No secondary header cluster required | none | Existing Workspace collection route | Existing Workspace resource view route | Workspace identity and manage capability state | Workspace | Which workspace is being viewed and whether it can be edited | compliant / no-op reference |
|
||||
| Baseline snapshot detail | Workspace detail | Existing BaselineSnapshot inspect flow into the view page | allowed | Single related-record navigation remains contextual | none | Existing BaselineSnapshot collection route | Existing BaselineSnapshot view route | Workspace, source profile, snapshot recency | Baseline snapshot | What snapshot is current and what related record it belongs to | compliant / no-op reference |
|
||||
| Backup set detail | Tenant recovery detail | Existing BackupSet inspect flow into the view page | allowed | Related-record navigation plus grouped secondary mutations | Grouped restore/archive/force-delete danger structure already exists | Existing BackupSet collection route | Existing BackupSet view route | Tenant, archive state, restore relevance | Backup set | Whether the backup set is active, archived, or eligible for restore | compliant / no-op reference |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline profile detail | Workspace operator | Standard record detail | Is this baseline ready to capture or compare, and what is the next step? | Snapshot readiness, capture mode, visible assignment scope | Run IDs, rollout reason details | readiness, snapshot freshness | `simulation only` for compare; existing workspace capture path for capture | `Capture baseline` or `Compare now` | none |
|
||||
| Evidence snapshot detail | Tenant operator | Standard record detail | Is this evidence current enough, and do I need to refresh or inspect related outputs? | Snapshot status, expiry, related pack/run availability | Internal run identifiers | freshness, lifecycle | Existing evidence refresh scope only | `Refresh evidence` when applicable | `Expire snapshot` |
|
||||
| Finding exception detail | Tenant manager | Governance record detail | Is this exception still valid, and do I need to renew or revoke it? | Current validity, owner, review due, linked finding context | Detailed evidence references | validity, governance lifecycle | `TenantPilot only` | `Renew exception` when applicable | `Revoke exception` |
|
||||
| Tenant review detail | Tenant manager | Standard record detail | What is the next lifecycle step for this review? | Review status, evidence/export linkage, current operation context | Low-level run metadata | lifecycle, readiness, freshness | `TenantPilot only` for publish/archive; existing export path for pack generation | One of `Refresh review`, `Publish review`, or `Export executive pack` depending on state | `Archive review` |
|
||||
| Tenant edit surface | Tenant manager or owner | Edit surface | Can I update this tenant safely without losing context? | Editable tenant identity, lifecycle state, related onboarding context | Technical provider details remain outside the primary edit task | lifecycle | `TenantPilot only` | Save the edit form | `Archive` or `Restore` when available |
|
||||
| Tenant detail (tenant admin resource view) | Tenant operator or manager | Workflow-heavy detail hub | What tenant setup, verification, or lifecycle step needs attention right now? | Verification report, recent operations, RBAC health, onboarding state | Raw run detail and lower-level provider metadata | lifecycle, verification readiness, RBAC health | Mixed existing tenant-operation scopes | `Verify configuration` only if it is the clearly dominant next step; otherwise grouped actions only | `Archive` or `Restore` |
|
||||
| Provider connection detail | Tenant manager | Configuration detail | Is this connection healthy, and what provider-management step is next? | Consent, connection type, override state | Credential-specific technical detail | consent, verification, lifecycle | Existing provider-management scopes | `Edit` or `Grant admin consent` only if one clearly dominates | Credential delete and override reversion remain grouped danger |
|
||||
| Finding detail | Tenant operator | Standard record detail | What does this finding mean, and where should I go next? | Finding summary, related record context, governance path | Low-level payload detail | governance state, health or severity | Existing finding workflow scope only | Existing workflow primary, if any, stays inside governed group | Existing workflow danger only |
|
||||
| Review pack detail | Tenant operator | Export detail | Is this pack ready to use? | Status, download readiness, pack options summary | Raw generation metadata | readiness, lifecycle | Existing export scope only | `Download` | none |
|
||||
| Alert destination detail | Workspace operator | Configuration detail | Is this destination working? | Destination type, enabled state, last-test result | Delivery log drilldown context | enablement, delivery health | Existing alert-test scope only | `Send test message` | none |
|
||||
| Policy version detail | Workspace operator | Reference detail | Which version am I looking at, and what record should I open next? | Version identity and parent policy context | Raw payload footer content | version lifecycle | read-only | Related-record open action only | none |
|
||||
| Workspace resource detail | Workspace owner or manager | Standard record detail | What workspace is this, and can I manage it? | Workspace identity and top-level manage affordance | Low-level metadata | none beyond manage eligibility | `TenantPilot only` | `Edit` | none |
|
||||
| Baseline snapshot detail | Workspace operator | Reference detail | What snapshot is this and what should I open next? | Snapshot identity, related profile context | Detailed snapshot payload content | recency, completeness | read-only | Related-record open action only | none |
|
||||
| Backup set detail | Tenant manager | Recovery detail | Is this backup set active, archived, or ready to restore? | Archive state, related record context, restore relevance | Deep backup item detail | lifecycle, restore readiness | Existing backup mutation scope only | Related-record open action stays secondary; restore remains grouped | `Archive` and `Force delete` remain grouped danger |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: yes
|
||||
- **Current operator problem**: Classic record pages are inconsistent about what belongs in the primary header lane, so operators see avoidable noise and weak hierarchy on pages that should be fast to scan.
|
||||
- **Existing structure is insufficient because**: HDR-001 exists at the constitutional level, but the repo still lacks a concrete inventory, a bounded standard-record rule set, and an explicit way to justify a workflow-heavy exception without page-local improvisation.
|
||||
- **Narrowest correct implementation**: Apply the rule only to the explicitly named record/detail/edit surfaces, remediate only the pages that need it, preserve already clean pages, and document a single special-type exception instead of inventing a framework.
|
||||
- **Ownership cost**: Ongoing review discipline for header-action placement, a small regression-test burden, browser smoke maintenance for remediated pages, and explicit exception tracking.
|
||||
- **Alternative intentionally rejected**: Purely local cleanup on the five obvious problem pages was rejected because it would reduce immediate noise but would not stop future drift or explain why some pages are compliant while one page is an allowed exception.
|
||||
- **Release truth**: current-release operator clarity and action-surface discipline
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - See one next step on standard record pages (Priority: P1)
|
||||
|
||||
As an operator opening a standard record/detail/edit page, I want the header to show one clear next step instead of a horizontal row of competing buttons.
|
||||
|
||||
**Why this priority**: This is the core workflow benefit. If the next step still competes with several peer actions, the feature has not solved the problem it targets.
|
||||
|
||||
**Independent Test**: Open each remediation-required standard page and verify that no more than one visible emphasized header action remains while routine navigation and rare actions move out of the flat primary lane.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a remediation-required standard record page, **When** the page renders after cleanup, **Then** it shows at most one visible primary header action.
|
||||
2. **Given** a standard page whose next step depends on state, **When** the record state changes, **Then** the page promotes only the state-appropriate next action and keeps others secondary.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Follow contextual navigation near the relevant content (Priority: P1)
|
||||
|
||||
As an operator, I want related navigation to live near the summary or context it belongs to, instead of taking the same visual weight as a mutation.
|
||||
|
||||
**Why this priority**: Contextual navigation is still important, but it should support the reading flow instead of dominating the header.
|
||||
|
||||
**Independent Test**: Open remediated pages that currently mix navigation and mutation in the header and confirm that pure navigation moves to related-context or other inline contextual placement outside the header.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a remediated standard page with related-record navigation, **When** the page renders, **Then** the navigation no longer appears as a peer to the main mutation and instead lives in contextual placement outside the header.
|
||||
2. **Given** a page offers both navigation and a next-step mutation, **When** the operator scans the header, **Then** the mutation reads as the next step and the navigation reads as supporting context.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep rare and dangerous actions available without clutter (Priority: P2)
|
||||
|
||||
As an operator, I want infrequent administrative actions and destructive actions to remain available without visually competing with routine work.
|
||||
|
||||
**Why this priority**: The pages still need power-user actions, but those actions should not make every visit look risky or overloaded.
|
||||
|
||||
**Independent Test**: Open pages with lifecycle or dangerous actions and confirm that rare actions are grouped and danger remains visibly separated with existing confirmation behavior intact.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a remediated page includes rare administrative actions, **When** the page renders, **Then** those actions live in a deliberate secondary group instead of as peer buttons.
|
||||
2. **Given** a remediated page includes destructive or governance-sensitive actions, **When** the page renders, **Then** those actions remain separated from safe routine actions and still require confirmation.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Treat workflow-heavy pages as explicit exceptions (Priority: P3)
|
||||
|
||||
As a product reviewer, I want workflow-heavy record pages to be explicitly identified as exceptions so they stay disciplined without being forced into the wrong pattern.
|
||||
|
||||
**Why this priority**: The cleanup should not flatten important operational hubs into misleadingly simple pages.
|
||||
|
||||
**Independent Test**: Review the special-type page and verify that it is explicitly classified, internally structured, and not silently exempted.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the tenant admin resource view is a workflow-heavy hub, **When** the spec and implementation are reviewed, **Then** it is marked as a special-type exception with a documented internal action order.
|
||||
2. **Given** a special-type page does not have one obvious next step, **When** it renders, **Then** it does not reintroduce a flat multi-button header row in the name of consistency.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- If a baseline profile has no consumable snapshot, `Capture baseline` remains the sole visible primary action and compare actions stay secondary or disabled.
|
||||
- If a standard page has no related destination currently available, the cleanup must not insert a dead contextual-navigation placeholder just to satisfy consistency.
|
||||
- If the current actor can view a record but cannot execute its mutation, the action may remain visible-but-disabled per existing RBAC rules, but it still must not become a competing primary if it is not executable.
|
||||
- If a workflow-heavy page has no clearly dominant next step, it should keep grouped actions only rather than manufacturing a fake primary.
|
||||
- If a compliant reference page already has one clean related action or one clean grouped danger structure, the spec must not force cosmetic churn.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new persisted truth, and no new queue model. It only reorganizes existing operator-facing action surfaces on existing record/detail/edit pages. Existing mutations keep their current preview, confirmation, audit, and run-observability behavior.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The spec adds only a narrow cross-page UI discipline plus an explicit exception vocabulary. It introduces no new persistence, no new state family, and no generalized action engine. The proportionality review above documents why a bounded cross-page rule is justified and why a broader framework is rejected.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing operations such as baseline capture, baseline compare, evidence refresh, tenant review refresh, verification, and provider-management actions continue to use their current `OperationRun`, toast, and audit contracts. This spec does not create a new run type or alter existing run summary semantics.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature spans workspace/admin and tenant-context surfaces but does not change authorization logic. Non-members remain `404`, members lacking required capabilities remain `403`, grouped or relocated actions still enforce server-side authorization, and destructive actions continue to require confirmation. At least one positive and one negative regression test must confirm that regrouping actions does not loosen access.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication-handshake behavior changes.
|
||||
|
||||
**Constitution alignment (BADGE-001):** The cleanup does not introduce new badge semantics. Existing status and health badges remain centralized and unchanged.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The feature must use native Filament header actions, `ActionGroup`, and existing shared UI primitives. It must avoid a page-local button framework or ad-hoc badge language. The only approved exception is keeping the tenant admin resource view as a workflow-heavy surface with explicit grouped action ordering.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Action labels must stay domain-first and consistent as actions move between primary, contextual, and grouped placement. Target verbs include `Capture baseline`, `Compare now`, `Refresh review`, `Publish review`, `Verify configuration`, `Open …`, `Archive`, and `Restore`. Implementation-first labels must not become primary operator labels.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** This spec classifies each affected surface, defines the one inspect/open model already in use, preserves existing list inspect affordances, and reassigns secondary and destructive actions according to surface type. Standard record pages must expose one clear next step; explicit exceptions must be catalogued and justified.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible header content must stay operator-first. Pure navigation becomes contextual content outside the header, diagnostics remain secondary, and dangerous actions keep existing confirmation and mutation-scope language. Workspace and tenant context must remain explicit through the same routes, related links, and action copy already in use.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature introduces no new presenter or semantic layer. It uses direct action placement and classification rather than a new interpretation framework. Tests should prove business consequences of header discipline, not a thin abstraction.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied for all remediated standard record/detail/edit pages after cleanup: one visible primary header action, no redundant flat navigation buttons, no empty action groups, and destructive actions in the correct secondary or danger placement. The tenant admin resource view is the only explicit exception and must document why grouped workflow actions remain appropriate.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature changes header-action hierarchy only. View pages continue to rely on their existing infolists or structured sections, and the tenant edit page keeps its edit-form save/cancel affordances as the primary edit path. The spec does not justify any regression toward disabled edit forms or naked-field layouts.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-192-001 Surface inventory**: The spec and implementation MUST maintain an explicit inventory of every in-scope record/detail/edit surface covered by this feature.
|
||||
- **FR-192-002 Explicit classification**: Every in-scope surface MUST be assigned exactly one classification: `compliant / no-op reference`, `remediation required`, `minor alignment only`, or `workflow-heavy special-type exception`.
|
||||
- **FR-192-003 Standard-page primary rule**: Every remediated standard record/detail/edit surface MUST expose at most one visible emphasized primary header action.
|
||||
- **FR-192-004 Contextual navigation rule**: Pure related-record navigation MUST leave the flat primary header lane on remediated standard pages and move into contextual placement outside the header.
|
||||
- **FR-192-005 Secondary grouping rule**: Infrequent, administrative, or secondary actions MUST move into deliberate grouped secondary placement rather than remaining as peer buttons.
|
||||
- **FR-192-006 Group-order rule**: Secondary grouped actions MUST remain internally ordered so navigation, routine mutation, external links, and danger do not become an undifferentiated junk drawer.
|
||||
- **FR-192-007 Danger separation rule**: Destructive, irreversible, or governance-sensitive actions MUST remain visibly separated from safe routine actions and keep their existing confirmation behavior.
|
||||
- **FR-192-008 Baseline profile hierarchy**: On BaselineProfile detail, `Capture baseline` MUST be the only visible primary action when no consumable snapshot exists, and `Compare now` MUST be the only visible primary action when a consumable snapshot exists. `View snapshot` and `Open compare matrix` MUST move to contextual placement outside the header, while `Compare assigned tenants` and `Edit` become secondary.
|
||||
- **FR-192-009 Evidence snapshot hierarchy**: On EvidenceSnapshot detail, only one central next action MAY remain visible. `Open operation` and `View review pack` MUST move to contextual placement outside the header, and `Expire snapshot` MUST remain a separated lifecycle danger action.
|
||||
- **FR-192-010 Finding exception hierarchy**: On FindingException detail, related navigation MUST move to contextual placement outside the header, `Renew exception` MAY be primary when renewal is valid, and `Revoke exception` MUST remain separated as a danger-governance action.
|
||||
- **FR-192-011 Tenant review hierarchy**: On TenantReview detail, only one dominant lifecycle action MAY remain primary based on review state. Refresh, publish, export, and related navigation MUST no longer appear as a flat row of peer buttons; pure navigation moves to contextual placement outside the header, and archive remains secondary danger.
|
||||
- **FR-192-012 Tenant edit discipline**: EditTenant MUST remain edit-first. Save/cancel remain the primary edit affordance, while `View` and related onboarding move to contextual placement outside the header and lifecycle actions stay secondary and structurally separated.
|
||||
- **FR-192-013 Workflow-heavy exception contract**: The tenant admin resource view MUST be explicitly marked as a workflow-heavy special type. It MAY expose one visible primary action only when a clear next step dominates; otherwise its actions remain grouped with deliberate internal order.
|
||||
- **FR-192-014 Minor-alignment review**: ViewProviderConnection and ViewFinding MUST be reviewed against the same discipline but changed only when a real header-noise issue exists.
|
||||
- **FR-192-015 Reference preservation**: ViewBaselineSnapshot, ViewBackupSet, ViewReviewPack, ViewAlertDestination, ViewPolicyVersion, and the Workspace resource view MUST stay unchanged or receive only minimal alignment if they already satisfy the discipline.
|
||||
- **FR-192-016 No body-layout expansion**: This feature MUST NOT force Spec 133 body-layout rollout onto simple view pages that are already structurally acceptable.
|
||||
- **FR-192-017 No governance-friction expansion**: This feature MUST NOT widen confirmation depth, reason-capture rules, or provider-dispatch semantics beyond the behavior already owned by the underlying actions.
|
||||
- **FR-192-018 Authorization continuity**: Moving, grouping, or relabeling actions MUST NOT change route scope, capability enforcement, deny-as-not-found behavior, or audit obligations.
|
||||
- **FR-192-019 Vocabulary continuity**: Header labels, modal titles, notifications, and related helper copy MUST keep the same domain vocabulary when actions move between primary, contextual, and grouped placement.
|
||||
- **FR-192-020 Regression guard**: The repo MUST add a lightweight project-wide guard that prevents new standard record pages from reintroducing multiple competing primary header actions, flat navigation-mutation mixes, or silent special-type exceptions.
|
||||
- **FR-192-021 Browser verification**: Browser or UI smoke checks MUST cover all remediation-required pages, the explicit workflow-heavy exception, and a no-regression baseline over the compliant or no-op reference pages.
|
||||
|
||||
## Surface Decision Matrix
|
||||
|
||||
- **Remediation required**:
|
||||
- BaselineProfile detail
|
||||
- EvidenceSnapshot detail
|
||||
- FindingException detail
|
||||
- TenantReview detail
|
||||
- EditTenant
|
||||
- **Workflow-heavy special-type exception**:
|
||||
- Tenant detail (tenant admin resource view)
|
||||
- **Minor alignment only**:
|
||||
- ProviderConnection detail
|
||||
- Finding detail
|
||||
- **Compliant / no-op reference**:
|
||||
- BaselineSnapshot detail
|
||||
- BackupSet detail
|
||||
- ReviewPack detail
|
||||
- AlertDestination detail
|
||||
- PolicyVersion detail
|
||||
- Workspace resource detail
|
||||
|
||||
## Target Outcomes by Key Surface
|
||||
|
||||
- **BaselineProfile detail**: The header presents one obvious next step. Snapshot and matrix navigation stop competing with compare or capture. Secondary compare variants and edit move into grouped secondary placement.
|
||||
- **EvidenceSnapshot detail**: One next action stays visible. Run and review-pack navigation move to contextual placement outside the header. `Expire snapshot` remains clearly separated as lifecycle danger.
|
||||
- **FindingException detail**: Governance lifecycle stops competing with related navigation. `Renew exception` and `Revoke exception` are no longer presented as flat equals.
|
||||
- **TenantReview detail**: Review lifecycle becomes easier to scan. The page distinguishes viewing related outputs from refreshing, publishing, exporting, or archiving.
|
||||
- **EditTenant**: The page reads as an edit surface first. View/context links move to contextual placement outside the header, and archive or restore stops competing with the edit task.
|
||||
- **Tenant detail (tenant admin resource view)**: The page remains a workflow hub, but grouped header actions gain an explicit internal order separating external links, verification, setup, and lifecycle, while pure navigation moves into contextual placement outside the header.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Creating a new global action framework for every surface class
|
||||
- Cleaning up monitoring, queue, or workbench surfaces
|
||||
- Hardening confirmation-depth, reason-capture, or danger-vocabulary policy
|
||||
- Changing dispatch, preflight, provider-start, or other backend operation semantics
|
||||
- Extending Spec 133 body composition to every view page by default
|
||||
- Cosmetic flattening of already-clean pages for the sake of uniformity alone
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Spec 133 remains a body-composition reference only and is not the rollout vehicle for this feature.
|
||||
- Existing pages such as BaselineSnapshot detail and BackupSet detail remain valid internal reference patterns for calm record-page headers.
|
||||
- Existing server-side authorization, audit, and run-observability behavior is already correct for the underlying actions and will be preserved as actions move.
|
||||
- The tenant admin resource view is the only currently known page in this scope that deserves an explicit workflow-heavy exception rather than a standard-record cleanup.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- HDR-001 in the constitution as the source rule for header action discipline
|
||||
- Existing related-navigation and contextual-link helpers already used by several detail pages
|
||||
- Existing Filament header action surfaces and `ActionGroup` patterns
|
||||
- Existing browser and regression testing infrastructure for Filament surfaces
|
||||
|
||||
## Risks
|
||||
|
||||
- The cleanup could drift into a broader action-framework project if it is not kept strictly to classic record/detail/edit surfaces.
|
||||
- A grouped secondary menu could become a junk drawer if internal order is not treated as part of the contract.
|
||||
- Workflow-heavy pages could be flattened incorrectly if the exception rule is not explicit and narrow.
|
||||
- Cosmetic overreach could create churn on already-compliant pages and dilute the value of the cleanup.
|
||||
|
||||
## Review Questions
|
||||
|
||||
- Does every remediated standard page now make the next likely step obvious?
|
||||
- Has pure navigation moved closer to the content it belongs to instead of living in the primary header lane?
|
||||
- Are rare and dangerous actions calmer without becoming hard to find?
|
||||
- Is the tenant admin resource view clearly documented as a special type rather than a silent inconsistency?
|
||||
- Have compliant reference pages been preserved instead of cosmetically rebuilt?
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| BaselineProfile detail | `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | `Capture baseline` or `Compare now` becomes the sole visible primary; `View snapshot` and `Open compare matrix` move to contextual placement outside the header, while `Compare assigned tenants` and `Edit` become grouped secondary actions | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Flat multi-button header is removed; contextual navigation leaves the primary lane | n/a | Existing compare and capture run/audit behavior unchanged | Remediation-required standard record page |
|
||||
| EvidenceSnapshot detail | `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` | One visible primary action only; `Open operation` and `View review pack` move to contextual placement outside the header; `Expire snapshot` remains separated danger | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Header becomes `one next step + contextual links + separated danger` | n/a | Existing evidence refresh and expire behavior unchanged | Remediation-required standard record page |
|
||||
| FindingException detail | `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php` | `Renew exception` may be primary; `Open finding` and `Open approval queue` move to contextual placement outside the header; `Revoke exception` remains separated danger | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Navigation and lifecycle actions are no longer flat peers | n/a | Existing exception-service audit behavior unchanged | Remediation-required standard record page |
|
||||
| TenantReview detail | `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` | One of `Refresh review`, `Publish review`, or `Export executive pack` may be primary depending on state; `Open operation`, `View executive pack`, and `View evidence snapshot` move to contextual placement outside the header; `More` retains infrequent actions with `Archive review` separated as danger | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Flat peer row of navigation plus lifecycle actions is removed | n/a | Existing review lifecycle and export behavior unchanged | Remediation-required standard record page |
|
||||
| EditTenant | `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php` | Header no longer carries `View` or related onboarding navigation; those move into contextual tenant-meta placement outside the header, while lifecycle actions remain grouped or separated and do not compete with the edit task | Existing resource edit entry unchanged | Unchanged by this spec | none | unchanged | Header no longer competes with form submission | Save and cancel remain the primary edit affordance | Existing tenant lifecycle audit behavior unchanged | Remediation-required edit surface |
|
||||
| Tenant detail (tenant admin resource view) | `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php` | Existing grouped `Actions` menu stays for external links, verification, setup, and lifecycle work, while pure navigation moves to contextual placement outside the header; `Verify configuration` may be lifted only as a sole visible primary if clearly justified | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No flat multi-button row is introduced to fake standardization | n/a | Existing tenant verification, RBAC refresh, and lifecycle audit behavior unchanged | Explicit workflow-heavy special-type exception |
|
||||
| ProviderConnection detail | `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php` | Audit whether `Grant admin consent` and `Edit` should remain peers; dedicated override actions stay grouped under one managed secondary structure | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Only minor cleanup if the header still reads as two competing primaries | n/a | Existing dedicated-override audit behavior unchanged | Minor alignment only |
|
||||
| Finding detail | `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php` | `Back to origin`, `Open related record`, and `Open approval queue` remain contextual outside the header; workflow actions stay inside the existing governed group | Existing resource inspect flow unchanged | Unchanged by this spec | Existing list behavior unchanged | unchanged | Only adjust if flat navigation still competes with the workflow group | n/a | Existing workflow audit behavior unchanged | Minor alignment only |
|
||||
| ReviewPack detail | `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php` | `Download` remains the clear primary and `Regenerate` remains secondary | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Existing review-pack generation behavior unchanged | Compliant / no-op reference |
|
||||
| AlertDestination detail | `apps/platform/app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php` | `Send test message` stays primary and `View last delivery` stays secondary | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Existing alert-test behavior unchanged | Compliant / no-op reference |
|
||||
| PolicyVersion detail | `apps/platform/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php` | Existing calm related-record access remains contextual and needs no expansion in this feature | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Read-only surface; no new audit behavior | Compliant / no-op reference |
|
||||
| Workspace resource detail | `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php` | Single `Edit` action remains acceptable | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Existing workspace-manage behavior unchanged | Compliant / no-op reference |
|
||||
| BaselineSnapshot detail | `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` | Existing calm related-record access remains contextual and needs no expansion in this feature | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Read-only surface; no new audit behavior | Compliant / no-op reference |
|
||||
| BackupSet detail | `apps/platform/app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php` | Existing `More` structure with grouped restore and delete lifecycle actions remains acceptable | Existing resource inspect flow unchanged | Unchanged by this spec | Existing list behavior unchanged | unchanged | No structural header change expected unless grouped ordering needs small tightening | n/a | Existing restore/delete audit behavior unchanged | Compliant / no-op reference |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Record Header Discipline**: The cross-page contract for standard record/detail/edit pages: one visible primary action, contextual navigation near content, grouped secondary actions, and separated danger.
|
||||
- **Surface Classification Matrix**: The explicit catalog assigning each in-scope surface to remediation required, minor alignment only, compliant/no-op reference, or workflow-heavy special-type exception.
|
||||
- **Special-Type Exception**: The explicit allowance for a workflow-heavy record page to stay grouped and internally ordered without pretending to be a standard single-next-step detail page.
|
||||
- **Contextual Navigation Slot**: The related-context placement that keeps navigation near the summary or section it belongs to instead of in a flat primary header row.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-192-001**: In acceptance review and smoke coverage, 100% of remediation-required standard record/detail/edit pages show no more than one visible primary header action.
|
||||
- **SC-192-002**: 100% of in-scope surfaces are explicitly classified in the spec and implementation notes, and no affected page remains an undocumented exception.
|
||||
- **SC-192-003**: Every remediated standard page relocates at least one pure navigation action out of the flat primary header lane into contextual placement outside the header.
|
||||
- **SC-192-004**: During acceptance walkthroughs, reviewers can identify the next likely step on each remediated standard page within 5 seconds without scanning more than one header action cluster.
|
||||
- **SC-192-005**: No compliant or no-op reference page receives a structural header rebuild unless a documented minor-alignment finding exists, and smoke coverage confirms no regression on that reference set.
|
||||
- **SC-192-006**: The tenant admin resource view remains explicitly marked as a workflow-heavy special type and passes review without reverting to a flat peer-button header row.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
This feature is complete when:
|
||||
|
||||
- every in-scope record/detail/edit surface is classified explicitly,
|
||||
- every remediated standard page shows at most one visible primary header action,
|
||||
- contextual navigation has left the flat primary header lane on remediated standard pages,
|
||||
- rare and administrative actions are grouped deliberately rather than left as peer buttons,
|
||||
- destructive or governance-sensitive actions remain structurally separated,
|
||||
- the tenant admin resource view is explicitly documented and treated as a workflow-heavy special type,
|
||||
- compliant and no-op reference pages are preserved rather than cosmetically rebuilt,
|
||||
- a lightweight regression guard exists for future record-page header changes,
|
||||
- and browser smoke checks confirm the visible hierarchy on the remediated pages.
|
||||
|
||||
## Recommended Sequencing
|
||||
|
||||
- Spec 193 should handle monitoring and workbench action hierarchy, where different surface rules are needed.
|
||||
- Spec 194 should handle governance-friction hardening, including confirmation depth, reason capture, and danger-language policy.
|
||||
237
specs/192-record-header-discipline/tasks.md
Normal file
237
specs/192-record-header-discipline/tasks.md
Normal file
@ -0,0 +1,237 @@
|
||||
# Tasks: Record Page Header Discipline & Contextual Navigation
|
||||
|
||||
**Input**: Design documents from `/specs/192-record-header-discipline/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/record-header-discipline.logical.openapi.yaml`
|
||||
|
||||
**Tests**: Tests are REQUIRED. Extend the existing guard layer, focused Pest feature coverage, and browser smoke coverage for the affected Filament pages.
|
||||
**Operations**: This feature reuses existing action semantics only. No new `OperationRun` type, summary-count contract, or notification channel should be introduced.
|
||||
**RBAC**: Existing workspace and tenant authorization semantics remain authoritative. Tasks must preserve non-member `404`, member-without-capability `403`, central capability registry usage, and per-action `UiEnforcement`.
|
||||
**Filament v5 / Livewire v4**: All touched surfaces remain inside the existing Filament v5 + Livewire v4 stack.
|
||||
**Provider Registration**: No panel or provider changes are planned; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
||||
**Global Search**: Touched resources already have their current View/Edit coverage and search settings; no new globally searchable resource is introduced.
|
||||
**Destructive Actions**: Existing destructive or governance-changing actions must remain `->requiresConfirmation()` and authorization-gated after regrouping.
|
||||
**Asset Strategy**: No new asset registration is planned; existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently, while acknowledging that some stories touch the same page files and are therefore safer to land sequentially.
|
||||
|
||||
## Phase 1: Setup (Acceptance Seams)
|
||||
|
||||
**Purpose**: Create the focused guard, feature, and browser test entry points used by the implementation work.
|
||||
|
||||
- [X] T001 Create the Spec 192 guard entry point in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php`
|
||||
- [X] T002 [P] Create the focused page-test entry points in `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php`
|
||||
- [X] T003 [P] Create the browser smoke entry point and compliant-reference baseline cases in `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||
|
||||
**Checkpoint**: Focused verification entry points exist for guard, page-level, and browser-level work.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Regression Contract)
|
||||
|
||||
**Purpose**: Encode the surface inventory, classification, and explicit exception model before touching page behavior.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T004 Encode the Spec 192 surface inventory, classifications, and explicit `ViewTenant` exception metadata in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
|
||||
- [X] T005 [P] Extend the record-page header-discipline validation rules for standard pages and the workflow-heavy special type in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||
- [X] T006 [P] Add foundational guard assertions for classified surfaces, explicit exceptions, and compliant references in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`, and `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php`
|
||||
|
||||
**Checkpoint**: The repo can fail CI when a standard record page regresses into multiple competing primaries or when a special-type exception is left undocumented.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - See One Next Step On Standard Record Pages (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Standard record/detail/edit pages expose one clear visible next step instead of a flat row of competing peer actions.
|
||||
|
||||
**Independent Test**: Open each remediation-required standard page and verify that it renders at most one visible primary header action while preserving existing authorization and action semantics.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T007 [P] [US1] Extend state-sensitive primary-action assertions for baseline profile detail in `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php` and `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
|
||||
- [X] T008 [P] [US1] Add one-primary-action assertions for evidence snapshot and finding exception detail in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` and `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`
|
||||
- [X] T009 [P] [US1] Add one-primary-action assertions for tenant review and tenant edit surfaces in `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T010 [US1] Refactor state-sensitive primary-action selection and grouped secondaries in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
|
||||
- [X] T011 [US1] Refactor evidence snapshot header hierarchy so only one visible next-step action remains in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||
- [X] T012 [US1] Refactor finding exception header hierarchy so renewal can be primary and revocation stays separated in `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
|
||||
- [X] T013 [US1] Refactor tenant review header hierarchy to promote only one lifecycle primary action in `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||
- [X] T014 [US1] Refactor the tenant edit header so it stops competing with the form primary affordance in `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
|
||||
- [X] T015 [US1] Run focused primary-action verification in `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php`
|
||||
|
||||
**Checkpoint**: The five remediation-required standard pages expose no more than one visible primary header action.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Follow Contextual Navigation Near The Relevant Content (Priority: P1)
|
||||
|
||||
**Goal**: Pure navigation leaves the flat primary header lane and appears nearer to the summary, related context, or grouped secondary actions it belongs to.
|
||||
|
||||
**Independent Test**: Open remediated pages with related destinations and verify that navigation is no longer presented as an equal-weight primary peer to the main mutation.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T016 [P] [US2] Add contextual-navigation assertions for baseline profile and evidence snapshot detail in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||
- [X] T017 [P] [US2] Add contextual-navigation assertions for finding exception, tenant review, and tenant edit surfaces in `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T018 [US2] Move active snapshot and compare-matrix navigation into contextual baseline-profile sections in `apps/platform/app/Filament/Resources/BaselineProfileResource.php` and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
|
||||
- [X] T019 [US2] Move operation and review-pack navigation into evidence snapshot summary or related-context sections in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` and `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||
- [X] T020 [US2] Move finding and approval-queue navigation into finding-exception related-context placement in `apps/platform/app/Filament/Resources/FindingExceptionResource.php` and `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
|
||||
- [X] T021 [US2] Move operation, executive-pack, and evidence navigation into tenant-review contextual summary surfaces in `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, and `apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php`
|
||||
- [X] T022 [US2] Move tenant edit view and onboarding links into contextual tenant-meta placement outside the header in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
|
||||
- [X] T023 [US2] Run focused contextual-navigation verification in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||
|
||||
**Checkpoint**: Related navigation has left the flat primary header lane on the remediated standard pages.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Keep Rare And Dangerous Actions Available Without Clutter (Priority: P2)
|
||||
|
||||
**Goal**: Rare administrative actions and destructive or governance-sensitive actions remain available without visually competing with routine work.
|
||||
|
||||
**Independent Test**: Open pages with grouped secondaries or danger actions and verify that rare actions live in grouped structures while danger stays visibly separated and confirmation-gated.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T024 [P] [US3] Extend grouped-secondary and danger-separation assertions for baseline profile, tenant review, and tenant edit in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`, and `apps/platform/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`
|
||||
- [X] T025 [P] [US3] Add grouped-secondary and danger assertions for evidence snapshot, finding exception, provider connection, and finding detail in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php`, `apps/platform/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php`, `apps/platform/tests/Feature/Filament/FindingViewRbacEvidenceTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T026 [US3] Group non-primary baseline profile actions into deliberate secondary and admin buckets in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
|
||||
- [X] T027 [US3] Separate lifecycle danger from safe secondaries on evidence snapshot and finding exception detail in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` and `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
|
||||
- [X] T028 [US3] Group tenant review lifecycle and export actions while keeping archive in a danger bucket in `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||
- [X] T029 [US3] Keep tenant edit lifecycle actions secondary and aligned with tenant lifecycle naming in `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php` and `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||
- [X] T030 [US3] Audit provider connection and finding detail headers for real minor-alignment issues in `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php` and `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, and only apply cleanup if the audit proves current header noise
|
||||
- [X] T031 [US3] Run focused grouped-secondary and danger verification in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`, `apps/platform/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php`, `apps/platform/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php`, `apps/platform/tests/Feature/Filament/FindingViewRbacEvidenceTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||
|
||||
**Checkpoint**: Rare and dangerous actions remain available, but no longer clutter standard record-page headers.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Treat Workflow-Heavy Pages As Explicit Exceptions (Priority: P3)
|
||||
|
||||
**Goal**: Workflow-heavy pages remain disciplined through explicit exception handling instead of silent non-conformance.
|
||||
|
||||
**Independent Test**: Review `ViewTenant` and verify that it is explicitly marked as a special type, internally ordered, and not silently exempted from the record-page rule.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T032 [P] [US4] Extend workflow-heavy exception assertions for tenant detail in `apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php`, `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||
- [X] T033 [P] [US4] Extend guard assertions so `ViewTenant` requires an explicit special-type reason in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T034 [US4] Move pure tenant-detail navigation into contextual placement outside the header and reorder grouped header actions into explicit external-link, verification/setup, and lifecycle buckets in `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
|
||||
- [X] T035 [US4] Encode the workflow-heavy special-type reason and max-primary-action rule for tenant detail in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php` and `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||
- [X] T036 [US4] Run focused special-type verification in `apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php`, `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||
|
||||
**Checkpoint**: The workflow-heavy exception is explicit, tested, and structurally disciplined.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Confirm compliant references, align operator copy, and run the focused verification pack.
|
||||
|
||||
- [X] T037 [P] Audit and explicitly confirm compliant reference pages remain no-op in `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`, `apps/platform/app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php`, `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, `apps/platform/app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php`, `apps/platform/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php`, and `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php`
|
||||
- [X] T038 [P] Align `Verb + Object` labels and grouped-action copy in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`, `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`, `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, and `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||
- [X] T039 [P] Add or update compliant-reference and no-regression assertions in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||
- [X] T040 [P] Extend `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php` with no-regression coverage for the compliant reference set named in Spec 192
|
||||
- [X] T041 [P] Add explicit no-body-layout-expansion assertions for the remediated and reference views in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php` and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||
- [X] T042 [P] Add explicit no-governance-friction-expansion assertions that regrouping preserves confirmation depth and action semantics in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, and `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`
|
||||
- [X] T043 [P] Run the focused verification commands documented in `specs/192-record-header-discipline/quickstart.md`
|
||||
- [X] T044 [P] Run formatting on touched Filament page and guard files from `apps/platform/` via `./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user-story work.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the MVP slice.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion. It is independently testable, but it touches several of the same page files as US1, so it is safer to land after US1 unless work is split carefully.
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion. It reuses the same page files as US1 and US2, so it is also safest after the primary and contextual hierarchy work is stable.
|
||||
- **User Story 4 (Phase 6)**: Depends on Foundational completion and should land after the standard-page rule is clear, because it documents the explicit exception to that rule.
|
||||
- **Polish (Phase 7)**: Depends on all desired story phases being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: Independent after Phase 2 and should be delivered first as the MVP.
|
||||
- **US2**: Independent after Phase 2 from a behavior standpoint, but shares files with US1 and should usually follow it in the same branch.
|
||||
- **US3**: Independent after Phase 2 from a behavior standpoint, but depends on the stabilized header hierarchy from US1 and US2 for low-risk implementation.
|
||||
- **US4**: Independent after Phase 2 and focused on the explicit special-type exception for tenant detail.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Story-level tests should be written and made to fail before the implementation tasks for that story.
|
||||
- Page-level behavior changes should preserve current authorization, confirmation, notification, and audit semantics.
|
||||
- Each story should be verified through its focused test files before moving on.
|
||||
|
||||
## Parallel Opportunities
|
||||
|
||||
- T002 and T003 can run in parallel during Setup.
|
||||
- T005 and T006 can run in parallel once the inventory in T004 exists.
|
||||
- Within each story, the test tasks marked `[P]` can run in parallel because they touch separate files.
|
||||
- US1, US2, and US3 share several of the same page classes, so their implementation tasks are not good parallel candidates even though the stories are independently testable.
|
||||
- US4 can proceed in parallel with late US3 polish if one contributor is focused only on `ViewTenant`, the guard layer, and the smoke suite.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch the focused header-hierarchy tests together:
|
||||
Task: "Extend state-sensitive primary-action assertions for baseline profile detail in apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php and apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php"
|
||||
Task: "Add one-primary-action assertions for evidence snapshot and finding exception detail in apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php and apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php"
|
||||
Task: "Add one-primary-action assertions for tenant review and tenant edit surfaces in apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php, apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php, and apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 4
|
||||
|
||||
```bash
|
||||
# Split the explicit exception work across tests and implementation:
|
||||
Task: "Extend workflow-heavy exception assertions for tenant detail in apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php, apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php, and apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php"
|
||||
Task: "Move pure tenant-detail navigation into contextual placement outside the header and reorder grouped header actions into explicit external-link, verification/setup, and lifecycle buckets in apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. **STOP and VALIDATE**: Confirm the five remediation-required standard pages expose one clear next step.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Deliver US1 to remove competing primary actions.
|
||||
2. Deliver US2 to move navigation closer to the content it belongs to.
|
||||
3. Deliver US3 to calm rare and dangerous actions without hiding them.
|
||||
4. Deliver US4 to make the workflow-heavy tenant detail exception explicit and disciplined.
|
||||
5. Finish with compliant-reference confirmation, vocabulary cleanup, validation, and formatting.
|
||||
|
||||
### Validation Rule
|
||||
|
||||
1. Do not mark a story complete until its focused verification task passes.
|
||||
2. Preserve existing `404` vs `403` behavior, confirmation requirements, and `OperationRun` semantics throughout implementation.
|
||||
3. Treat the compliant-reference set as a regression baseline, not as a cosmetic rewrite target.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks touch different files and can be executed in parallel.
|
||||
- User-story labels map directly to the prioritized stories in `spec.md`.
|
||||
- This feature deliberately prefers existing Filament action builders and guard infrastructure over introducing a new header-action framework.
|
||||
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Monitoring Surface Action Hierarchy and Workbench Semantics
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-11
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation completed in one pass.
|
||||
- No clarification markers remain in the specification.
|
||||
- Required operator-surface contract and UI action matrix sections are present and bounded to action hierarchy semantics rather than implementation design.
|
||||
@ -0,0 +1,318 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Monitoring Surface Action Hierarchy Internal Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for Spec 193 monitoring and workbench surface hierarchy
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 193. The affected
|
||||
surfaces continue to render HTML through Filament and Livewire. The schemas
|
||||
below define the bounded render contract and regression expectations for
|
||||
monitoring/workbench action layers, selection-aware prominence, calm
|
||||
bounded-scope references, and the explicit diagnostic exception.
|
||||
servers:
|
||||
- url: /internal
|
||||
x-monitoring-action-hierarchy-consumers:
|
||||
- surface: remediation-required-workbench-pages
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
|
||||
- apps/platform/app/Filament/Pages/Monitoring/Operations.php
|
||||
- apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
mustRender:
|
||||
- explicit_action_layers
|
||||
- quiet_scope_and_navigation_layers
|
||||
- selection_or_focus_actions_only_when_active
|
||||
- no_selection_quiet_state_when_applicable
|
||||
mustNotRender:
|
||||
- flat_scope_navigation_selection_strip
|
||||
- scope_as_peer_cta
|
||||
- mixed_global_and_selection_actions_in_one_lane
|
||||
- surface: shared-pattern-audit-pages
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/Monitoring/Alerts.php
|
||||
- apps/platform/app/Filament/Pages/Monitoring/AuditLog.php
|
||||
- apps/platform/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php
|
||||
mustRender:
|
||||
- explicit_inventory_classification
|
||||
- quiet_operate_hub_scope_usage
|
||||
mustNotRender:
|
||||
- undocumented_exemption
|
||||
- surface: calm-reference-pages
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php
|
||||
- apps/platform/app/Filament/Pages/BaselineCompareLanding.php
|
||||
- apps/platform/app/Filament/Pages/BaselineCompareMatrix.php
|
||||
- apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php
|
||||
mustRender:
|
||||
- bounded_scope_semantics
|
||||
- no_forced_extra_layers
|
||||
mustNotRender:
|
||||
- cosmetic_normalization_without_finding
|
||||
- surface: special-type-exception
|
||||
sourceFiles:
|
||||
- apps/platform/app/Filament/Pages/TenantDiagnostics.php
|
||||
mustRender:
|
||||
- explicit_exception_reason
|
||||
- repair_actions_only_when_defect_exists
|
||||
mustNotRender:
|
||||
- silent_exception
|
||||
- surface: regression-guards
|
||||
sourceFiles:
|
||||
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php
|
||||
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
|
||||
- apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
- apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php
|
||||
paths:
|
||||
/internal/action-surfaces/monitoring/{surface}:
|
||||
get:
|
||||
summary: Return the logical action-layer contract for an in-scope monitoring surface
|
||||
operationId: getMonitoringSurfaceActionHierarchyContract
|
||||
parameters:
|
||||
- name: surface
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/SurfaceKey'
|
||||
responses:
|
||||
'200':
|
||||
description: Logical render contract and regression expectations for the requested surface
|
||||
content:
|
||||
application/vnd.tenantpilot.monitoring-action-hierarchy+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MonitoringSurfaceContract'
|
||||
'404':
|
||||
description: Requested surface is not in the Spec 193 inventory
|
||||
components:
|
||||
schemas:
|
||||
SurfaceKey:
|
||||
type: string
|
||||
enum:
|
||||
- finding_exceptions_queue
|
||||
- tenantless_operation_run_viewer
|
||||
- operations
|
||||
- alerts
|
||||
- audit_log
|
||||
- alert_deliveries
|
||||
- evidence_overview
|
||||
- baseline_compare_matrix
|
||||
- baseline_compare_landing
|
||||
- review_register
|
||||
- tenant_diagnostics
|
||||
SurfaceClassification:
|
||||
type: string
|
||||
enum:
|
||||
- remediation_required
|
||||
- minor_alignment_only
|
||||
- compliant_no_op
|
||||
- special_type_acceptable
|
||||
SurfaceKind:
|
||||
type: string
|
||||
enum:
|
||||
- queue_workbench
|
||||
- monitoring_detail
|
||||
- monitoring_landing
|
||||
- read_only_report
|
||||
- diagnostic_exception
|
||||
ActionLayer:
|
||||
type: string
|
||||
enum:
|
||||
- scope_context
|
||||
- navigation
|
||||
- surface_utility
|
||||
- selection_focused
|
||||
- related_drilldown
|
||||
SurfaceState:
|
||||
type: string
|
||||
enum:
|
||||
- no_selection_monitoring
|
||||
- focused_selection
|
||||
- global_monitoring
|
||||
- related_drilldown
|
||||
- diagnostic_exception
|
||||
ActionKind:
|
||||
type: string
|
||||
enum:
|
||||
- context
|
||||
- navigation
|
||||
- utility
|
||||
- mutation
|
||||
- drilldown
|
||||
- repair
|
||||
- governance
|
||||
MutationScope:
|
||||
type: string
|
||||
enum:
|
||||
- TenantPilot only
|
||||
- Microsoft tenant
|
||||
- simulation only
|
||||
- read-only
|
||||
ScopeSignal:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- label
|
||||
- source
|
||||
- isContextOnly
|
||||
- changesSurfaceScope
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- OperateHubShell
|
||||
- CanonicalNavigationContext
|
||||
- tenant_route
|
||||
- local_filter_state
|
||||
isContextOnly:
|
||||
type: boolean
|
||||
changesSurfaceScope:
|
||||
type: boolean
|
||||
leaksScopeIfMisplaced:
|
||||
type: boolean
|
||||
MonitoringSurfaceAction:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- actionKey
|
||||
- label
|
||||
- actionKind
|
||||
- layer
|
||||
- visibleInStates
|
||||
- requiresConfirmation
|
||||
- usesUiEnforcement
|
||||
- mutationScope
|
||||
properties:
|
||||
actionKey:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
actionKind:
|
||||
$ref: '#/components/schemas/ActionKind'
|
||||
layer:
|
||||
$ref: '#/components/schemas/ActionLayer'
|
||||
visibleInStates:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SurfaceState'
|
||||
requiresConfirmation:
|
||||
type: boolean
|
||||
usesUiEnforcement:
|
||||
type: boolean
|
||||
capabilityKey:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
writesAuditLog:
|
||||
type: boolean
|
||||
mutationScope:
|
||||
$ref: '#/components/schemas/MutationScope'
|
||||
MonitoringLayerContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- layer
|
||||
- isPresent
|
||||
- isPrimaryWorkLayer
|
||||
- visibilityRule
|
||||
properties:
|
||||
layer:
|
||||
$ref: '#/components/schemas/ActionLayer'
|
||||
isPresent:
|
||||
type: boolean
|
||||
isPrimaryWorkLayer:
|
||||
type: boolean
|
||||
mustRemainQuiet:
|
||||
type: boolean
|
||||
visibilityRule:
|
||||
type: string
|
||||
StateContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- stateKey
|
||||
- dominantQuestion
|
||||
- prominentActionKeys
|
||||
- allowsNoProminentAction
|
||||
properties:
|
||||
stateKey:
|
||||
$ref: '#/components/schemas/SurfaceState'
|
||||
dominantQuestion:
|
||||
type: string
|
||||
prominentActionKeys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
quietLayerKeys:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ActionLayer'
|
||||
allowsNoProminentAction:
|
||||
type: boolean
|
||||
MonitoringSurfaceRegressionExpectation:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- forbidsScopeAsPeerCta
|
||||
- forbidsFlatGlobalSelectionMix
|
||||
- requiresExplicitExceptionReason
|
||||
- browserSmokeRequired
|
||||
properties:
|
||||
forbidsScopeAsPeerCta:
|
||||
type: boolean
|
||||
forbidsFlatGlobalSelectionMix:
|
||||
type: boolean
|
||||
requiresNoSelectionQuietState:
|
||||
type: boolean
|
||||
requiresExplicitExceptionReason:
|
||||
type: boolean
|
||||
allowsMinorAlignmentOnly:
|
||||
type: boolean
|
||||
browserSmokeRequired:
|
||||
type: boolean
|
||||
MonitoringSurfaceContract:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- surfaceKey
|
||||
- surfaceKind
|
||||
- classification
|
||||
- canonicalNoun
|
||||
- primaryQuestion
|
||||
- scopeSignals
|
||||
- layers
|
||||
- actions
|
||||
- states
|
||||
- regressionExpectation
|
||||
properties:
|
||||
surfaceKey:
|
||||
$ref: '#/components/schemas/SurfaceKey'
|
||||
surfaceKind:
|
||||
$ref: '#/components/schemas/SurfaceKind'
|
||||
classification:
|
||||
$ref: '#/components/schemas/SurfaceClassification'
|
||||
canonicalNoun:
|
||||
type: string
|
||||
primaryQuestion:
|
||||
type: string
|
||||
scopeSignals:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ScopeSignal'
|
||||
layers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MonitoringLayerContract'
|
||||
actions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MonitoringSurfaceAction'
|
||||
states:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StateContract'
|
||||
explicitExceptionReason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
regressionExpectation:
|
||||
$ref: '#/components/schemas/MonitoringSurfaceRegressionExpectation'
|
||||
158
specs/193-monitoring-action-hierarchy/data-model.md
Normal file
158
specs/193-monitoring-action-hierarchy/data-model.md
Normal file
@ -0,0 +1,158 @@
|
||||
# Data Model: Monitoring Surface Action Hierarchy and Workbench Semantics
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new persisted entity, table, enum, or long-lived artifact. It reuses existing Filament pages, existing action definitions, existing authorization helpers, and existing run or audit truth, while adding a derived planning model for how monitoring and workbench surfaces are inventoried, layered, and regression-tested.
|
||||
|
||||
## Existing Source Truths Reused Without Change
|
||||
|
||||
The following truths remain authoritative and are not redefined by this feature:
|
||||
|
||||
- existing page and resource routes
|
||||
- existing model ownership and scope semantics
|
||||
- existing capability checks and `UiEnforcement` behavior
|
||||
- existing confirmation, audit, and `OperationRun` behavior for underlying actions
|
||||
- existing `OperateHubShell`, `CanonicalNavigationContext`, and tenant-filter state behavior
|
||||
- existing page-local visibility rules for selected-object actions and run follow-up behavior
|
||||
|
||||
This feature changes action hierarchy and placement only.
|
||||
|
||||
## New Derived Planning Models
|
||||
|
||||
### MonitoringSurfaceInventoryEntry
|
||||
|
||||
**Type**: spec and guard inventory entry
|
||||
**Source**: explicit Spec 193 classification matrix + action-surface regression guard
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Stable identifier such as `finding_exceptions_queue` or `tenantless_operation_run_viewer` |
|
||||
| `pageClass` | string | Concrete Filament page or resource page class under review |
|
||||
| `panelScope` | string | `admin` or `tenant` |
|
||||
| `ownerScope` | string | `workspace-owned`, `workspace-visible-tenant-owned`, or `tenant-owned` |
|
||||
| `surfaceKind` | string | `queue_workbench`, `monitoring_detail`, `monitoring_landing`, `read_only_report`, or `diagnostic_exception` |
|
||||
| `classification` | string | `remediation_required`, `minor_alignment_only`, `compliant_no_op`, or `special_type_acceptable` |
|
||||
| `sharedPattern` | string or null | e.g. `OperateHubShell`, `cluster_entry`, or `none` |
|
||||
| `requiresHeaderRemediation` | boolean | Whether the surface must change under Spec 193 |
|
||||
| `requiresExplicitDeclaration` | boolean | Whether the page must carry an explicit `actionSurfaceDeclaration()` |
|
||||
| `exceptionReason` | string or null | Required only for the special-type exception |
|
||||
| `browserSmokeRequired` | boolean | Whether browser smoke must cover the surface |
|
||||
|
||||
### ActionLayerDescriptor
|
||||
|
||||
**Type**: derived page render contract
|
||||
**Source**: existing page action methods + explicit Spec 193 rules
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | Links the layer state back to the inventory entry |
|
||||
| `layerKey` | string | `scope_context`, `navigation`, `surface_utility`, `selection_focused`, or `related_drilldown` |
|
||||
| `isPresent` | boolean | Whether the layer exists on this surface |
|
||||
| `isPrimaryWorkLayer` | boolean | True when the layer represents the current next-action lane |
|
||||
| `mustRemainQuiet` | boolean | True for scope and navigation layers when work actions exist |
|
||||
| `visibilityRule` | string | Human-readable rule for when the layer is shown or emphasized |
|
||||
|
||||
### MonitoringSurfaceActionDescriptor
|
||||
|
||||
**Type**: derived action classification entry
|
||||
**Source**: existing Filament action definitions on the target page
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `actionKey` | string | Action name such as `approve_selected_exception` or `refresh` |
|
||||
| `label` | string | Visible operator-facing label |
|
||||
| `actionKind` | string | `context`, `navigation`, `utility`, `mutation`, `drilldown`, `repair`, or `governance` |
|
||||
| `layer` | string | One of the Spec 193 layers |
|
||||
| `visibleInStates` | array<string> | Surface states where the action may be visible |
|
||||
| `requiresConfirmation` | boolean | Mirrors existing confirmation behavior |
|
||||
| `usesUiEnforcement` | boolean | Whether the action is wrapped with a central enforcement helper |
|
||||
| `capabilityKey` | string or null | Canonical capability requirement when applicable |
|
||||
| `writesAuditLog` | boolean | Whether the underlying mutation writes audit truth |
|
||||
| `mutationScope` | string | `TenantPilot only`, `Microsoft tenant`, `simulation only`, or `read-only` |
|
||||
|
||||
### WorkbenchStateContract
|
||||
|
||||
**Type**: derived work-state entry
|
||||
**Source**: explicit queue or viewer state rules in the spec
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | The workbench or monitoring surface |
|
||||
| `stateKey` | string | `no_selection_monitoring`, `focused_selection`, `global_monitoring`, `related_drilldown`, or `diagnostic_exception` |
|
||||
| `dominantQuestion` | string | The operator question the state must answer |
|
||||
| `prominentActionKeys` | array<string> | Actions allowed to read as the current next step |
|
||||
| `quietLayerKeys` | array<string> | Layers that must remain visible but subordinate |
|
||||
| `allowsNoProminentAction` | boolean | True for calm reference or exception states |
|
||||
|
||||
### ScopeSignalContract
|
||||
|
||||
**Type**: derived context entry
|
||||
**Source**: `OperateHubShell`, route-bound tenant context, and canonical navigation helpers
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | The surface that shows the scope signal |
|
||||
| `label` | string | Operator-facing scope label |
|
||||
| `source` | string | `OperateHubShell`, `CanonicalNavigationContext`, `tenant_route`, or `local_filter_state` |
|
||||
| `isContextOnly` | boolean | True when the signal must not read as a CTA |
|
||||
| `changesSurfaceScope` | boolean | True only when interacting with the signal resets or broadens scope |
|
||||
| `leaksScopeIfMisplaced` | boolean | True when wrong placement could imply broader access or actionability |
|
||||
|
||||
### MonitoringSurfaceRegressionExpectation
|
||||
|
||||
**Type**: guard and test expectation entry
|
||||
**Source**: Spec 193 regression-protection requirements
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------|------|-------|
|
||||
| `surfaceKey` | string | The page under regression protection |
|
||||
| `forbidsScopeAsPeerCta` | boolean | Scope must not read as a peer CTA |
|
||||
| `forbidsFlatGlobalSelectionMix` | boolean | Global and selected-object actions must not flatten into one lane |
|
||||
| `requiresNoSelectionQuietState` | boolean | Workbench pages must render a calm state when no object is selected |
|
||||
| `requiresExplicitExceptionReason` | boolean | True only for `TenantDiagnostics` |
|
||||
| `allowsMinorAlignmentOnly` | boolean | True for audit-only surfaces that should not be rebuilt without a specific finding |
|
||||
| `browserSmokeRequired` | boolean | Whether browser smoke must cover this surface |
|
||||
|
||||
## Resolution Rules
|
||||
|
||||
### Monitoring and workbench remediation rules
|
||||
|
||||
1. A remediation-required monitoring or workbench surface resolves actions into explicit layers rather than a single flat header strip.
|
||||
2. Scope and context signals resolve to `scope_context` and must remain visibly subordinate to live work actions.
|
||||
3. Back, return, show-all, and origin links resolve to `navigation`, not to the active work lane.
|
||||
4. Refresh, clear filters, and other page controls resolve to `surface_utility`.
|
||||
5. Selection-bound or focused-object actions resolve to `selection_focused` and may become prominent only in states where a valid selection exists.
|
||||
6. Drilldowns and related opens resolve to `related_drilldown`, not to the same peer level as scope or work actions.
|
||||
|
||||
### Work-state rules
|
||||
|
||||
- `finding_exceptions_queue` resolves to `no_selection_monitoring` when no exception is selected and to `focused_selection` when a pending exception is selected.
|
||||
- `tenantless_operation_run_viewer` resolves to `global_monitoring` plus optional `related_drilldown` or `focused follow-up` states depending on run context and resumable behavior.
|
||||
- `operations` resolves to `global_monitoring` even when tenant-prefiltered; scope reset remains utility, not primary work.
|
||||
|
||||
### Bounded-scope reference rules
|
||||
|
||||
1. A compliant or no-op surface may keep one narrow utility or drilldown affordance without being forced into extra layers.
|
||||
2. Reference surfaces must not be rebuilt only to mimic the remediated workbench pages.
|
||||
|
||||
### Special-type exception rules
|
||||
|
||||
1. `tenant_diagnostics` may expose repair actions only when the corresponding diagnostic defect exists.
|
||||
2. `tenant_diagnostics` must always carry an explicit exception reason in inventory and regression expectations.
|
||||
3. The exception does not create a general allowance for other monitoring pages to promote repair or mutation actions in the same way.
|
||||
|
||||
## Relationships
|
||||
|
||||
- One `MonitoringSurfaceInventoryEntry` maps to one or more `ActionLayerDescriptor` entries.
|
||||
- One `MonitoringSurfaceInventoryEntry` may contain many `MonitoringSurfaceActionDescriptor` entries.
|
||||
- A workbench or viewer surface may contain multiple `WorkbenchStateContract` entries.
|
||||
- Every surface may contain zero or many `ScopeSignalContract` entries.
|
||||
- Every in-scope surface must map to one `MonitoringSurfaceRegressionExpectation`.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- No derived model may widen tenant or workspace visibility beyond existing route and helper semantics.
|
||||
- No action may lose `UiEnforcement`, confirmation, audit, or `OperationRun` behavior when it changes layer.
|
||||
- No scope signal may be promoted into a peer CTA when it is informational only.
|
||||
- No selection-focused lane may remain prominent when the selected object is absent or no longer valid.
|
||||
- No exception may remain undocumented in the inventory and regression layer.
|
||||
302
specs/193-monitoring-action-hierarchy/plan.md
Normal file
302
specs/193-monitoring-action-hierarchy/plan.md
Normal file
@ -0,0 +1,302 @@
|
||||
# Implementation Plan: Monitoring Surface Action Hierarchy and Workbench Semantics
|
||||
|
||||
**Branch**: `193-monitoring-action-hierarchy` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/193-monitoring-action-hierarchy/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/193-monitoring-action-hierarchy/spec.md`
|
||||
|
||||
**Note**: This plan keeps the work inside the existing Filament v5 / Livewire v4 page layer, existing `OperateHubShell` and `CanonicalNavigationContext` scope helpers, and the current action-surface guard infrastructure. It explicitly avoids adding a new monitoring-action framework.
|
||||
|
||||
## Summary
|
||||
|
||||
Codify one bounded action-layer contract for monitoring, queue, operations, and workbench surfaces in the admin panel. Reuse existing Filament header actions, `ActionGroup`, `UiEnforcement`, `OperateHubShell`, `CanonicalAdminTenantFilterState`, and the existing `ActionSurfaceValidator` extension path to inventory all in-scope surfaces, remediate the three clearly problematic workbench pages, convert the alerts overview into an explicitly declared in-scope monitoring surface, preserve calm bounded-scope pages as references, document `TenantDiagnostics` as the only special-type exception, and extend guard plus browser regression layers so mixed monitoring headers do not return.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `OperateHubShell`, `CanonicalNavigationContext`, `CanonicalAdminTenantFilterState`, `UiEnforcement`, `ActionSurfaceValidator`, and Filament page or resource action builders
|
||||
**Storage**: PostgreSQL through existing workspace-owned and tenant-owned models; no schema change planned
|
||||
**Testing**: Pest feature tests, existing guard tests, and Pest browser smoke tests run through Laravel Sail
|
||||
**Target Platform**: Laravel monolith web application under `apps/platform`, with canonical workspace-context monitoring routes under `/admin`, tenant-context routes under `/admin/t/{tenant}/...`, and cluster-backed monitoring routes under `/admin/alerts`
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: Preserve the 5-second scan rule for monitoring surfaces, keep monitoring renders DB-only with no outbound HTTP or queued jobs at render time, avoid adding new polling beyond existing run-detail behavior, and avoid query churn when separating action layers
|
||||
**Constraints**: No new action framework, no new persistence, no route or panel changes, no authorization-plane changes, no new badge taxonomy, no silent surface exemptions, no record-page header rules copied directly onto workbench pages, and no expansion of confirmation depth, reason capture, or run semantics beyond existing actions
|
||||
**Scale/Scope**: 11 in-scope surfaces, 3 remediation-required core pages, 3 minor-alignment audits, 4 compliant or no-op bounded-scope references, 1 special-type exception, existing Blade views for monitoring pages, and focused guard plus feature plus browser regression coverage
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Inventory-first / snapshots-second | PASS | PASS | The feature does not alter inventory, snapshots, or backup truth. It only reorganizes monitoring surfaces. |
|
||||
| Read/write separation | PASS | PASS | Existing write actions such as approve, reject, and tenant repair keep their current confirmation, audit, and test behavior. No new writes are introduced. |
|
||||
| Graph contract path | N/A | N/A | No new Microsoft Graph call path or contract-registry change is planned. |
|
||||
| Deterministic capabilities | PASS | PASS | Capability checks remain in the canonical registries plus `UiEnforcement`; regrouping actions does not change entitlement logic. |
|
||||
| Workspace + tenant isolation | PASS | PASS | Existing route scopes, `OperateHubShell` resolution, and tenant-safe drilldown rules remain authoritative. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`, member-without-capability remains `403`, and server-side checks remain unchanged. |
|
||||
| Run observability / Ops-UX | PASS | PASS | Existing `OperationRun`-backed actions keep their current queued-toast, monitoring-detail, and terminal-status semantics. |
|
||||
| Data minimization | PASS | PASS | No new persistence, caches, or cross-surface helper artifacts are introduced. |
|
||||
| Proportionality / anti-bloat | PASS | PASS | The work extends the existing inventory and validation layers rather than introducing a new monitoring-action engine. |
|
||||
| UI semantics / few layers | PASS | PASS | The feature uses direct action placement and explicit classification, not a new presenter or interpretation layer. |
|
||||
| Filament-native UI | PASS | PASS | Native Filament actions, action groups, pages, tables, and shared context bars remain the implementation path. |
|
||||
| Surface taxonomy / monitoring-specific hierarchy | PASS | PASS | The plan explicitly distinguishes monitoring/workbench surfaces from Spec 192 record pages and documents the allowed exception. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched surfaces stay inside the existing Filament v5 + Livewire v4 stack. |
|
||||
| Provider registration location | PASS | PASS | No provider change is needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No new globally searchable resource is introduced and no resource search settings are changed. Existing searchable resources already have View/Edit pages where needed. |
|
||||
| Destructive action safety | PASS | PASS | Existing confirmed actions such as exception approval or rejection and tenant repair actions keep `->requiresConfirmation()` plus current authorization. |
|
||||
| Asset strategy | PASS | PASS | No new global or on-demand asset registration is needed. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged. |
|
||||
|
||||
## Filament-Specific Compliance Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: The plan remains on Filament v5 + Livewire v4 and introduces no legacy or mixed-version API usage.
|
||||
- **Provider registration location**: No panel or provider changes are required; Laravel 11+ panel providers remain registered in `bootstrap/providers.php`.
|
||||
- **Global search**: The feature does not add a new globally searchable resource and does not alter global-search visibility for existing resources. Touched monitoring pages remain page-level surfaces or reuse existing resource detail pages.
|
||||
- **Destructive actions**: `Approve exception`, `Reject exception`, `Bootstrap owner`, and `Merge duplicate memberships` remain routed through `Action::make(...)->action(...)` with `->requiresConfirmation()` plus existing authorization and audit semantics.
|
||||
- **Asset strategy**: No new global or lazy-loaded assets are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains sufficient.
|
||||
- **Testing plan**: Extend the existing action-surface guard layer, add focused Pest tests for the remediated and exception surfaces, and add a browser smoke suite that proves visible monitoring hierarchy on the remediated pages and no-regression behavior on reference pages.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/193-monitoring-action-hierarchy/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Reuse the existing `ActionSurfaceExemptions` plus `ActionSurfaceValidator` inventory pattern from Spec 192 instead of creating a new monitoring-action framework.
|
||||
- Keep `OperateHubShell` and `CanonicalNavigationContext` as the single scope and return-context sources for canonical `/admin` monitoring pages.
|
||||
- Express hierarchy through native Filament actions, `ActionGroup`, existing context bars, and targeted Blade adjustments rather than custom header components.
|
||||
- Retire the blanket baseline exemption for `Alerts` and bring it into explicit Spec 193 declaration plus inventory coverage.
|
||||
- Build regression protection on top of the existing guard tests, `OperateHubShellTest`, focused page tests, and one dedicated browser smoke suite.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/193-monitoring-action-hierarchy/`:
|
||||
|
||||
- `research.md`: decisions and rejected alternatives for monitoring/workbench hierarchy
|
||||
- `data-model.md`: derived surface inventory, action-layer, and regression expectation models
|
||||
- `contracts/monitoring-action-hierarchy.logical.openapi.yaml`: internal logical contract for monitoring-surface action layers and exception handling
|
||||
- `quickstart.md`: implementation and verification sequence for the feature
|
||||
|
||||
Design highlights:
|
||||
|
||||
- Keep all classification and action-layer rules derived, not persisted.
|
||||
- Represent each in-scope surface through one explicit inventory entry and one explicit regression expectation.
|
||||
- Extend the existing action-surface validation layer with a second bounded inventory for monitoring/workbench surfaces.
|
||||
- Keep selection-state logic local to the affected pages instead of moving it into a shared runtime resolver.
|
||||
- Treat `TenantDiagnostics` as the only explicit special-type exception and require an exception reason in the guard layer.
|
||||
|
||||
## Phase 1 — Agent Context Update
|
||||
|
||||
Planned command:
|
||||
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
This feature does not introduce a new technology stack, but the required agent-context refresh still runs after the technical context and design artifacts are complete.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/193-monitoring-action-hierarchy/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── spec.md
|
||||
├── contracts/
|
||||
│ └── monitoring-action-hierarchy.logical.openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Pages/
|
||||
│ │ │ ├── Monitoring/
|
||||
│ │ │ │ ├── FindingExceptionsQueue.php # MODIFY
|
||||
│ │ │ │ ├── Operations.php # MODIFY
|
||||
│ │ │ │ ├── Alerts.php # MODIFY (add declaration + minor alignment)
|
||||
│ │ │ │ ├── AuditLog.php # AUDIT / possible minor alignment
|
||||
│ │ │ │ └── EvidenceOverview.php # REFERENCE only
|
||||
│ │ │ ├── Operations/
|
||||
│ │ │ │ └── TenantlessOperationRunViewer.php # MODIFY
|
||||
│ │ │ ├── Reviews/
|
||||
│ │ │ │ └── ReviewRegister.php # REFERENCE only
|
||||
│ │ │ ├── BaselineCompareLanding.php # REFERENCE only
|
||||
│ │ │ ├── BaselineCompareMatrix.php # REFERENCE only
|
||||
│ │ │ └── TenantDiagnostics.php # AUDIT / special-type exception
|
||||
│ │ └── Resources/
|
||||
│ │ ├── AlertDeliveryResource.php # REUSE / possible declaration notes
|
||||
│ │ └── AlertDeliveryResource/
|
||||
│ │ └── Pages/
|
||||
│ │ └── ListAlertDeliveries.php # AUDIT / possible minor alignment
|
||||
│ ├── Support/
|
||||
│ │ ├── OperateHub/
|
||||
│ │ │ └── OperateHubShell.php # REUSE
|
||||
│ │ ├── Navigation/
|
||||
│ │ │ └── CanonicalNavigationContext.php # REUSE
|
||||
│ │ ├── Filament/
|
||||
│ │ │ └── CanonicalAdminTenantFilterState.php # REUSE
|
||||
│ │ ├── Rbac/
|
||||
│ │ │ └── UiEnforcement.php # REUSE
|
||||
│ │ └── Ui/
|
||||
│ │ └── ActionSurface/
|
||||
│ │ ├── ActionSurfaceExemptions.php # MODIFY
|
||||
│ │ ├── ActionSurfaceValidator.php # MODIFY
|
||||
│ │ └── ActionSurfaceProfileDefinition.php # POSSIBLE MODIFY
|
||||
├── resources/
|
||||
│ └── views/
|
||||
│ └── filament/
|
||||
│ ├── partials/
|
||||
│ │ └── context-bar.blade.php # REUSE / possible minor alignment
|
||||
│ └── pages/
|
||||
│ ├── monitoring/
|
||||
│ │ ├── finding-exceptions-queue.blade.php # MODIFY
|
||||
│ │ ├── operations.blade.php # POSSIBLE MODIFY
|
||||
│ │ ├── alerts.blade.php # POSSIBLE MODIFY
|
||||
│ │ └── audit-log.blade.php # POSSIBLE MODIFY
|
||||
│ ├── operations/
|
||||
│ │ └── tenantless-operation-run-viewer.blade.php # MODIFY
|
||||
│ ├── reviews/
|
||||
│ │ └── review-register.blade.php # REFERENCE only
|
||||
│ ├── baseline-compare-landing.blade.php # REFERENCE only
|
||||
│ ├── baseline-compare-matrix.blade.php # REFERENCE only
|
||||
│ └── tenant-diagnostics.blade.php # REFERENCE / special-type audit
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Guards/
|
||||
│ │ ├── ActionSurfaceContractTest.php # MODIFY
|
||||
│ │ ├── ActionSurfaceValidatorTest.php # MODIFY
|
||||
│ │ └── Spec193MonitoringSurfaceHierarchyGuardTest.php # NEW
|
||||
│ ├── Monitoring/
|
||||
│ │ ├── AuditLogInspectFlowTest.php # REUSE / possible extend
|
||||
│ │ ├── OperationsCanonicalUrlsTest.php # MODIFY or REUSE
|
||||
│ │ ├── OperationsDashboardDrillthroughTest.php # MODIFY or REUSE
|
||||
│ │ ├── OperationsRelatedNavigationTest.php # MODIFY or REUSE
|
||||
│ │ ├── FindingExceptionsQueueHierarchyTest.php # NEW
|
||||
│ │ └── OperationsHeaderHierarchyTest.php # NEW
|
||||
│ ├── Operations/
|
||||
│ │ └── TenantlessOperationRunViewerTest.php # MODIFY
|
||||
│ ├── OpsUx/
|
||||
│ │ └── OperateHubShellTest.php # MODIFY
|
||||
│ └── Rbac/
|
||||
│ ├── ActionSurfaceRbacSemanticsTest.php # REUSE / possible extend
|
||||
│ └── TenantActionSurfaceConsistencyTest.php # REUSE / possible extend
|
||||
└── Browser/
|
||||
├── Spec192RecordPageHeaderDisciplineSmokeTest.php # REUSE for patterns
|
||||
└── Spec193MonitoringSurfaceHierarchySmokeTest.php # NEW
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the work entirely inside the existing Laravel/Filament monolith under `apps/platform`. Modify only the affected page classes, a small set of monitoring Blade views, the existing action-surface validation layer, and focused tests. Do not create a new runtime resolver or header-framework layer.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| Cross-page monitoring/workbench taxonomy and explicit exception catalog (BLOAT-001 trigger) | The feature must distinguish remediation-required workbench pages, shared-pattern audit pages, calm bounded-scope references, and the single acceptable diagnostic exception in a way CI can validate. | Pure local cleanup would reduce visible clutter but would not prevent future drift or explain why some monitoring surfaces are preserved while others are remediated. |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Monitoring and workbench pages still mix scope, navigation, utility, and active work actions in one header lane, slowing queue review and making next-step semantics ambiguous.
|
||||
- **Existing structure is insufficient because**: The constitution and Spec 192 now govern other action-surface classes, but the repo lacks a bounded implementation inventory and regression hook for this monitoring/workbench surface class.
|
||||
- **Narrowest correct implementation**: Extend the existing action-surface inventory and validation layer with one additional monitoring/workbench inventory, remediate only the three clearly problematic pages, explicitly classify the others, and add focused feature plus browser regression coverage.
|
||||
- **Ownership cost created**: A small extension to the validator and exemption inventory, one new guard test, several focused page tests, one browser smoke suite, and ongoing review discipline for future monitoring surfaces.
|
||||
- **Alternative intentionally rejected**: A new monitoring action-placement engine or registry was rejected because the current repo already has sufficient primitives through Filament actions, `OperateHubShell`, and the existing action-surface validator.
|
||||
- **Release truth**: current-release operator clarity and action-surface discipline
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Codify the monitoring inventory and guard contract
|
||||
|
||||
Goal: turn the spec inventory into an enforceable project-level contract without introducing a new framework.
|
||||
|
||||
Changes:
|
||||
|
||||
- Extend `ActionSurfaceExemptions` with a `spec193MonitoringSurfaceInventory()` and per-surface lookup helper.
|
||||
- Extend `ActionSurfaceValidator` with explicit validation of the Spec 193 inventory and its allowed classifications.
|
||||
- Add `Spec193MonitoringSurfaceHierarchyGuardTest.php` to assert completeness, explicit exception reasoning, and bounded reference preservation.
|
||||
- Remove the blanket baseline exemption for `Alerts` and replace it with explicit monitoring-surface coverage.
|
||||
|
||||
Tests:
|
||||
|
||||
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` with Spec 193 expectations.
|
||||
- Add `Spec193MonitoringSurfaceHierarchyGuardTest.php`.
|
||||
|
||||
### Phase B — Remediate the highest-noise workbench surfaces
|
||||
|
||||
Goal: implement the core hierarchy wins first on the pages with the clearest mixed-header problem.
|
||||
|
||||
Changes:
|
||||
|
||||
- Refactor `FindingExceptionsQueue` so scope, return, filters, drilldowns, and selected-exception decisions no longer render as flat peers.
|
||||
- Refactor `TenantlessOperationRunViewer` so scope, return, show-all, refresh, related links, and resumable actions render as distinct layers.
|
||||
- Refactor `Operations` so scope and show-all context stay visible but no longer read as the page’s primary work surface.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add focused page tests for no-selection vs selected-workbench behavior on `FindingExceptionsQueue`.
|
||||
- Extend `TenantlessOperationRunViewerTest.php` and relevant Monitoring tests with hierarchy and navigation assertions.
|
||||
|
||||
### Phase C — Tighten shared patterns and classify bounded-scope references
|
||||
|
||||
Goal: bring shared monitoring patterns under the same contract without rebuilding calm pages.
|
||||
|
||||
Changes:
|
||||
|
||||
- Add an explicit declaration to `Alerts` and audit its origin-context placement.
|
||||
- Review `AuditLog` and `ListAlertDeliveries` for minor alignment only, changing them only where real action-layer ambiguity exists.
|
||||
- Confirm `EvidenceOverview`, `BaselineCompareLanding`, `BaselineCompareMatrix`, and `ReviewRegister` remain compliant or no-op references.
|
||||
- Keep `TenantDiagnostics` as the single special-type acceptable exception and codify why it is allowed.
|
||||
|
||||
Tests:
|
||||
|
||||
- Extend `OperateHubShellTest.php` for scope-label and return-affordance expectations that remain relevant after the monitoring refactor.
|
||||
- Add focused assertions for the Alerts overview in `AlertsHierarchyTest.php`, keep alert-delivery minor-alignment coverage in the existing alert-delivery suites, and verify that minor-alignment pages remain calm or declaration-complete.
|
||||
|
||||
### Phase D — Browser verification and final regression protection
|
||||
|
||||
Goal: prove the new hierarchy in a real browser and prevent future mixed monitoring headers from re-entering the repo.
|
||||
|
||||
Changes:
|
||||
|
||||
- Add `Spec193MonitoringSurfaceHierarchySmokeTest.php` covering the three remediated pages, the `TenantDiagnostics` exception surface, and a no-regression subset of calm reference pages.
|
||||
- Ensure the guard layer fails on scope-as-CTA regressions, mixed selection and global header lanes, and undocumented exceptions.
|
||||
- Re-run formatting and the focused Sail test pack.
|
||||
|
||||
Tests:
|
||||
|
||||
- Browser smoke coverage for visible hierarchy and no JavaScript errors.
|
||||
- Focused guard and feature tests for each remediated or exception surface.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Cleanup grows into a new monitoring-action framework | Medium | Low | Keep all work inside existing pages, views, and validator layers. |
|
||||
| Shared-pattern loyalty masks real hierarchy issues | High | Medium | Treat `OperateHubShell` pages as review targets, not automatic exemptions. |
|
||||
| Selection-aware workbench pages become too quiet when no selection exists | Medium | Medium | Add explicit no-selection vs selected-state tests on the queue and viewer surfaces. |
|
||||
| Alerts stays outside regression coverage because of the old cluster exemption | Medium | Medium | Convert it to explicit declaration plus inventory coverage in Phase A. |
|
||||
| Calm bounded-scope pages get unnecessary churn | Medium | Low | Maintain an explicit compliant/no-op reference set and cover it in browser smoke. |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` so Spec 193 becomes an explicit CI-enforced rule instead of a manual review note.
|
||||
- Add `Spec193MonitoringSurfaceHierarchyGuardTest.php` to validate remediation-required pages, minor-alignment pages, calm references, and the explicit diagnostic exception.
|
||||
- Add focused feature tests for `FindingExceptionsQueue`, `Operations`, and `TenantlessOperationRunViewer` covering state-driven hierarchy, scope behavior, and preserved authorization semantics.
|
||||
- Reuse and extend `OperateHubShellTest.php` to keep DB-only rendering, tenant-context resolution, and scope-label behavior correct on canonical monitoring pages.
|
||||
- Add `Spec193MonitoringSurfaceHierarchySmokeTest.php` using the existing browser-smoke patterns already present in Spec 192 and other surface smoke tests.
|
||||
- Add explicit regression assertions that this feature does not expand confirmation depth, reason capture, provider-dispatch semantics, or record-page header rules.
|
||||
- Run the focused Sail verification commands from `quickstart.md`, then run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS.
|
||||
|
||||
- Livewire v4.0+ compliance remains intact because all touched surfaces stay inside the existing Filament v5 + Livewire v4 stack.
|
||||
- Provider registration remains unchanged in `bootstrap/providers.php`.
|
||||
- The plan changes no global-search semantics; affected surfaces are pages or existing resource-backed pages whose search behavior already satisfies the Filament hard rule.
|
||||
- Destructive and governance-changing actions keep `->requiresConfirmation()` plus existing authorization.
|
||||
- No new assets are introduced; existing `filament:assets` deployment behavior remains sufficient.
|
||||
94
specs/193-monitoring-action-hierarchy/quickstart.md
Normal file
94
specs/193-monitoring-action-hierarchy/quickstart.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Quickstart: Monitoring Surface Action Hierarchy and Workbench Semantics
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the in-scope monitoring, queue, operations, and workbench surfaces under one bounded hierarchy: scope reads as context, navigation reads as navigation, utilities stay utility-level, selected-object or focused actions become prominent only in active work states, calm bounded-scope pages remain calm, and `TenantDiagnostics` remains the only explicit exception.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
1. Confirm the in-scope inventory in code.
|
||||
- Add the Spec 193 inventory to the existing `ActionSurfaceExemptions` layer.
|
||||
- Validate which surfaces are remediation-required, minor-alignment only, compliant or no-op, or special-type acceptable.
|
||||
- Retire the blanket `Alerts` baseline exemption and replace it with explicit declaration plus inventory coverage.
|
||||
|
||||
2. Remediate the core workbench pages first.
|
||||
- Refactor `FindingExceptionsQueue` so queue scope, utilities, drilldowns, and selected-exception decisions no longer render as flat peers.
|
||||
- Refactor `TenantlessOperationRunViewer` so scope, return navigation, refresh, related links, and resumable actions render as distinct layers.
|
||||
- Refactor `Operations` so scope and show-all context stay visible but quieter than the list’s actual work controls.
|
||||
|
||||
3. Tighten shared monitoring patterns and classify the rest.
|
||||
- Add an explicit declaration and audit pass to `Alerts`.
|
||||
- Review `AuditLog` and `ListAlertDeliveries` for minor alignment only.
|
||||
- Confirm `EvidenceOverview`, `BaselineCompareLanding`, `BaselineCompareMatrix`, and `ReviewRegister` remain calm references.
|
||||
- Keep `TenantDiagnostics` as the explicit special-type exception and verify its exception reason in code.
|
||||
|
||||
4. Add regression protection.
|
||||
- Extend the existing action-surface validator with Spec 193 inventory validation.
|
||||
- Add a dedicated guard test for Spec 193 inventory and exception semantics.
|
||||
- Add focused feature tests for the remediated pages and the diagnostic exception.
|
||||
- Add one browser smoke suite covering remediated, exception, and reference surfaces.
|
||||
|
||||
5. Run focused verification.
|
||||
- Run the guard tests, focused feature tests, browser smoke suite, and formatting through Sail.
|
||||
|
||||
## Suggested Source Files
|
||||
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
|
||||
- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/Operations.php`
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php`
|
||||
- `apps/platform/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php`
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`
|
||||
- `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
||||
- `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||
- `apps/platform/app/Filament/Pages/TenantDiagnostics.php`
|
||||
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
|
||||
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
|
||||
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||
|
||||
## Suggested Test Files
|
||||
|
||||
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||
- `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`
|
||||
- `apps/platform/tests/Feature/Guards/Spec193MonitoringSurfaceHierarchyGuardTest.php`
|
||||
- `apps/platform/tests/Feature/OpsUx/OperateHubShellTest.php`
|
||||
- `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||
- `apps/platform/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php`
|
||||
- `apps/platform/tests/Feature/Monitoring/OperationsRelatedNavigationTest.php`
|
||||
- `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`
|
||||
- `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php`
|
||||
- `apps/platform/tests/Feature/Monitoring/OperationsHeaderHierarchyTest.php`
|
||||
- `apps/platform/tests/Browser/Spec193MonitoringSurfaceHierarchySmokeTest.php`
|
||||
|
||||
## Minimum Verification Commands
|
||||
|
||||
Run all commands through Sail from `apps/platform`.
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceValidatorTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec193MonitoringSurfaceHierarchyGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/OperateHubShellTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec193MonitoringSurfaceHierarchySmokeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual Acceptance Checklist
|
||||
|
||||
1. Open `FindingExceptionsQueue` with and without a selected exception and confirm the page visibly changes from quiet monitoring mode to focused workbench mode.
|
||||
2. Open `TenantlessOperationRunViewer` from Operations and confirm scope, return, refresh, related links, and follow-up actions no longer read as one flat header strip.
|
||||
3. Open `Operations` and confirm scope reset is visible but quieter than tabs, filters, and row drilldown.
|
||||
4. Open `Alerts`, `AuditLog`, and `ListAlertDeliveries` and confirm they remain calm or only receive documented minor alignment.
|
||||
5. Open `EvidenceOverview`, `BaselineCompareLanding`, `BaselineCompareMatrix`, and `ReviewRegister` and confirm they remain calm bounded-scope references.
|
||||
6. Open `TenantDiagnostics` with and without an active defect state and confirm repair actions appear only when justified and remain confirmed.
|
||||
7. Confirm browser smoke checks show no JavaScript errors on remediated, exception, and reference surfaces.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- No migration is expected.
|
||||
- No new provider registration is expected; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
||||
- No new asset registration is expected. Existing deploy handling of `cd apps/platform && php artisan filament:assets` remains sufficient.
|
||||
99
specs/193-monitoring-action-hierarchy/research.md
Normal file
99
specs/193-monitoring-action-hierarchy/research.md
Normal file
@ -0,0 +1,99 @@
|
||||
# Research: Monitoring Surface Action Hierarchy and Workbench Semantics
|
||||
|
||||
## Decision: Reuse the existing `ActionSurfaceExemptions` plus `ActionSurfaceValidator` inventory pattern instead of introducing a monitoring-specific action-placement framework
|
||||
|
||||
### Rationale
|
||||
|
||||
Spec 192 already proved that the repo can encode a bounded surface-class contract through `ActionSurfaceExemptions`, `ActionSurfaceValidator`, and a dedicated guard test. Spec 193 needs the same kind of explicit inventory and CI protection for monitoring and workbench pages, not a second runtime engine for action placement.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add a `MonitoringActionHierarchyResolver` or `WorkbenchActionRegistry`: rejected because the placement rules remain page- and state-sensitive, and the repo already has enough validation infrastructure.
|
||||
- Keep the rules only in the spec with manual review: rejected because mixed monitoring headers are exactly the sort of drift CI should catch.
|
||||
|
||||
## Decision: Keep `OperateHubShell` and `CanonicalNavigationContext` as the canonical scope and return-context sources for `/admin` monitoring pages
|
||||
|
||||
### Rationale
|
||||
|
||||
The affected canonical workspace routes already rely on `OperateHubShell` for entitled tenant context and on `CanonicalNavigationContext` for back-link semantics. The narrowest correct move is to preserve those sources and only change how their outputs are layered, rather than letting each page improvise its own scope or return logic.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Resolve tenant and return context ad hoc in each page: rejected because it would duplicate authorization-sensitive context logic and risk canonical-route drift.
|
||||
- Remove remembered-tenant context from canonical monitoring pages: rejected because the current product truth intentionally supports tenant-prefiltered monitoring views.
|
||||
|
||||
## Decision: Express the hierarchy with native Filament actions, `ActionGroup`, context bars, and targeted Blade adjustments
|
||||
|
||||
### Rationale
|
||||
|
||||
The codebase already uses native Filament header actions, grouped actions, page views, and context-bar partials. The problem is semantic weight and layering, not a missing UI toolkit. Using those existing primitives keeps the feature local and avoids importing a new presentation framework.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add custom header components or a page-level action-slot DSL: rejected because the existing Filament action primitives already cover the needed hierarchy.
|
||||
- Solve the issue only by button restyling: rejected because the problem is semantic competition, not color alone.
|
||||
|
||||
## Decision: Bring `Alerts` under explicit Spec 193 coverage instead of leaving it as a blanket baseline exemption
|
||||
|
||||
### Rationale
|
||||
|
||||
`Alerts` is in scope for minor alignment and currently carries a baseline exemption because the active route resolves through the cluster entry. That exemption is too coarse for Spec 193, which needs an explicit verdict for every in-scope surface. The clean approach is to add an explicit declaration and inventory entry while keeping the page minor-alignment only unless a real hierarchy issue is found.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Keep the existing exemption and rely on browser smoke only: rejected because the spec requires explicit classification and regression protection for all in-scope surfaces.
|
||||
- Fully remediate Alerts as if it were a noisy workbench: rejected because the current surface is probably already calm enough for minor alignment only.
|
||||
|
||||
## Decision: Model selection-heavy workbench behavior locally on the affected pages instead of in a shared state resolver
|
||||
|
||||
### Rationale
|
||||
|
||||
`FindingExceptionsQueue` and `TenantlessOperationRunViewer` already have page-local state and action visibility rules. The narrowest correct implementation is to keep that local state and only classify actions into explicit layers. That preserves current authorization, notifications, and run semantics while avoiding a premature shared workbench state machine.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Add a shared `WorkbenchState` resolver or enum family: rejected because the feature does not yet have enough truly shared runtime behavior to justify it.
|
||||
- Collapse all non-global actions into a single `More` menu: rejected because the spec requires visible state transitions between calm monitoring mode and active work mode.
|
||||
|
||||
## Decision: Preserve bounded-scope monitoring pages as explicit references
|
||||
|
||||
### Rationale
|
||||
|
||||
`EvidenceOverview`, `BaselineCompareLanding`, `BaselineCompareMatrix`, and `ReviewRegister` already read as bounded-scope monitoring pages with one primary question and limited header complexity. The spec should name them as calm references and use them as a regression baseline, rather than rebuilding them to mimic the remediated workbench pages.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Normalize every in-scope page to the same visible action-layer structure: rejected because it would create churn without additional operator value.
|
||||
- Ignore calm pages and only document the noisy ones: rejected because the spec explicitly requires full-scope classification.
|
||||
|
||||
## Decision: Keep `TenantDiagnostics` as the only special-type acceptable exception
|
||||
|
||||
### Rationale
|
||||
|
||||
`TenantDiagnostics` is not a generic monitoring list; it is a focused diagnostic repair surface whose actions are meaningful only when a broken membership state exists. It should remain allowed as a special type, with explicit documentation and regression expectations, rather than being forced into the same pattern as a queue or a read-only registry report.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Force diagnostics into a quiet read-only monitoring pattern: rejected because it would obscure legitimate repair actions.
|
||||
- Let diagnostics stay different without explicit cataloging: rejected because silent exceptions produce review drift.
|
||||
|
||||
## Decision: Build regression protection with one extra inventory, focused feature tests, and one browser smoke suite
|
||||
|
||||
### Rationale
|
||||
|
||||
The repo already has the right three test layers for this kind of change: inventory and validator guards, focused feature tests around page state and authorization, and browser smoke for visible hierarchy. Extending those layers gives the feature durable protection without overbuilding.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Browser-test every state permutation of every monitoring page: rejected because it is expensive and redundant with feature tests.
|
||||
- Add only static guard coverage: rejected because no-selection vs active-selection behavior still needs runtime assertions.
|
||||
|
||||
## Decision: No new assets, provider changes, or route changes are needed
|
||||
|
||||
### Rationale
|
||||
|
||||
The work stays within existing Filament pages and resource pages. No panel/provider registration, asset registration, or route family change is required. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- Introduce a custom asset or component library for layered monitoring headers: rejected because native Filament surfaces already provide the necessary primitives.
|
||||
333
specs/193-monitoring-action-hierarchy/spec.md
Normal file
333
specs/193-monitoring-action-hierarchy/spec.md
Normal file
@ -0,0 +1,333 @@
|
||||
# Feature Specification: Monitoring Surface Action Hierarchy and Workbench Semantics
|
||||
|
||||
**Feature Branch**: `193-monitoring-action-hierarchy`
|
||||
**Created**: 2026-04-11
|
||||
**Status**: Proposed
|
||||
**Input**: User description: "Spec 193 - Monitoring Surface Action Hierarchy and Workbench Semantics"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Several monitoring, queue, operations, and workbench surfaces still present scope context, return navigation, utility controls, and selection-bound work actions as one flat header strip.
|
||||
- **Today's failure**: Operators can reach the right surface, but the header often fails to answer four questions quickly enough: where am I, what scope am I in, what is selected, and what action is actually next. On queue and monitoring pages, scope chips read like calls to action, related navigation competes with work actions, and selection logic appears at the same visual weight as global surface controls.
|
||||
- **User-visible improvement**: Monitoring and workbench surfaces become easier to scan. Scope reads as context, navigation reads as navigation, utilities read as utilities, and selected-object actions only become prominent when there is an active object or selection.
|
||||
- **Smallest enterprise-capable version**: Inventory the in-scope monitoring and workbench surfaces, classify each one, remediate the three clearly problematic core surfaces, lightly align shared-pattern neighbors only where needed, explicitly preserve already calm bounded-scope pages, document the one special-type diagnostic surface, and add lightweight regression protection.
|
||||
- **Explicit non-goals**: No record-page header rewrite, no new danger or reason-capture policy, no dispatch or preflight redesign, no general bulk-action framework, no full monitoring redesign, and no forced normalization of already calm pages.
|
||||
- **Permanent complexity imported**: A narrow monitoring-surface action hierarchy contract, an explicit classification matrix for this surface class, a documented special-type exception, and focused regression coverage for monitoring and workbench action-layer drift.
|
||||
- **Why now**: Spec 192 intentionally excludes this surface class. The repo already contains repeated mixed-header patterns on the canonical monitoring surfaces, so leaving the gap open would keep creating inconsistent operator semantics precisely where operations and queue review need the clearest hierarchy.
|
||||
- **Why not local**: Page-by-page cleanup would reduce clutter on one screen at a time but would not define the repo-wide rule for separating scope, navigation, utility, and selection layers on monitoring and workbench surfaces, nor would it explain why some pages are calm references while others need layered remediation.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: Cross-surface UI taxonomy risk and multi-surface cleanup breadth risk. Defense: the spec is limited to one explicit surface class, introduces no new engine or persisted truth, and preserves no-op pages instead of forcing broad redesign.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- Existing workspace queue route for Finding Exceptions Queue
|
||||
- Existing workspace Operations landing and tenantless operation detail routes
|
||||
- Existing workspace Monitoring routes for Alerts, Audit Log, Evidence Overview, and alert deliveries
|
||||
- Existing tenant-bound Baseline Compare landing and compare matrix routes
|
||||
- Existing workspace Review Register route and tenant review detail route it opens
|
||||
- Existing tenant diagnostics route
|
||||
- **Data Ownership**:
|
||||
- Workspace-scoped monitoring views remain workspace-scoped and continue to display existing workspace-owned or workspace-visible operational records.
|
||||
- Tenant-owned records surfaced through these pages remain tenant-owned: finding exceptions, operation runs, evidence snapshots, tenant reviews, and tenant diagnostics context.
|
||||
- This spec introduces no new tables, persisted entities, or route truth. It changes only surface classification, action hierarchy, and visible action placement on existing pages.
|
||||
- **RBAC**:
|
||||
- Existing workspace membership and capability checks continue to govern workspace monitoring surfaces.
|
||||
- Existing tenant membership and tenant capability checks continue to govern tenant-bound monitoring surfaces.
|
||||
- Regrouping actions does not change authorization semantics: non-members remain `404`, members lacking capability remain `403`, and destructive or repair actions keep confirmation plus server-side authorization.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Workspace monitoring surfaces may keep an entitled remembered tenant context as a prefilter or scope signal, but the tenant-wide view must remain explicit and reversible. Tenant-bound monitoring pages remain bound to the active tenant and do not broaden scope. Selection-aware work actions must never imply a broader scope than the currently filtered or active tenant context.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Scope labels, return links, row drilldowns, and related-navigation affordances must continue to derive from existing capability-aware helpers and entitled-tenant resolution. Moving actions between layers must not expose inaccessible tenants, inaccessible related records, or cross-tenant operation details.
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Finding Exceptions Queue | Workspace queue workbench | Explicit inspect action selects one exception and opens the in-page review state | forbidden | Scope, return, clear filters, and drilldown links move into distinct context, utility, and related layers outside the selected-action lane | Selection-bound review decisions stay in the selected-object action layer only when an exception is selected and pending | Existing Finding Exceptions Queue route | Same queue page with selected exception state and tenant detail drilldown | Workspace or remembered-tenant scope, selected exception summary, queue filters | Finding exceptions / exception request | Whether the queue is scoped, whether an exception is selected, and whether a decision is currently available | remediation required |
|
||||
| Tenantless Operation Run Viewer | Workspace monitoring detail viewer | Canonical tenantless operation detail page is the only inspect destination for a selected run | forbidden | Scope, back navigation, show-all, refresh, and related links split into context, navigation, utility, and drilldown layers | No destructive action on the viewer; resumable actions stay isolated from navigation and only appear when actually applicable | Existing Operations landing route | Existing tenantless operation detail route | Scope label, origin context, current tenant context mismatch banner, related-run context | Operations / operation run | What run is being viewed, what scope it belongs to, and whether the current context differs from the run tenant | remediation required |
|
||||
| Operations | Workspace monitoring landing | Clickable row opens the tenantless operation detail viewer | required | Scope label, return navigation, and show-all behavior separate from tabs, filters, and list interaction | none | Existing Operations route | Existing tenantless operation detail route | Workspace scope, remembered tenant scope, active tab, explicit show-all reset | Operations / operation run | Whether the view is tenant-prefiltered, which operational state tab is active, and what class of runs needs attention | remediation required |
|
||||
| Alerts | Workspace monitoring landing | Page-level overview with drilldown into related alert destinations and deliveries | not applicable | Scope and return navigation stay quiet and distinct from alert overview content and related navigation | none | Existing Alerts overview route | Existing alert delivery and destination drilldowns | Workspace scope, origin context | Alerts | Current alert health and KPI context | minor alignment only |
|
||||
| Audit Log | Workspace audit history | Explicit inspect action opens selected event detail without changing the page type | forbidden | Scope and return remain in the header; selected-event inspection and related links remain subordinate to audit history | none | Existing Audit Log route | Same page with selected event inspection state | Workspace or tenant prefilter, audit filters, selected event identity | Audit log / audit event | Which event is selected and whether the list is filtered | minor alignment only |
|
||||
| List Alert Deliveries | Workspace delivery history resource list | Existing alert-delivery inspect flow remains the primary open model | allowed | Shared OperateHubShell scope and return actions stay quiet; list-level utilities remain separate from resource inspection | none | Existing alert deliveries resource index | Existing alert-delivery resource detail or inspect path | Workspace or remembered-tenant scope | Alert deliveries / alert delivery | Delivery history and current scope | minor alignment only |
|
||||
| Evidence Overview | Workspace bounded-scope monitoring report | Clickable row opens the tenant-scoped evidence snapshot detail | required | Single clear-filters utility stays separate from the report body; no additional layering required | none | Existing Evidence Overview route | Existing tenant evidence snapshot view route | Optional tenant prefilter only | Evidence overview / evidence snapshot | Current active evidence freshness and next step by tenant | compliant / no-op |
|
||||
| Baseline Compare Matrix | Tenant focused monitoring matrix | Matrix page itself is the focused inspect surface for a selected baseline profile and subject | forbidden | Existing focused compare actions remain local to the matrix context; no extra header hierarchy is required | none | Existing Baseline Compare landing or profile compare entry | Existing compare matrix route | Active tenant, baseline profile, selected subject | Baseline compare matrix | Compare results for one focused profile and subject | compliant / no-op |
|
||||
| Baseline Compare Landing | Tenant bounded-scope monitoring landing | Page-level landing remains the canonical monitoring entry for baseline compare status | not applicable | Existing landing actions remain tied to compare state and diagnostics without introducing competing header layers | none | Existing Baseline Compare landing route | Existing compare matrix and finding drilldowns | Active tenant, current compare state, profile context | Baseline compare | Compare coverage, evidence gaps, and next step | compliant / no-op |
|
||||
| Review Register | Workspace bounded-scope review register | Clickable row opens the tenant review detail | required | Single clear-filters utility remains sufficient; register actions stay list-scoped and calm | none | Existing Review Register route | Existing tenant review detail route | Tenant filter and review-state filters | Review register / tenant review | Review truth, publication readiness, and next step | compliant / no-op |
|
||||
| Tenant Diagnostics | Tenant singleton diagnostic workbench | The diagnostics page itself is the focused diagnostic surface for the current tenant | forbidden | No quiet navigation layer is required beyond normal tenant context; repair actions remain visible only when inconsistency exists | Repair actions remain capability-gated, confirmed, and isolated to the diagnostics exception surface | Existing tenant diagnostics route | Same page | Active tenant, missing-owner state, duplicate-membership state | Tenant diagnostics / tenant repair | Whether the tenant has repair-needed membership inconsistencies | special-type acceptable |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Finding Exceptions Queue | Workspace approver | Queue workbench | What scope am I reviewing, is anything selected, and what decision is available right now? | Queue scope, filters, selected exception summary, request status, governance validity | Deep related finding context and tenant detail drilldowns | queue state, validity, selection state | `TenantPilot only` | `Approve exception` or `Reject exception` only when a pending exception is selected | Review decisions are destructive-like governance actions and stay confirmed in the selected-object layer |
|
||||
| Tenantless Operation Run Viewer | Operator or manager monitoring one run | Monitoring detail viewer | What run is this, how does it relate to my current scope, and do I need to refresh, resume, or open something related? | Run identity, current outcome, canonical context banner, lifecycle banner, restore continuation context | Related links, redaction integrity detail, deeper failure explanations | execution outcome, freshness, lifecycle attention, context mismatch | Existing run-linked scopes only | `Refresh` stays utility; resumable action appears only when applicable | none |
|
||||
| Operations | Workspace operator | Monitoring landing | Which run class needs attention, and am I looking at one tenant or all tenants? | KPI widgets, active tab, tenant scope, run table | Deep run detail after drilldown | execution status, outcome, problem class, scope | read-only landing | Tab switch and inspect flow only | none |
|
||||
| Alerts | Workspace operator | Monitoring landing | Are alerts healthy, and where should I drill down next? | Alert KPIs and current scope | Delivery-detail drilldowns and destination management live downstream | alert health, delivery activity | Existing alert-management scopes only | Existing quiet overview and related drilldowns | none |
|
||||
| Audit Log | Workspace operator or auditor | History and inspection surface | What happened, in which scope, and which event needs inspection? | Audit history, filters, selected event context | Event detail body and related target drilldown | audit outcome, actor type, target type, scope | read-only history | `Inspect event` remains the inspect affordance | none |
|
||||
| List Alert Deliveries | Workspace operator | Delivery history list | Which deliveries succeeded or failed, and what scope am I in? | Delivery history and current scope | Delivery-specific inspection downstream | delivery outcome and scope | read-only history | Existing inspect flow only | none |
|
||||
| Evidence Overview | Workspace operator | Read-only registry report | Which tenants have current usable evidence, and what is the next step? | Tenant rows, artifact truth, freshness, next step | Downstream evidence snapshot details | artifact truth, freshness | read-only landing | Clear filters and row drilldown only | none |
|
||||
| Baseline Compare Matrix | Tenant operator | Focused monitoring matrix | What does this compare show for the currently focused profile and subject? | Compare matrix content and focused subject | Deeper compare diagnostics already inside the surface | coverage and drift | read-only focused analysis | Existing focused compare affordances only | none |
|
||||
| Baseline Compare Landing | Tenant operator | Bounded-scope monitoring landing | Is compare current, trustworthy, and what should I inspect next? | Compare state, profile identity, findings count, evidence-gap summary | Deep diagnostics and matrix/finding drilldowns | compare state, coverage, fidelity, evidence gaps | Existing compare and drilldown scopes only | Existing compare-state actions only | none |
|
||||
| Review Register | Workspace reviewer | Read-only register | Which reviews are current, complete, and ready for the next lifecycle step? | Review truth, completeness, publication readiness, next step | Downstream review detail | lifecycle, completeness, publication readiness | Existing review export scope when present | Row drilldown and existing scoped export affordance | none |
|
||||
| Tenant Diagnostics | Tenant owner or manager | Diagnostic exception surface | Is this tenant structurally broken, and what repair action is justified right now? | Missing-owner and duplicate-membership state | None beyond the diagnostic evidence already on the page | repair-needed state | `TenantPilot only` | Repair actions only when a defect exists | `Bootstrap owner` and `Merge duplicate memberships` remain confirmed and capability-gated |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: yes
|
||||
- **Current operator problem**: Monitoring and workbench surfaces currently hide action meaning inside one flat header lane, which slows queue review, weakens scope awareness, and makes it harder to tell whether an action is global, contextual, or selection-bound.
|
||||
- **Existing structure is insufficient because**: Spec 192 intentionally governs classic record pages, but monitoring and workbench surfaces have a different interaction model with scope context, selected-object state, and read-mostly utilities. Applying record-page rules directly would either flatten legitimate workbench behavior or leave the current ambiguity untouched.
|
||||
- **Narrowest correct implementation**: Define the rule only for the named monitoring and workbench surfaces, remediate only the three clearly problematic pages, allow minor alignment on shared-pattern neighbors, preserve already calm bounded-scope surfaces, and document the single acceptable special type.
|
||||
- **Ownership cost**: Ongoing review discipline for this surface class, modest browser and regression-test maintenance, and explicit documentation of exceptions and shared patterns.
|
||||
- **Alternative intentionally rejected**: Treating these pages as ordinary record headers or doing only local one-off cleanup was rejected because both options would leave the surface-class semantics undocumented and would not protect new monitoring pages from drifting back into mixed header patterns.
|
||||
- **Release truth**: current-release operator clarity and workbench surface discipline
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Review a queue without header ambiguity (Priority: P1)
|
||||
|
||||
As a workspace approver using a monitoring queue, I want the page to separate scope, utilities, selected-record state, and decision actions so I can immediately see whether there is something actionable right now.
|
||||
|
||||
**Why this priority**: Finding Exceptions Queue is the clearest workbench failure in the current scope. If it remains a flat mixed header, the spec has not solved its primary operator problem.
|
||||
|
||||
**Independent Test**: Open the queue with and without a selected exception and confirm that the page visibly changes from quiet monitoring mode to active workbench mode without leaving scope or navigation actions at the same level as approve or reject.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the queue has no selected exception, **When** the page renders, **Then** scope and utility actions remain visible while selected-object decision actions are not prominent.
|
||||
2. **Given** a pending exception is selected, **When** the page renders, **Then** selection-bound decision actions become the prominent work actions and scope or drilldown links do not read as peer actions.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Read one operation run without mixed context signals (Priority: P1)
|
||||
|
||||
As an operator opening a tenantless operation run, I want return navigation, scope context, refresh, related links, and resumable actions to be visibly separated so the viewer reads as a monitoring detail surface instead of a record-page button bar.
|
||||
|
||||
**Why this priority**: The run viewer is a canonical monitoring detail surface and a common drilldown from Operations. If it keeps mixing navigation and run actions, the repo still lacks a valid pattern for monitoring detail pages.
|
||||
|
||||
**Independent Test**: Open the viewer from Operations and from another origin context, then verify that back navigation stays quieter than refresh or resumable actions and that related links do not crowd the main action lane.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the viewer has an origin context, **When** the page renders, **Then** the back affordance is visually distinct from utility and related work actions.
|
||||
2. **Given** the viewed run has no resumable action, **When** the page renders, **Then** the header does not reserve equal prominence for a missing action and remains calm.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Preserve calm monitoring pages without forced churn (Priority: P2)
|
||||
|
||||
As a product reviewer, I want already calm monitoring pages to be explicitly confirmed as compliant or bounded-scope no-ops so the cleanup does not turn into cosmetic standardization.
|
||||
|
||||
**Why this priority**: The spec should sharpen meaningful hierarchy, not create churn on pages that already convey one clear monitoring question.
|
||||
|
||||
**Independent Test**: Review the bounded-scope reference pages and confirm that they either remain unchanged or receive only documented minor alignment, with no artificial new header structure added.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a bounded-scope report page such as Evidence Overview or Review Register, **When** the feature is reviewed, **Then** it remains calm and is not forced into extra action layers it does not need.
|
||||
2. **Given** a page is already sufficiently quiet, **When** the spec is applied, **Then** the page is classified as compliant or minor alignment only rather than remediated by default.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Keep special diagnostic surfaces explicit (Priority: P3)
|
||||
|
||||
As a reviewer, I want special-type monitoring surfaces to be explicitly marked so necessary diagnostic repair actions can exist without weakening the broader monitoring hierarchy rule.
|
||||
|
||||
**Why this priority**: The spec must allow narrow exceptions without creating silent inconsistency.
|
||||
|
||||
**Independent Test**: Review Tenant Diagnostics and verify that it remains a documented exception whose repair actions appear only when the diagnostic condition exists.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** Tenant Diagnostics shows no repair-needed condition, **When** the page renders, **Then** no repair action is promoted.
|
||||
2. **Given** a repair-needed state exists, **When** the page renders, **Then** the required repair action is available with confirmation and explicit exception documentation.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- If a monitoring page is in workspace scope with no remembered tenant context, the scope layer must still read clearly and must not imply tenant-specific actionability.
|
||||
- If a workspace monitoring page is filtered to one tenant, the operator must still be able to understand whether the surface is tenant-prefiltered or truly tenant-bound.
|
||||
- If no selection exists on a queue or workbench surface, selection-bound actions must not keep placeholder prominence.
|
||||
- If a selected object becomes unavailable after filters change, the surface must fall back to a calm no-selection state instead of keeping stale focused-object actions visible.
|
||||
- If a run viewer opens a tenant-owned run while a different tenant is active in context, the page must show the context mismatch clearly without treating the scope banner as a call to action.
|
||||
- If a shared pattern like OperateHubShell supplies scope and return affordances, those affordances must still be subordinate to the actual work state on the page.
|
||||
- If a special-type diagnostic surface has no active inconsistency, it must not manufacture work actions just to match other workbench pages.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new persistence, and no new queued work. It reorganizes action hierarchy and surface semantics only. Existing mutations such as exception approval, exception rejection, diagnostics repair, compare actions, and run-related resumes keep their current confirmation, audit, and run-observability behavior.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces only a bounded surface-class contract and classification matrix for monitoring and workbench pages. It adds no new persistence, no new state family, and no new action framework. The proportionality review above explains why a local-only cleanup is too weak and why a broader framework is intentionally rejected.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing operations visible from these surfaces continue to use their current run lifecycle, queued toast, progress surfaces, and terminal notification rules. This spec does not create or rename any operation type, does not alter service-owned `OperationRun` transitions, and does not change summary count semantics.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature spans workspace monitoring pages and tenant-bound monitoring pages but does not change authorization logic. Non-members remain `404`, members lacking capability remain `403`, selection-bound or repair actions still enforce server-side authorization, and destructive-like actions continue to require confirmation. At least one positive and one negative authorization regression must verify that moving actions between layers does not loosen access.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior changes.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This feature does not add or change badge semantics. Existing badge mappings remain centralized and unchanged.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The feature must use native Filament actions, action groups, page sections, and existing shared primitives such as OperateHubShell. It must avoid creating page-local button frameworks, ad-hoc status language, or custom visual taxonomies for scope or selection state. The only approved exception is keeping Tenant Diagnostics as a focused diagnostic repair surface with its existing capability-gated actions.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing action labels must stay domain-first and consistent while their placement changes. Examples include `Approve exception`, `Reject exception`, `Refresh`, `Open`, `Close details`, `Show all tenants`, `Bootstrap owner`, and `Merge duplicate memberships`. Scope labels must read as context rather than as equivalent action verbs.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** This spec classifies each affected surface, defines its single inspect or open model, states whether row click is required, allowed, or forbidden, and assigns secondary, related, and destructive actions to explicit layers. Monitoring and workbench pages must not silently inherit record-page action assumptions.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content remains operator-first. Scope, navigation, selection state, and next action must be legible without revealing raw implementation detail. Diagnostics stay secondary or downstream, dangerous actions keep confirmation and mutation-scope language, and workspace or tenant context remains explicit in navigation and page semantics.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature does not introduce a new presenter or semantic interpretation layer. It uses direct action placement, state-driven visibility, and explicit page classification instead of a new UI meta-framework. Tests must validate user-visible action hierarchy and authorization continuity rather than thin indirection.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied for remediated monitoring and workbench pages when each affected surface has one inspect or open model, no redundant peer navigation buttons, no empty action groups, and destructive-like actions only in the appropriate selected-object or exception layer. Any page that intentionally differs must be catalogued as a documented exception.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature changes action hierarchy and contextual placement only. It does not justify any regression in existing table filters, empty states, infolists, or form layouts. Monitoring tables must keep their current search, sort, filter, and empty-state contracts while the header hierarchy becomes calmer.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-193-001 Surface inventory**: The repo MUST maintain an explicit inventory of every in-scope monitoring, queue, operations, and workbench surface covered by this spec.
|
||||
- **FR-193-002 Explicit classification**: Every in-scope surface MUST be assigned exactly one classification: `remediation required`, `minor alignment only`, `compliant / no-op`, or `special-type acceptable`.
|
||||
- **FR-193-003 Monitoring surface action layers**: Remediated monitoring and workbench surfaces MUST separate actions into clearly distinguishable layers drawn from scope or context, navigation, surface utility, selection or focused-object actions, and related or drilldown actions.
|
||||
- **FR-193-004 Scope-is-context rule**: Scope labels, remembered-tenant context, origin context, and active workbench context MUST not appear as peer call-to-action buttons when they are semantically contextual only.
|
||||
- **FR-193-005 Navigation separation rule**: Back, return, show-all, and related-open navigation MUST not share the same primary action lane as live work actions when doing so makes the next step ambiguous.
|
||||
- **FR-193-006 Selection prominence rule**: Selection-bound or focused-object actions MUST become prominent only when a valid selection or focused object exists.
|
||||
- **FR-193-007 No-selection quiet state**: Queue and workbench surfaces MUST render a calm monitoring state when no selection or focused object is active.
|
||||
- **FR-193-008 Work-state transition rule**: Remediated workbench surfaces MUST visibly change hierarchy between no-selection, selected-object, global monitoring, and related drilldown states instead of keeping one static header layout.
|
||||
- **FR-193-009 Finding Exceptions Queue hierarchy**: Finding Exceptions Queue MUST stop presenting scope, clear filters, close details, drilldown links, and exception decisions as one flat peer row. Scope and utility actions remain globally visible, drilldown links move to a related layer, and `Approve exception` or `Reject exception` become prominent only when a pending exception is selected.
|
||||
- **FR-193-010 Queue decision visibility**: Finding Exceptions Queue MUST not promote exception decision actions when the selected exception is absent, resolved, or otherwise not decision-ready.
|
||||
- **FR-193-011 Tenantless run viewer hierarchy**: Tenantless Operation Run Viewer MUST separate scope context, back navigation, show-all navigation, refresh utility, related links, and resumable run actions into distinct layers so the page reads as a monitoring viewer rather than a record-page action strip.
|
||||
- **FR-193-012 Viewer navigation discipline**: Tenantless Operation Run Viewer MUST keep origin or return navigation visually subordinate to refresh and any run-specific follow-up action, and related links MUST not appear as equal peers to scope or refresh.
|
||||
- **FR-193-013 Operations landing hierarchy**: Operations MUST stop using the header as a mixed context-navigation strip. Scope context and show-all behavior remain visible but quieter than list filters, tab state, and row inspect flow.
|
||||
- **FR-193-014 Shared-pattern tightening**: Shared OperateHubShell patterns may remain in use, but any page using them MUST still subordinate scope and return affordances to the actual work or monitoring state of that page.
|
||||
- **FR-193-015 Minor alignment audit**: Alerts, Audit Log, and List Alert Deliveries MUST be reviewed against the same hierarchy but changed only where real action-layer ambiguity exists.
|
||||
- **FR-193-016 Compliant bounded-scope preservation**: Evidence Overview, Baseline Compare Matrix, Baseline Compare Landing, and Review Register MUST remain unchanged or receive only minimal documented alignment when their current bounded-scope semantics are already clear.
|
||||
- **FR-193-017 Special-type exception contract**: Tenant Diagnostics MUST be explicitly marked as a special-type acceptable surface whose repair actions are allowed because the page is itself a focused diagnostic exception surface, not a general monitoring list.
|
||||
- **FR-193-018 No record-header leakage**: Monitoring and workbench surfaces MUST NOT silently inherit classic record-page rules from Spec 192 where those rules would hide workbench state or selection semantics.
|
||||
- **FR-193-019 No governance-friction expansion**: This feature MUST NOT change confirmation depth, reason-capture behavior, danger-language policy, dispatch semantics, or provider-start semantics beyond what underlying actions already own.
|
||||
- **FR-193-020 Authorization continuity**: Moving, regrouping, or relabeling actions MUST NOT change route scope, capability enforcement, deny-as-not-found behavior, or audit obligations.
|
||||
- **FR-193-021 Vocabulary continuity**: Scope, navigation, utility, and work actions MUST keep consistent domain vocabulary across buttons, modal titles, notifications, and audit prose while the hierarchy changes.
|
||||
- **FR-193-022 Regression guard**: The repo MUST add a lightweight project-wide guard that prevents future monitoring or workbench surfaces from reintroducing scope-as-CTA patterns, flat global-plus-selection header mixes, or undocumented surface-class exceptions.
|
||||
- **FR-193-023 Browser verification**: Browser or UI smoke checks MUST cover every remediated surface, the documented special-type exception, and a no-regression subset of the compliant bounded-scope pages.
|
||||
|
||||
## Surface Decision Matrix
|
||||
|
||||
- **Remediation required**:
|
||||
- Finding Exceptions Queue
|
||||
- Tenantless Operation Run Viewer
|
||||
- Operations
|
||||
- **Minor alignment only**:
|
||||
- Alerts
|
||||
- Audit Log
|
||||
- List Alert Deliveries
|
||||
- **Compliant / no-op**:
|
||||
- Evidence Overview
|
||||
- Baseline Compare Matrix
|
||||
- Baseline Compare Landing
|
||||
- Review Register
|
||||
- **Special-type acceptable**:
|
||||
- Tenant Diagnostics
|
||||
|
||||
## Target Outcomes by Key Surface
|
||||
|
||||
- **Finding Exceptions Queue**: The page no longer reads as one flat strip of scope, close, open, and decision actions. Without a selected exception it behaves like a quiet monitoring queue. With a selected pending exception it becomes a focused review workbench.
|
||||
- **Tenantless Operation Run Viewer**: Scope and return context no longer compete with refresh, open-related, or resumable actions. The header reflects a monitoring viewer instead of a record-detail hybrid.
|
||||
- **Operations**: Scope and origin context no longer dominate the header. The page reads as a monitoring landing surface where tab state, filters, and inspect flow carry the work.
|
||||
- **Alerts, Audit Log, and List Alert Deliveries**: Shared monitoring patterns are checked and tightened only where hierarchy is still ambiguous.
|
||||
- **Evidence Overview, Baseline Compare Matrix, Baseline Compare Landing, and Review Register**: Calm bounded-scope monitoring pages are explicitly preserved as references, not cosmetically rebuilt.
|
||||
- **Tenant Diagnostics**: Diagnostic repair actions remain available only as a documented exception surface rather than as evidence that every monitoring page may promote repairs in the same way.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Applying record-page header rules to monitoring and workbench surfaces
|
||||
- Introducing a new global danger, confirmation, or reason-capture policy
|
||||
- Redesigning dispatch, preflight, provider-start, or queue semantics
|
||||
- Reworking list, row, or bulk action contracts outside this surface class
|
||||
- Forcing a full monitoring redesign across every page in the admin panel
|
||||
- Rebuilding calm bounded-scope pages for cosmetic consistency alone
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Spec 192 remains the rule set for classic record detail and edit pages, and this spec is the companion rule set for monitoring and workbench surfaces.
|
||||
- Existing authorization, audit logging, and run-observability behavior on the underlying actions is already correct and will be preserved.
|
||||
- OperateHubShell remains a valid shared pattern, but not a blanket exemption from action-hierarchy review.
|
||||
- The named compliant pages are bounded enough that a stronger multi-layer header would add noise rather than clarity.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- The constitution rule for action-surface discipline and the separation already established by Spec 192
|
||||
- Existing OperateHubShell scope and return affordances
|
||||
- Existing Filament action surfaces, tables, empty states, and tenant/workspace scope helpers
|
||||
- Existing browser-smoke and action-surface regression infrastructure
|
||||
|
||||
## Risks
|
||||
|
||||
- The feature could drift into a generic multi-surface framework if the scope is not kept strictly to monitoring and workbench pages.
|
||||
- Shared-pattern loyalty could cause real hierarchy issues to be excused as intentional infrastructure.
|
||||
- Selection-aware pages could hide useful actions too aggressively if the no-selection state is not balanced against active-work state.
|
||||
- Calm bounded-scope pages could be pulled into unnecessary churn if the no-op classification is not taken seriously.
|
||||
|
||||
## Review Questions
|
||||
|
||||
- Can a reviewer tell within a few seconds which surface state is active: scope only, selected object, or related drilldown?
|
||||
- Do scope labels now read as context instead of as peer calls to action?
|
||||
- Are navigation actions clearly calmer than actual work actions on remediated pages?
|
||||
- Are already calm monitoring pages preserved instead of standardized for their own sake?
|
||||
- Is Tenant Diagnostics clearly documented as an exception rather than a silent contradiction?
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Finding Exceptions Queue | `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` | Shared scope and return affordances remain quiet; `Clear filters` stays utility; `Close details`, `Open tenant detail`, and `Open finding` move out of the same peer lane as `Approve exception` and `Reject exception` | Existing explicit inspect or select flow remains the only open model | Existing inspect flow remains; no extra visible row actions are added | none | Existing queue empty-state CTA remains | Header becomes `context + utility + related links + selected-object actions` instead of one flat row | n/a | Existing exception approval and rejection audit behavior unchanged | Remediation-required workbench page |
|
||||
| Tenantless Operation Run Viewer | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | Scope label, back or origin, show-all, refresh, `Open` group, and resumable action are reordered into distinct layers rather than equal peers | Page itself is the canonical detail destination | n/a | none | n/a | Viewer header is split into context, navigation, utility, related, and follow-up layers; no redundant peer links | n/a | Existing resume and follow-up audit behavior unchanged | Remediation-required monitoring detail page |
|
||||
| Operations | `apps/platform/app/Filament/Pages/Monitoring/Operations.php` | Scope label, return, and `Show all tenants` remain quiet and do not act as a mixed primary action strip | `recordUrl()` and clickable row remain the only inspect model | Existing row click remains primary open affordance | none | Existing table empty state unchanged | Header is reduced to context and scope reset only; tabs and filters remain the work controls | n/a | Read-only landing; no new audit behavior | Remediation-required monitoring landing |
|
||||
| Alerts | `apps/platform/app/Filament/Pages/Monitoring/Alerts.php` | Shared scope and origin affordances remain quiet; only minor tightening if origin link still competes visually with overview behavior | Page-level overview only | n/a | none | Existing overview empty state unchanged | No structural rebuild unless audit finds real ambiguity | n/a | Existing downstream alert-management audit behavior unchanged | Minor alignment only |
|
||||
| Audit Log | `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php` | Shared scope and return remain quiet; selected-event inspection continues downstream rather than being elevated into mixed work actions | Explicit `Inspect event` remains the inspect model | `Inspect event` remains the visible row action | none | `Clear filters` remains the only empty-state CTA | Header remains calm; no new action lane unless minor ambiguity is found | n/a | Read-only audit history; no new audit behavior | Minor alignment only |
|
||||
| List Alert Deliveries | `apps/platform/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php` | Shared scope and return affordances remain as quiet context; no added peer actions | Existing resource inspect flow remains unchanged | Existing resource row actions unchanged | Existing resource bulk actions unchanged | Existing resource empty state unchanged | No structural rebuild unless shared pattern still reads as mixed CTA | n/a | Existing delivery-history behavior unchanged | Minor alignment only |
|
||||
| Evidence Overview | `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` | Single `Clear filters` utility remains sufficient and unchanged | Clickable row remains the inspect model | Single row drilldown remains | none | Existing clear-filters empty or header CTA remains | No added layering required | n/a | Read-only report | Compliant / no-op reference |
|
||||
| Baseline Compare Matrix | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` | Existing focused compare affordances remain local to the matrix context | Matrix page itself remains the inspect surface | n/a | none | Existing matrix empty state unchanged | No header rebuild required | n/a | Read-only focused analysis | Compliant / no-op reference |
|
||||
| Baseline Compare Landing | `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` | Existing compare-state actions remain tied to current compare truth and are not expanded into a layered header unless review finds ambiguity | Page-level landing remains canonical | n/a | none | Existing landing empty or state CTAs remain | No structural rebuild required | n/a | Existing compare-run behavior unchanged | Compliant / no-op reference |
|
||||
| Review Register | `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` | Single `Clear filters` utility remains sufficient; no extra header layering required | Clickable row remains the inspect model | Existing `Export executive pack` row action remains scoped and subordinate | none | Existing clear-filters empty-state CTA remains | No header rebuild required | n/a | Existing review export audit behavior unchanged | Compliant / no-op reference |
|
||||
| Tenant Diagnostics | `apps/platform/app/Filament/Pages/TenantDiagnostics.php` | Capability-gated repair actions remain on the page only when a defect exists; no extra context buttons are added to mimic other pages | Page itself remains the focused diagnostic surface | n/a | none | n/a | Repair actions remain explicit exception-surface actions with confirmation | n/a | Existing repair audit behavior unchanged | Special-type acceptable exception |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Monitoring Surface Classification**: The explicit catalog assigning each in-scope monitoring or workbench page to remediation required, minor alignment only, compliant or no-op, or special-type acceptable.
|
||||
- **Action Layer Contract**: The bounded rule that separates scope or context, navigation, surface utility, selection or focused-object work, and related or drilldown behavior on monitoring surfaces.
|
||||
- **Selection State**: The operator-visible distinction between no-selection monitoring mode and active-selection workbench mode.
|
||||
- **Scope Signal**: The context element that shows workspace or tenant scope, origin, or remembered context without behaving like a peer call to action.
|
||||
- **Special-Type Diagnostic Exception**: The documented allowance for a focused diagnostic page to expose repair actions when inconsistency exists without weakening the general monitoring hierarchy rule.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-193-001**: 100% of in-scope monitoring and workbench surfaces are explicitly classified in the spec and implementation notes.
|
||||
- **SC-193-002**: 100% of remediation-required surfaces show visibly separate context or scope, navigation, utility, and selected-object or related-action layers in acceptance review.
|
||||
- **SC-193-003**: On remediated queue or workbench surfaces, selected-object actions are absent or clearly subordinate when no valid selection exists and become prominent only when a valid selection exists.
|
||||
- **SC-193-004**: During acceptance walkthroughs, reviewers can correctly identify current scope, whether anything is selected, and the next actionable step on each remediated surface within 5 seconds.
|
||||
- **SC-193-005**: No compliant or no-op reference page receives a structural rebuild unless a documented minor-alignment finding exists.
|
||||
- **SC-193-006**: Regression coverage fails any newly introduced monitoring surface that treats scope as a peer CTA, mixes selection-bound actions with global surface controls in one flat lane, or introduces an undocumented exception.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
This feature is complete when:
|
||||
|
||||
- every in-scope monitoring and workbench surface is classified,
|
||||
- every remediated surface shows explicit action layers instead of a flat mixed header,
|
||||
- scope and context no longer read as peer CTAs on remediated pages,
|
||||
- selection-bound work actions only become prominent in an active work state,
|
||||
- navigation and work actions are clearly separated,
|
||||
- shared monitoring patterns are either confirmed or intentionally tightened,
|
||||
- calm bounded-scope pages are explicitly preserved,
|
||||
- a lightweight regression guard exists for this surface class,
|
||||
- and browser smoke checks confirm the visible hierarchy on remediated and exception surfaces.
|
||||
|
||||
## Recommended Sequencing
|
||||
|
||||
- Spec 194 should follow with governance friction hardening so structure and friction rules remain separate concerns.
|
||||
245
specs/193-monitoring-action-hierarchy/tasks.md
Normal file
245
specs/193-monitoring-action-hierarchy/tasks.md
Normal file
@ -0,0 +1,245 @@
|
||||
# Tasks: Monitoring Surface Action Hierarchy and Workbench Semantics
|
||||
|
||||
**Input**: Design documents from `/specs/193-monitoring-action-hierarchy/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/monitoring-action-hierarchy.logical.openapi.yaml`, `quickstart.md`
|
||||
|
||||
**Tests**: Required. This feature changes runtime behavior on existing Filament v5 / Livewire v4 monitoring surfaces, so Pest guard, feature, RBAC, and browser smoke coverage must be added or extended.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1 -> US2 -> US3 -> US4`, with `US1` as the MVP cut after the shared guard foundation is in place.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare dedicated test entry points for the monitoring-surface hierarchy slice.
|
||||
|
||||
- [X] T001 Create the Spec 193 guard test scaffold in `apps/platform/tests/Feature/Guards/Spec193MonitoringSurfaceHierarchyGuardTest.php`
|
||||
- [X] T002 [P] Create focused monitoring hierarchy test scaffolds in `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php` and `apps/platform/tests/Feature/Monitoring/OperationsHeaderHierarchyTest.php`
|
||||
- [X] T003 [P] Create the browser smoke scaffold in `apps/platform/tests/Browser/Spec193MonitoringSurfaceHierarchySmokeTest.php`
|
||||
|
||||
**Checkpoint**: Dedicated Spec 193 test entry points exist and the implementation can proceed without mixing this slice into unrelated suites.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Codify the shared monitoring-surface inventory and validation rules that every user story depends on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should start before this phase is complete.
|
||||
|
||||
- [X] T004 [P] Add Spec 193 inventory contract expectations in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||
- [X] T005 [P] Add Spec 193 validation-rule expectations in `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`
|
||||
- [X] T006 [P] Add completeness and exception-reason coverage in `apps/platform/tests/Feature/Guards/Spec193MonitoringSurfaceHierarchyGuardTest.php`
|
||||
- [X] T007 Implement the Spec 193 monitoring-surface inventory and retire the blanket Alerts exemption in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
|
||||
- [X] T008 Implement Spec 193 classification and exception validation in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||
|
||||
**Checkpoint**: The repo can enumerate, classify, and fail CI on undocumented monitoring-surface hierarchy regressions before any page-level refactor starts.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Review a Queue Without Header Ambiguity (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make Finding Exceptions Queue read as a quiet monitoring surface when nothing is selected and as a focused review workbench only when a decision-ready exception is active.
|
||||
|
||||
**Independent Test**: Open the queue with and without a selected exception and confirm that scope, utility, drilldown, and decision actions no longer render as one flat peer strip.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T009 [P] [US1] Add no-selection vs selected-workbench hierarchy coverage in `apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php`
|
||||
- [X] T010 [P] [US1] Extend approval and rejection authorization continuity coverage in `apps/platform/tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T011 [US1] Refactor layered header actions and selected-state visibility in `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
|
||||
- [X] T012 [US1] Align queue detail, utility, and related-drilldown rendering in `apps/platform/resources/views/filament/pages/monitoring/finding-exceptions-queue.blade.php`
|
||||
|
||||
**Checkpoint**: Finding Exceptions Queue is independently functional with a calm no-selection state and a distinct selected-exception work lane.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Read One Operation Run Without Mixed Context Signals (Priority: P1)
|
||||
|
||||
**Goal**: Make Operations and the tenantless operation run viewer separate scope, navigation, utility, related links, and follow-up actions so the monitoring flow reads clearly from list to detail.
|
||||
|
||||
**Independent Test**: Open Operations and drill into the tenantless viewer from multiple origin contexts, then verify that return navigation stays quieter than refresh or run follow-up actions and that related links do not compete with the main work lane.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T013 [P] [US2] Extend viewer hierarchy and calm-no-action coverage in `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
|
||||
- [X] T014 [P] [US2] Add operations landing header hierarchy coverage in `apps/platform/tests/Feature/Monitoring/OperationsHeaderHierarchyTest.php`
|
||||
- [X] T015 [P] [US2] Extend canonical navigation and related-link assertions in `apps/platform/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php` and `apps/platform/tests/Feature/Monitoring/OperationsRelatedNavigationTest.php`
|
||||
- [X] T016 [P] [US2] Extend shared scope-label and return-affordance coverage in `apps/platform/tests/Feature/OpsUx/OperateHubShellTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T017 [US2] Refactor viewer context, navigation, utility, and follow-up action layering in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- [X] T018 [US2] Align the tenantless operation viewer header rendering in `apps/platform/resources/views/filament/pages/operations/tenantless-operation-run-viewer.blade.php`
|
||||
- [X] T019 [US2] Refactor operations landing scope-reset and header hierarchy in `apps/platform/app/Filament/Pages/Monitoring/Operations.php`
|
||||
- [X] T020 [US2] Align operations landing context-bar and header rendering in `apps/platform/resources/views/filament/pages/monitoring/operations.blade.php`
|
||||
|
||||
**Checkpoint**: Operations and the tenantless run viewer are independently functional and clearly separate context, navigation, utility, and follow-up actions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Preserve Calm Monitoring Pages Without Forced Churn (Priority: P2)
|
||||
|
||||
**Goal**: Explicitly classify shared-pattern monitoring pages and bounded-scope reference pages so only genuinely ambiguous surfaces change while calm pages remain calm.
|
||||
|
||||
**Independent Test**: Review Alerts, Audit Log, alert deliveries, Evidence Overview, Review Register, Baseline Compare Landing, and Baseline Compare Matrix and confirm they either remain calm or receive only documented minor alignment.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T021 [P] [US3] Add minor-alignment coverage for the Alerts overview in `apps/platform/tests/Feature/Monitoring/AlertsHierarchyTest.php` and alert-delivery calm/deep-link behavior in `apps/platform/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php` and `apps/platform/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php`
|
||||
- [X] T022 [P] [US3] Extend Audit Log minor-alignment coverage in `apps/platform/tests/Feature/Monitoring/AuditLogInspectFlowTest.php` and `apps/platform/tests/Feature/Filament/AuditLogPageTest.php`
|
||||
- [X] T023 [P] [US3] Add calm-reference no-regression coverage for Evidence Overview and Review Register in `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` and `apps/platform/tests/Feature/TenantReview/TenantReviewRegisterTest.php`
|
||||
- [X] T024 [P] [US3] Add calm-reference no-regression coverage for Baseline Compare Landing and Baseline Compare Matrix in `apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T025 [US3] Add explicit monitoring-surface declaration and origin-context alignment in `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`
|
||||
- [X] T026 [US3] Tighten shared-pattern hierarchy only where needed in `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php` and `apps/platform/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php`
|
||||
|
||||
**Checkpoint**: Minor-alignment pages are explicit and bounded, while calm reference pages stay independently testable without cosmetic rebuilds.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Keep Special Diagnostic Surfaces Explicit (Priority: P3)
|
||||
|
||||
**Goal**: Preserve Tenant Diagnostics as the single documented diagnostic exception, with repair actions visible only when a real defect exists.
|
||||
|
||||
**Independent Test**: Open Tenant Diagnostics with and without repair-needed conditions and confirm that repair actions only appear when justified and remain protected by explicit exception coverage and existing access semantics.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T027 [P] [US4] Extend explicit exception-reason coverage in `apps/platform/tests/Feature/Guards/Spec193MonitoringSurfaceHierarchyGuardTest.php`
|
||||
- [X] T028 [P] [US4] Extend tenant diagnostics repair-state coverage in `apps/platform/tests/Feature/Filament/TenantDiagnosticsRepairsTest.php`
|
||||
- [X] T029 [P] [US4] Extend tenant diagnostics access semantics in `apps/platform/tests/Feature/TenantRBAC/TenantDiagnosticsAccessTest.php`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T030 [US4] Update repair-action visibility and explicit special-type declaration handling in `apps/platform/app/Filament/Pages/TenantDiagnostics.php`
|
||||
|
||||
**Checkpoint**: Tenant Diagnostics remains independently functional as an explicit exception surface without weakening the general monitoring hierarchy rule.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Lock the slice down with browser proof, copy review, and focused verification.
|
||||
|
||||
- [X] T031 [P] Add remediated, exception, and calm-reference browser smoke coverage in `apps/platform/tests/Browser/Spec193MonitoringSurfaceHierarchySmokeTest.php`
|
||||
- [X] T032 Review operator-facing labels, modal titles, notifications, and audit prose in `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Pages/Monitoring/Alerts.php`, `apps/platform/app/Filament/Pages/Monitoring/AuditLog.php`, `apps/platform/app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php`, and `apps/platform/app/Filament/Pages/TenantDiagnostics.php`
|
||||
- [X] T033 [P] Add explicit non-regression assertions for confirmation depth, reason capture, provider-dispatch semantics, and record-page header leakage in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` and `apps/platform/tests/Feature/Guards/Spec193MonitoringSurfaceHierarchyGuardTest.php`
|
||||
- [X] T034 [P] Run the focused Sail verification and formatting workflow from `specs/193-monitoring-action-hierarchy/quickstart.md` against the changed guard, feature, browser, and page files
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion; recommended MVP cut.
|
||||
- **User Story 2 (Phase 4)**: Depends on Foundational completion; can run in parallel with US1 if capacity allows, but is easier to review after US1 establishes the pattern.
|
||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion; can proceed in parallel with US1 or US2 because it focuses on classification preservation and minor alignment.
|
||||
- **User Story 4 (Phase 6)**: Depends on Foundational completion; can proceed in parallel with US3 once the exception contract exists.
|
||||
- **Polish (Phase 7)**: Depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependencies beyond Foundational.
|
||||
- **US2**: No dependencies beyond Foundational, but it reuses the hierarchy and context patterns proven in US1.
|
||||
- **US3**: No dependencies beyond Foundational; it validates that shared-pattern and bounded-scope pages stay intentionally calm.
|
||||
- **US4**: No dependencies beyond Foundational; it specializes the exception path after the inventory and validator rules exist.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the story tests first and confirm they fail before implementation.
|
||||
- Update page classes before finalizing any matching Blade view adjustments.
|
||||
- Keep each story independently shippable before moving to the next priority.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T002` and `T003` can run in parallel after `T001`.
|
||||
- `T004`, `T005`, and `T006` can run in parallel before implementing `T007` and `T008`.
|
||||
- Within US1, `T009` and `T010` can run in parallel.
|
||||
- Within US2, `T013`, `T014`, `T015`, and `T016` can run in parallel.
|
||||
- Within US3, `T021`, `T022`, `T023`, and `T024` can run in parallel.
|
||||
- Within US4, `T027`, `T028`, and `T029` can run in parallel.
|
||||
- `T031` and `T033` can run in parallel once all page-level changes are complete.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US1
|
||||
T009 Add no-selection vs selected-workbench hierarchy coverage in apps/platform/tests/Feature/Monitoring/FindingExceptionsQueueHierarchyTest.php
|
||||
T010 Extend approval and rejection authorization continuity coverage in apps/platform/tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US2
|
||||
T013 Extend viewer hierarchy coverage in apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
T014 Add operations landing header hierarchy coverage in apps/platform/tests/Feature/Monitoring/OperationsHeaderHierarchyTest.php
|
||||
T015 Extend canonical navigation and related-link assertions in apps/platform/tests/Feature/Monitoring/OperationsCanonicalUrlsTest.php and apps/platform/tests/Feature/Monitoring/OperationsRelatedNavigationTest.php
|
||||
T016 Extend shared scope-label and return-affordance coverage in apps/platform/tests/Feature/OpsUx/OperateHubShellTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US3
|
||||
T021 Add Alerts overview and alert-delivery minor-alignment coverage
|
||||
T022 Extend Audit Log minor-alignment coverage
|
||||
T023 Add Evidence Overview and Review Register no-regression coverage
|
||||
T024 Add Baseline Compare Landing and Matrix no-regression coverage
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 4
|
||||
|
||||
```bash
|
||||
# Parallel test pass for US4
|
||||
T027 Extend explicit exception-reason coverage in apps/platform/tests/Feature/Guards/Spec193MonitoringSurfaceHierarchyGuardTest.php
|
||||
T028 Extend tenant diagnostics repair-state coverage in apps/platform/tests/Feature/Filament/TenantDiagnosticsRepairsTest.php
|
||||
T029 Extend tenant diagnostics access semantics in apps/platform/tests/Feature/TenantRBAC/TenantDiagnosticsAccessTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational guard and inventory work.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate the queue hierarchy through the focused US1 tests.
|
||||
5. Stop and review the remediated workbench pattern before widening the slice.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Ship US1 to establish the first remediated monitoring workbench pattern.
|
||||
2. Add US2 to carry the same action hierarchy into Operations and the canonical run viewer.
|
||||
3. Add US3 to classify shared-pattern surfaces and preserve calm references without churn.
|
||||
4. Add US4 to formalize the Tenant Diagnostics exception path.
|
||||
5. Finish with explicit FR-193-019 non-regression assertions, browser smoke, and focused Sail verification from Phase 7.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor completes Setup and Foundational tasks.
|
||||
2. After Foundation is green:
|
||||
- Contributor A takes US1.
|
||||
- Contributor B takes US2.
|
||||
- Contributor C takes US3.
|
||||
- Contributor D takes US4.
|
||||
3. Merge back for Phase 7 browser smoke and focused verification.
|
||||
Loading…
Reference in New Issue
Block a user