Compare commits
2 Commits
168-tenant
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 37c6d0622c | |||
| 807d574d31 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -114,6 +114,10 @@ ## Active Technologies
|
||||
- PostgreSQL using existing `findings`, `finding_exceptions`, related decision tables, and existing DB-backed summary sources; no schema changes required (166-finding-governance-health)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams (167-derived-state-memoization)
|
||||
- PostgreSQL unchanged; feature adds no persistence and relies on request-local in-memory state only (167-derived-state-memoization)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167 (168-tenant-governance-aggregate-contract)
|
||||
- PostgreSQL unchanged; no new persistence, cache store, or durable summary artifac (168-tenant-governance-aggregate-contract)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ActionSurfaceDeclaration`, `ActionSurfaceValidator`, `ActionSurfaceDiscovery`, `ActionSurfaceExemptions`, and Filament Tables / Actions APIs (169-action-surface-v11)
|
||||
- PostgreSQL unchanged; no new persistence, cache store, queue payload, or durable artifac (169-action-surface-v11)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -133,8 +137,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 169-action-surface-v11: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ActionSurfaceDeclaration`, `ActionSurfaceValidator`, `ActionSurfaceDiscovery`, `ActionSurfaceExemptions`, and Filament Tables / Actions APIs
|
||||
- 168-tenant-governance-aggregate-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167
|
||||
- 167-derived-state-memoization: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ArtifactTruthPresenter`, `OperationUxPresenter`, `RelatedNavigationResolver`, `AppServiceProvider`, `BadgeCatalog`, `BadgeRenderer`, and current Filament resource/page seams
|
||||
- 166-finding-governance-health: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets
|
||||
- 165-baseline-summary-trust: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -1,32 +1,32 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 1.13.0 → 1.14.0
|
||||
- Version change: 1.14.0 -> 2.0.0
|
||||
- Modified principles:
|
||||
- Governance / Scope & Compliance → Governance / Scope, Compliance, and Review Expectations
|
||||
- Filament UI - Action Surface Contract -> Operator-Facing UI/UX Constitution v1 / Filament UI - Action Surface Contract
|
||||
- Filament UI - Layout & Information Architecture Standards (UX-001) -> Operator-Facing UI/UX Constitution v1 / Filament UI - Layout & Information Architecture Standards (UX-001)
|
||||
- Operator-facing UI Naming Standards (UI-NAMING-001) -> Operator-Facing UI/UX Constitution v1 / Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||
- Operator Surface Principles (OPSURF-001) -> Operator-Facing UI/UX Constitution v1 / Operator Surface Principles (OPSURF-001)
|
||||
- Spec Scope Fields (SCOPE-002) -> Operator-Facing UI/UX Constitution v1 / Spec Scope Fields (SCOPE-002)
|
||||
- Added sections:
|
||||
- Proportionality First (PROP-001)
|
||||
- No Premature Abstraction (ABSTR-001)
|
||||
- No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||
- No New State Without Behavioral Consequence (STATE-001)
|
||||
- UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
|
||||
- V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
||||
- One Truth, Few Layers (LAYER-001)
|
||||
- Spec Discipline Over Slice Proliferation (SPEC-DISC-001)
|
||||
- Tests Must Protect Business Truth (TEST-TRUTH-001)
|
||||
- Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
|
||||
- Mandatory Bloat Check for New Specs (BLOAT-001)
|
||||
- Default Bias (BIAS-001)
|
||||
- Operator-Facing UI/UX Constitution v1 (UI-CONST-001)
|
||||
- Surface Taxonomy (UI-SURF-001)
|
||||
- Hard Rules (UI-HARD-001)
|
||||
- Exception Model (UI-EX-001)
|
||||
- Enforcement Model (UI-REVIEW-001)
|
||||
- Immediate Retrofit Priorities
|
||||
- Appendix A - One-page Condensed Constitution
|
||||
- Appendix B - Feature Review Checklist
|
||||
- Appendix C - Red Flags for Future PRs
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .specify/memory/constitution.md
|
||||
- ✅ .specify/templates/spec-template.md
|
||||
- ✅ .specify/templates/plan-template.md
|
||||
- ✅ .specify/templates/tasks-template.md
|
||||
- ✅ docs/product/principles.md
|
||||
- ✅ docs/product/standards/README.md
|
||||
- ✅ docs/HANDOVER.md
|
||||
- ✅ docs/product/principles.md
|
||||
- ✅ Agents.md
|
||||
- Commands checked:
|
||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||
- Follow-up TODOs:
|
||||
@ -317,98 +317,266 @@ ### Scheduled/system runs (OPS-UX-SYS-001)
|
||||
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
||||
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
||||
|
||||
### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||
### Operator-Facing UI/UX Constitution v1 (UI-CONST-001)
|
||||
|
||||
For every new or modified Filament Resource / RelationManager / Page:
|
||||
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 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.
|
||||
|
||||
#### Surface Taxonomy (UI-SURF-001)
|
||||
|
||||
Every new admin surface MUST be assigned exactly one surface type before implementation. Ad-hoc interaction models are forbidden.
|
||||
|
||||
##### CRUD / List-first Resource
|
||||
- Purpose: scan, find, open, and selectively mutate many business records.
|
||||
- Primary behavior: Browse -> Open -> Decide / Mutate.
|
||||
- Primary model: one-click inspect/open. Full-row click is the default; identifier click is allowed only when full-row click conflicts with another dominant row mechanism.
|
||||
- Secondary actions: at most one inline non-destructive shortcut; everything else belongs in overflow.
|
||||
- Destructive actions: never inline beside inspect; only in overflow or the detail header; confirmation is mandatory.
|
||||
- Explicit View/Inspect: forbidden when row click or identifier click already opens the same destination.
|
||||
|
||||
##### Queue / Review Surface
|
||||
- Purpose: triage items, inspect them in context, decide, and continue working through the queue.
|
||||
- Primary behavior: Inspect in context -> Decide -> Continue.
|
||||
- Primary model: explicit Inspect using a slide-over, inline detail pane, or same-page inspect.
|
||||
- Secondary actions: only queue-relevant actions belong in the row.
|
||||
- Destructive actions: inline is allowed only when the destructive decision is part of the real queue work; irreversibility or high risk still requires confirmation.
|
||||
- Row click: forbidden by default.
|
||||
- Explicit View/Inspect: required unless the detail is already visible inline.
|
||||
|
||||
##### History / Audit Surface
|
||||
- Purpose: inspect immutable history, events, and evidence without losing chronology.
|
||||
- Primary behavior: Inspect event -> Follow trace -> Return to history context.
|
||||
- Primary model: explicit Inspect, preferably in a slide-over or same-page detail.
|
||||
- Secondary actions: related navigation only.
|
||||
- Destructive actions: normally none.
|
||||
- Row click: forbidden.
|
||||
- Explicit View/Inspect: required.
|
||||
|
||||
##### Config-lite Resource
|
||||
- Purpose: manage small, low-cardinality configuration where edit is effectively the detail surface.
|
||||
- Primary behavior: Open config -> Adjust.
|
||||
- Primary model: edit-as-inspect.
|
||||
- Secondary actions: minimal and usually limited to Edit or overflow.
|
||||
- Destructive actions: overflow or detail header only.
|
||||
- Row click: allowed when it opens Edit directly and no separate View surface exists.
|
||||
- Explicit View/Inspect: forbidden.
|
||||
|
||||
##### Read-only Registry / Report Surface
|
||||
- Purpose: inspect, compare, reference, and export immutable or mostly read-only artifacts.
|
||||
- Primary behavior: Scan -> Open detail -> Reference / Export.
|
||||
- Primary model: row click or identifier click to detail.
|
||||
- Secondary actions: optional single inline non-destructive shortcut when it serves the operator flow.
|
||||
- Destructive actions: normally none; if they exist they belong in detail only.
|
||||
- Explicit View/Inspect: forbidden when a functional one-click open already exists.
|
||||
|
||||
##### Detail-first Operational Surface
|
||||
- Purpose: fully understand one operational record, including state, truth, context, and next steps.
|
||||
- Primary behavior: Read -> Understand -> Act / Navigate.
|
||||
- Primary model: dedicated detail page or dedicated operational page.
|
||||
- Secondary actions: header actions and related-link groups.
|
||||
- Destructive actions: detail header or grouped header actions only, always with confirmation.
|
||||
- Row click and explicit View/Inspect: not applicable.
|
||||
|
||||
#### Hard Rules (UI-HARD-001)
|
||||
|
||||
##### Primary inspect model
|
||||
- Every list surface MUST expose exactly one primary inspect/open model.
|
||||
- A surface MUST NOT offer row click, identifier click, and explicit View/Inspect for the same destination as parallel primary models.
|
||||
- CRUD / List-first and Read-only Registry / Report surfaces MUST provide an obvious one-click open path.
|
||||
- Queue / Review and History / Audit surfaces MUST use explicit Inspect rather than row-click navigation.
|
||||
|
||||
##### Row-click semantics
|
||||
- Full-row click is the default for CRUD / List-first and Read-only Registry / Report surfaces.
|
||||
- Identifier-only click is allowed only when full-row click would conflict with another dominant row behavior such as selection-heavy interaction, expand/collapse, drag/sort, or another primary row mechanism.
|
||||
- When row click is enabled, the row MUST feel consistent. Silent split behavior inside the same row is forbidden.
|
||||
- Edit-as-inspect is allowed only for Config-lite resources.
|
||||
|
||||
##### View and Inspect actions
|
||||
- Explicit View MUST NOT exist when the same destination is already opened through row click or identifier click.
|
||||
- Explicit Inspect is the default only for Queue / Review, History / Audit, and explicitly catalogued exceptions.
|
||||
- View and Inspect MUST NOT be treated as interchangeable labels. If the interaction preserves context and behaves unlike ordinary navigation, it is Inspect, not View.
|
||||
|
||||
##### Action hierarchy
|
||||
- Every surface MUST distinguish between the primary inspect/open action, secondary safe actions, destructive actions, and long-running workflow launches.
|
||||
- Standard CRUD and Read-only Registry rows MUST NOT exceed the primary open interaction plus one inline safe shortcut.
|
||||
- All other secondary actions MUST move to overflow.
|
||||
- Long-running workflow launches such as sync, compare, verify, generate, consent, setup, or retry SHOULD live in list headers or detail headers rather than in every row.
|
||||
|
||||
##### Destructive actions
|
||||
- Destructive actions MUST NOT appear inline beside the primary inspect interaction on standard CRUD, Config-lite, or Read-only Registry surfaces.
|
||||
- Destructive actions MUST live in overflow or the detail header.
|
||||
- Destructive actions MUST use confirmation.
|
||||
- High-risk or high-volume destructive bulk actions SHOULD use typed confirmation.
|
||||
- The Queue Decision exception applies only when the destructive decision is part of the actual queue work.
|
||||
|
||||
##### Overflow and More
|
||||
- Overflow actions MUST follow one product-wide pattern per surface class.
|
||||
- Mixed labeled-overflow versus icon-only overflow patterns inside the same surface class are forbidden unless an approved exception documents why.
|
||||
- Empty `ActionGroup` and empty `BulkActionGroup` are forbidden.
|
||||
- Placeholder UI added only to satisfy a contract or slot is forbidden.
|
||||
|
||||
##### Bulk actions
|
||||
- Bulk actions are allowed only when they are safe enough, materially faster than row-by-row execution, and genuinely fit the surface.
|
||||
- A surface with no real bulk need MUST NOT render bulk UI.
|
||||
- Bulk destructive actions follow the same protection rules as row destructive actions, with stricter confirmation and review expectations.
|
||||
|
||||
##### Row label length and action budget
|
||||
- Inline row action labels MUST stay short and SHOULD be one or two words.
|
||||
- Long workflow labels belong in overflow, headers, or detail surfaces.
|
||||
- Standard list rows MUST NOT become control centers for onboarding recovery, provider management, consent flows, RBAC setup, diagnostics, and destructive lifecycle actions all at once.
|
||||
|
||||
##### Scope and context semantics
|
||||
- Scope chips, tenant pills, and similar context signals MUST correspond to real scoping behavior.
|
||||
- A scope signal MUST NOT be shown when it neither scopes the displayed data nor materially changes the action targets.
|
||||
- Remembered context is allowed only when labeled clearly as reference context rather than active scope.
|
||||
- Cross-panel navigation MUST NOT imply that the operator remains inside the same logical scope when that is not true.
|
||||
|
||||
##### Canonical navigation and terminology
|
||||
- Every domain object MUST have one canonical collection noun and one canonical singular noun.
|
||||
- The same domain object MUST NOT use competing primary nouns across shells.
|
||||
- The Operations domain MUST use one canonical collection noun. Parallel primary nouns such as Runs beside Operations are forbidden.
|
||||
- Cross-panel navigation is allowed only when it lands on a canonical surface, uses stable nouns, and keeps back navigation clear.
|
||||
|
||||
##### Visibility of critical operational truth
|
||||
- Critical operational truth MUST be visible by default.
|
||||
- It MUST NOT be hidden only in default-off columns, tooltips, helper text, overflow menus, or detail pages when list decisions depend on it.
|
||||
- Lifecycle truth, operability truth, health truth, execution outcome, trust/confidence, and next action MUST remain separate semantic dimensions.
|
||||
- One badge, column, or label MUST NOT collapse multiple truth dimensions into a generic status.
|
||||
|
||||
##### Row density and scanability
|
||||
- Standard CRUD lists MUST remain scanable.
|
||||
- Outside Queue / Review and History / Audit exceptions, each row MAY contain at most one multi-line explanatory column and at most one prose-heavy explanatory context.
|
||||
- Standard CRUD rows MUST NOT carry more than one sentence of flowing prose.
|
||||
- Next-step prose belongs in detail, inspect, or queue surfaces, not in ordinary CRUD rows.
|
||||
|
||||
##### Custom abstractions
|
||||
- Custom UI abstractions MAY document and validate, but they MUST NOT create declaration-only safety that diverges from real behavior.
|
||||
- Contract systems MUST NOT force placeholder UI.
|
||||
- Behavior matters more than declaration. If declared conformance and rendered behavior differ, the surface is non-conformant.
|
||||
- A feature MUST NOT ship when its implemented interaction semantics contradict its declared surface type.
|
||||
|
||||
#### Exception Model (UI-EX-001)
|
||||
|
||||
Only catalogued exception types are allowed. Every exception MUST be named in the spec, reference its exception type, include a reason block, be called out explicitly in the PR, and carry at least one dedicated test.
|
||||
|
||||
##### Queue Decision Exception
|
||||
- Allowed when per-item decision-making is the real queue work.
|
||||
- Guardrails: Inspect remains available unless detail is already inline; irreversible decisions require confirmation; unrelated maintenance actions do not join the row.
|
||||
|
||||
##### History In-place Inspect Exception
|
||||
- Allowed when leaving the page would break chronology or traceability.
|
||||
- Guardrails: explicit Inspect is mandatory; row click is forbidden; generic mutation rails are forbidden.
|
||||
|
||||
##### Config-lite Edit-as-Inspect Exception
|
||||
- Allowed when a separate View surface would add no value.
|
||||
- Guardrails: no parallel View surface; no high-risk destructive flow as the default entry point.
|
||||
|
||||
##### Read-only Shortcut Exception
|
||||
- Allowed for exactly one dominant non-destructive shortcut.
|
||||
- Guardrails: inspect/open remains dominant; only one shortcut exists; the shortcut does not compete with the primary open path.
|
||||
|
||||
##### Cross-panel Canonical Route Exception
|
||||
- Allowed when only one canonical surface makes sense.
|
||||
- Guardrails: nouns stay stable; shell transition is explicit; back navigation is clear; scope signals remain truthful.
|
||||
|
||||
#### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
||||
|
||||
For every new or modified Filament Resource, RelationManager, or Page:
|
||||
|
||||
Required surfaces
|
||||
- List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
|
||||
- Inspect affordance (List/Table): Every table MUST provide a record inspection affordance.
|
||||
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
|
||||
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
|
||||
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
|
||||
- View/Detail MUST be sectioned (e.g., Infolist Sections / Cards); avoid long ungrouped field lists.
|
||||
- Create/Edit MUST provide consistent Save/Cancel UX.
|
||||
- List/Table MUST define Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
|
||||
- Every table MUST provide a record inspection affordance that matches its surface type.
|
||||
- Accepted forms are `recordUrl()` row click, a primary linked column, or an explicit row action when the taxonomy requires Inspect.
|
||||
- CRUD / List-first, Config-lite, and Read-only Registry surfaces MUST NOT render a redundant View action when the same destination is already available through row click or identifier click.
|
||||
- Queue / Review and History / Audit surfaces MAY use a lone explicit Inspect action because context-preserving inspect is the primary interaction.
|
||||
- View/Detail MUST define header actions and MUST keep destructive actions grouped and confirmed.
|
||||
- View/Detail MUST be sectioned using Infolists, Sections, Cards, Tabs, or equivalent composable structure.
|
||||
- Create/Edit MUST provide consistent Save and Cancel UX.
|
||||
|
||||
Grouping & safety
|
||||
- Max 2 visible Row Actions (typically View/Edit). Everything else MUST be in an ActionGroup “More”.
|
||||
- Bulk actions MUST be grouped via BulkActionGroup.
|
||||
- RelationManagers MUST follow the same action surface rules (grouped row actions, bulk actions where applicable, inspection affordance).
|
||||
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large/bulk changes.
|
||||
Grouping and safety
|
||||
- Standard CRUD and Read-only Registry rows MUST NOT exceed inspect/open plus one inline safe shortcut.
|
||||
- Queue / Review rows MAY expose inline decision actions only when allowed by UI-EX-001.
|
||||
- Everything else MUST move to `ActionGroup::make()` or the detail header.
|
||||
- Bulk actions MUST be grouped via `BulkActionGroup` only when the surface has a real bulk use case.
|
||||
- Empty `ActionGroup` and `BulkActionGroup` are forbidden.
|
||||
- Destructive actions MUST NOT be primary and MUST require confirmation; typed confirmation MAY be required for large or high-risk bulk changes.
|
||||
- Relevant mutations MUST write an audit log entry.
|
||||
|
||||
RBAC enforcement
|
||||
- Non-member access MUST abort(404) and MUST NOT leak existence.
|
||||
- Member without capability: UI visible but disabled with tooltip; server-side MUST abort(403).
|
||||
- Central enforcement helpers (tenant/workspace UI enforcement) MUST be used for gating.
|
||||
- Members without capability MAY see disabled actions with helper text, but server-side execution MUST still abort(403).
|
||||
- Central tenant and workspace UI enforcement helpers MUST be used for gating.
|
||||
|
||||
Spec / DoD gates
|
||||
- Every spec MUST include a “UI Action Matrix”.
|
||||
- A change is not “Done” unless the Action Surface Contract is met OR an explicit exemption exists with documented reason.
|
||||
- CI MUST run an automated Action Surface Contract check (test suite and/or command) that fails when required surfaces are missing.
|
||||
Behavior over declaration
|
||||
- Every spec MUST include both a UI/UX Surface Classification and a UI Action Matrix.
|
||||
- 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.
|
||||
|
||||
### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
#### Filament UI — Layout & Information Architecture Standards (UX-001)
|
||||
|
||||
Goal: Demo-level, enterprise-grade admin UX. These rules are NON-NEGOTIABLE for new or modified Filament screens.
|
||||
Goal: operator-facing Filament screens MUST feel enterprise-grade, legible, and decisive.
|
||||
|
||||
Page layout
|
||||
- Create/Edit MUST default to a Main/Aside layout using a 3-column grid with `Main=columnSpan(2)` and `Aside=columnSpan(1)`.
|
||||
- All fields MUST be inside Sections/Cards. No “naked” inputs at the root schema level.
|
||||
- Main contains domain definition/content. Aside contains status/meta (status, version label, owner, scope selectors, timestamps).
|
||||
- Related data (assignments, relations, evidence, runs, findings, etc.) MUST render as separate Sections below the main/aside grid (or as tabs/sub-navigation), never mixed as unstructured long lists.
|
||||
- All fields MUST live inside Sections or Cards. Naked root-level inputs are forbidden.
|
||||
- Main content carries domain definition and working content. Aside carries status and meta such as scope, owner, timestamps, or version labels.
|
||||
- Related data MUST render as separate sections, tabs, or subordinate surfaces rather than as one long unstructured form or detail page.
|
||||
|
||||
View pages
|
||||
- View/Detail MUST be a read-only experience using Infolists (or equivalent), not disabled edit forms.
|
||||
- Status-like values MUST render as badges/chips using the centralized badge semantics (BADGE-001).
|
||||
- Long text MUST render as readable prose (not textarea styling).
|
||||
- View/Detail MUST be a read-only surface built with Infolists or an equivalent read-first structure, not disabled edit forms.
|
||||
- Status-like values MUST render via BADGE-001 semantics.
|
||||
- Long text MUST read like prose, not like disabled textarea output.
|
||||
|
||||
Empty states
|
||||
- Empty lists/tables MUST show: a specific title, one-sentence explanation, and exactly ONE primary CTA in the empty state.
|
||||
- When non-empty, the primary CTA MUST move to the table header (top-right) and MUST NOT be duplicated in the empty state.
|
||||
- Empty lists and tables MUST show a specific title, a one-sentence explanation, and exactly one primary CTA.
|
||||
- When records exist, that primary CTA moves to the header and MUST NOT be duplicated in the empty state shell.
|
||||
|
||||
Actions & flows
|
||||
- Pages SHOULD expose at most 1 primary header action and 1 secondary action; all other actions MUST be grouped (ActionGroup / BulkActionGroup).
|
||||
- Multi-step or high-risk flows MUST use a Wizard (e.g., capture/compare/restore with preview + confirmation).
|
||||
- Destructive actions MUST remain non-primary and require confirmation (RBAC-UX-005).
|
||||
Actions and flows
|
||||
- Pages SHOULD expose at most one primary header action and one secondary header action; all others belong in groups.
|
||||
- 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.
|
||||
|
||||
Table work-surface defaults
|
||||
- Tables SHOULD provide search (when the dataset can grow), a meaningful default sort, and filters for core dimensions (status/severity/type/tenant/time-range).
|
||||
- Tables MUST render key statuses as badges/chips (BADGE-001) and avoid ad-hoc status mappings.
|
||||
Table defaults
|
||||
- Tables SHOULD provide search when the dataset can grow, a meaningful default sort, and filters for core dimensions.
|
||||
- Standard CRUD tables MUST stay scanable and MUST NOT rely on row prose to communicate next steps.
|
||||
- Critical operational truth that informs list decisions MUST be default-visible.
|
||||
|
||||
Enforcement
|
||||
- New resources/pages SHOULD use shared layout builders (e.g., `MainAsideForm`, `MainAsideInfolist`, `StandardTableDefaults`) to keep screens consistent.
|
||||
- A change is not “Done” unless UX-001 is satisfied OR an explicit exemption exists with a documented rationale in the spec/PR.
|
||||
- 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.
|
||||
|
||||
### Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||
#### Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||
|
||||
Goal: operator-facing actions, run labels, notifications, audit prose, and related UI copy MUST use consistent,
|
||||
enterprise-grade product language.
|
||||
Goal: operator-facing actions, runs, notifications, audit prose, and navigation MUST use one clear domain vocabulary.
|
||||
|
||||
Naming model
|
||||
- Operator-facing copy MUST distinguish four layers: Scope, Source/Domain, Operation, and Target Object.
|
||||
- Scope terms (`Workspace`, `Tenant`) describe execution context and MUST NOT be used as the primary action label unless they are the actual target object.
|
||||
- Source/Domain terms (`Intune`, `Entra`, `Teams`, future providers) are secondary and MUST NOT lead the primary label unless the current screen presents competing sources that need explicit disambiguation.
|
||||
- Operator-facing copy MUST distinguish Scope, Source/Domain, Operation, and Target Object.
|
||||
- Scope terms such as Workspace and Tenant describe execution context and MUST NOT become the primary action label unless they are the actual target object.
|
||||
- Source/domain terms such as Intune or Entra are secondary and lead only when same-screen disambiguation genuinely requires them.
|
||||
|
||||
Primary action labels
|
||||
- Primary buttons, header actions, and menu actions MUST use `Verb + Object`.
|
||||
- Preferred examples: `Sync policies`, `Sync groups`, `Capture baseline`, `Compare baseline`, `Restore policy`, `Run review`, `Export review pack`.
|
||||
- Forbidden examples: `Sync from tenant`, `Backup tenant`, `Compare tenant`, `Sync from Intune`, `Run tenant sync now`, `Start inventory refresh from provider`.
|
||||
Primary labels
|
||||
- Primary buttons, header actions, and menu actions MUST use Verb + Object.
|
||||
- Preferred examples are `Sync policies`, `Sync groups`, `Capture baseline`, `Compare baseline`, `Restore policy`, `Run review`, and `Export review pack`.
|
||||
- Implementation-first labels such as `Sync from tenant`, `Sync from Intune`, `Run tenant sync now`, or `Start inventory refresh from provider` are forbidden.
|
||||
|
||||
Domain vocabulary
|
||||
- Operator-facing copy MUST prefer product-domain objects such as `policies`, `groups`, `baseline`, `findings`, `review pack`, `alerts`, and `operations`.
|
||||
- Primary operator-facing copy MUST NOT use implementation-first terms such as `provider`, `gateway`, `resolver`, `collector`, `contract registry`, or `job dispatch`.
|
||||
- Source/domain details MAY appear in modal descriptions, helper text, run metadata, audit metadata, and notifications when needed for precision.
|
||||
Canonical nouns and routes
|
||||
- Every domain object MUST keep one canonical collection noun and one canonical singular noun.
|
||||
- Cross-shell or cross-panel navigation MUST preserve the same noun.
|
||||
- Operations is the canonical collection noun for run records. Runs MUST NOT appear as a competing primary collection noun.
|
||||
|
||||
Run, notification, and audit semantics
|
||||
- Visible run titles MUST use the same domain vocabulary as the initiating action and SHOULD be concise noun phrases such as `Policy sync`, `Baseline capture`, `Baseline compare`, `Policy restore`, and `Tenant review`.
|
||||
- Notifications MUST use either `{Object} {state}` or `{Operation} {result}` and remain short, e.g. `Policy sync queued`, `Policy sync completed`, `Policy sync failed`, `Baseline compare detected drift`.
|
||||
- Audit prose MUST use the same operator-facing language, e.g. `{actor} queued policy sync`, `{actor} captured baseline`, `{actor} reopened finding`.
|
||||
- The same user-visible action MUST keep the same domain vocabulary across button labels, modal titles, run titles, notifications, and audit prose.
|
||||
- Visible run titles MUST use the same domain vocabulary as the initiating action and SHOULD remain concise noun phrases such as `Policy sync`, `Baseline capture`, `Baseline compare`, `Policy restore`, and `Tenant review`.
|
||||
- Notifications MUST use either `{Object} {state}` or `{Operation} {result}` and remain short.
|
||||
- Audit prose MUST use the same operator-facing language as the initiating action.
|
||||
- The same user-visible action MUST keep the same domain vocabulary across button labels, modal titles, run titles, notifications, audit prose, and related navigation.
|
||||
|
||||
Verb standard
|
||||
- Preferred verbs are `Sync`, `Capture`, `Compare`, `Restore`, `Review`, `Export`, `Open`, `Archive`, `Resolve`, `Reopen`, and `Assign`.
|
||||
- `Start`, `Execute`, `Trigger`, and `Perform` SHOULD be avoided for operator-facing copy unless there is a deliberate domain reason.
|
||||
- `Run` MAY be used only when the object is itself run-like, such as `Run review` or `Run compare`; it MUST NOT be the generic fallback verb for all operations.
|
||||
- `Start`, `Execute`, `Trigger`, and `Perform` SHOULD be avoided unless the domain specifically requires them.
|
||||
- `Run` MAY be used only when the object is itself run-like, such as `Run review`; it MUST NOT become the fallback verb for everything.
|
||||
|
||||
Current binding decision
|
||||
- The Policies screen primary action MUST be `Sync policies`.
|
||||
@ -417,75 +585,125 @@ ### Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||
- The visible run label for that action MUST be `Policy sync`.
|
||||
- The audit prose for that action MUST be `{actor} queued policy sync`.
|
||||
|
||||
### Operator Surface Principles (OPSURF-001)
|
||||
#### Operator Surface Principles (OPSURF-001)
|
||||
|
||||
Goal: operator-facing surfaces MUST optimize for the primary working audience rather than raw implementation visibility.
|
||||
Goal: operator-facing surfaces MUST optimize for the operator's working question instead of raw implementation visibility.
|
||||
|
||||
Operator-first default surfaces
|
||||
- `/admin` is operator-first.
|
||||
- Default-visible content MUST use operator-facing language, clear scope, and actionable status communication.
|
||||
- Default-visible content MUST use operator language, clear scope, and actionable status communication.
|
||||
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
|
||||
|
||||
Progressive disclosure for diagnostics
|
||||
- Diagnostic detail MAY be available where needed, but it MUST be secondary and explicitly revealed.
|
||||
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces, drawers, tabs, accordions, or modals rather than primary content.
|
||||
- A surface MUST NOT require operators to parse raw JSON or provider-specific field names to understand the primary state or next action.
|
||||
- Diagnostic detail MAY exist, but it MUST be secondary and explicitly revealed.
|
||||
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces rather than the primary content region.
|
||||
- Operators MUST NOT need to parse raw payloads to understand current state or next action.
|
||||
|
||||
Distinct status dimensions
|
||||
- Operator-facing surfaces MUST distinguish at least the following dimensions when they all exist in the domain:
|
||||
- execution outcome
|
||||
- data completeness
|
||||
- governance result
|
||||
- lifecycle or readiness state
|
||||
- These dimensions MUST NOT be collapsed into a single ambiguous status model.
|
||||
- If a surface summarizes multiple status dimensions, the default-visible presentation MUST label each dimension explicitly.
|
||||
Distinct truth dimensions
|
||||
- When the domain has execution outcome, data completeness, governance result, lifecycle or readiness state, operability truth, health truth, trust/confidence, or next action semantics, the surface MUST keep them explicit instead of collapsing them into one ambiguous status.
|
||||
- If multiple truth dimensions are summarized, the default-visible UI MUST label each dimension clearly.
|
||||
|
||||
Explicit mutation scope
|
||||
- Every action that changes state MUST communicate before execution whether it affects:
|
||||
- TenantPilot only
|
||||
- the Microsoft tenant
|
||||
- simulation only
|
||||
- Mutation scope MUST be understandable from the action label, helper text, confirmation copy, preview, or nearby status copy before the operator commits.
|
||||
- A mutating action MUST NOT rely on hidden implementation knowledge to communicate its blast radius.
|
||||
- Every state-changing action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
||||
- Mutation scope MUST be understandable from nearby action copy, helper text, preview, or confirmation.
|
||||
|
||||
Safe execution for dangerous actions
|
||||
- Dangerous actions MUST follow a consistent safe-execution pattern:
|
||||
- configuration
|
||||
- safety checks or simulation
|
||||
- preview
|
||||
- hard confirmation where required
|
||||
- execute
|
||||
- One-click destructive actions are not acceptable for high-blast-radius operations.
|
||||
- When a full multi-step flow is not feasible, the spec MUST document the explicit exemption and the replacement safeguards.
|
||||
Safe execution
|
||||
- Dangerous actions MUST follow a consistent safety flow: configuration, safety checks or simulation, preview, hard confirmation where required, then execution.
|
||||
- One-click high-blast-radius actions are forbidden unless an approved exception documents replacement safeguards.
|
||||
|
||||
Explicit workspace and tenant context
|
||||
- Workspace and tenant scope MUST remain explicit in navigation, actions, and page semantics.
|
||||
- Tenant-scoped surfaces MUST NOT silently expose workspace-wide actions.
|
||||
- Canonical workspace views that reference tenant-owned records MUST make the workspace and tenant context legible before the operator acts.
|
||||
- Workspace and tenant context MUST remain explicit in navigation, action copy, and page semantics.
|
||||
- Tenant surfaces MUST NOT silently expose workspace-wide actions.
|
||||
- Canonical workspace views that operate on tenant-owned records MUST make both workspace and tenant context legible before the operator acts.
|
||||
|
||||
Critical truth visibility and scanability
|
||||
- Critical operational truth MUST be default-visible wherever the list or summary surface is used to prepare decisions.
|
||||
- Standard CRUD surfaces MUST preserve scanability and MUST avoid collapsing multiple truth dimensions into one generic badge or one prose-heavy row.
|
||||
|
||||
Page contract requirement
|
||||
- Every new or materially refactored operator-facing page MUST define:
|
||||
- primary persona
|
||||
- surface type
|
||||
- primary operator question
|
||||
- default-visible information
|
||||
- diagnostics-only information
|
||||
- status dimensions used
|
||||
- mutation scope
|
||||
- primary actions
|
||||
- dangerous actions
|
||||
- This page contract MUST be recorded in the governing spec and kept in sync when the page semantics materially change.
|
||||
- Every new or materially refactored operator-facing page MUST define the primary persona, surface type, primary operator question, default-visible information, diagnostics-only information, status dimensions used, mutation scope, primary actions, and dangerous actions.
|
||||
- The page contract MUST live in the governing spec and stay in sync with implementation.
|
||||
|
||||
Spec Scope Fields (SCOPE-002)
|
||||
#### Spec Scope Fields (SCOPE-002)
|
||||
|
||||
- Every feature spec MUST declare:
|
||||
- Scope: workspace | tenant | canonical-view
|
||||
- Primary Routes
|
||||
- Data Ownership: workspace-owned vs tenant-owned tables/records impacted
|
||||
- RBAC: membership requirements + capability requirements
|
||||
- For canonical-view specs, the spec MUST define:
|
||||
- Default filter behavior when tenant-context is active (e.g., prefilter to current tenant)
|
||||
- Explicit entitlement checks that prevent cross-tenant leakage
|
||||
- Every feature spec MUST declare Scope, Primary Routes, Data Ownership, and RBAC requirements.
|
||||
- Canonical-view specs MUST define the default filter behavior when tenant context is active and the entitlement checks that prevent cross-tenant leakage.
|
||||
|
||||
#### 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.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
#### Immediate Retrofit Priorities
|
||||
|
||||
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.
|
||||
- Wave 1 is done only when each surface has exactly one primary inspect model, destructive actions are protected, and placeholder groups are gone.
|
||||
|
||||
Wave 2 - Scope, nouns, and truth
|
||||
- Then fix scope and context leaks, stabilize canonical nouns, make cross-panel transitions explicit, move critical operational truth to default-visible regions, and reduce prose-heavy dense rows.
|
||||
|
||||
Wave 3 - Enforcement
|
||||
- Then move the constitution into repo enforcement, require the PR checklist, anchor guard tests, and trim old declaration-only action-surface checks until behavior is the governing truth.
|
||||
|
||||
#### Appendix A - One-page Condensed Constitution
|
||||
|
||||
- Every admin surface has one surface type.
|
||||
- Every list has exactly one primary inspect/open model.
|
||||
- CRUD and Registry surfaces use one-click open.
|
||||
- Queue and Audit surfaces use explicit Inspect.
|
||||
- Edit-as-inspect exists only for Config-lite resources.
|
||||
- Standard lists expose at most one inline safe shortcut.
|
||||
- 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.
|
||||
- Scope chips must be truthful.
|
||||
- Domain nouns are canonical and stable.
|
||||
- Critical operational truth is default-visible.
|
||||
- Semantic truth dimensions are not collapsed into a generic status.
|
||||
- Standard lists stay scanable.
|
||||
- Exceptions are catalogued, justified, and tested.
|
||||
- Features with ambiguous interaction semantics do not ship.
|
||||
|
||||
#### Appendix B - Feature Review Checklist
|
||||
|
||||
- Surface type is declared.
|
||||
- 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.
|
||||
- Secondary actions are grouped correctly.
|
||||
- Destructive actions are placed correctly.
|
||||
- Overflow is not empty.
|
||||
- Bulk is justified.
|
||||
- Inline labels are short.
|
||||
- Scope signals are truthful.
|
||||
- Canonical nouns stay consistent.
|
||||
- Critical truth is visible.
|
||||
- Scanability is preserved.
|
||||
- Exceptions are documented and tested.
|
||||
|
||||
#### Appendix C - Red Flags for Future PRs
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- Critical health or operability truth is hidden by default.
|
||||
- A contract claims conformance while the rendered UI behaves differently.
|
||||
|
||||
### Data Minimization & Safe Logging
|
||||
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||
@ -569,4 +787,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 1.14.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-27
|
||||
**Version**: 2.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-28
|
||||
|
||||
@ -57,6 +57,11 @@ ## Constitution Check
|
||||
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||
- 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
|
||||
- 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
|
||||
- UI/UX placeholder ban (UI-HARD-001): empty `ActionGroup` / `BulkActionGroup` placeholders and declaration-only UI conformance are forbidden
|
||||
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
||||
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
||||
@ -64,7 +69,7 @@ ## Constitution Check
|
||||
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
|
||||
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
||||
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||
- Filament UI 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; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||
## Project Structure
|
||||
|
||||
|
||||
@ -17,6 +17,15 @@ ## 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]
|
||||
|
||||
## 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.
|
||||
|
||||
| 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 |
|
||||
|
||||
## 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.
|
||||
@ -172,6 +181,19 @@ ## 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,
|
||||
- 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 secondary actions live,
|
||||
- where destructive actions live,
|
||||
- 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 (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,
|
||||
@ -190,6 +212,8 @@ ## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||
The same section MUST state that each affected surface has exactly one primary inspect/open model, that redundant View actions are absent,
|
||||
that empty `ActionGroup` / `BulkActionGroup` placeholders are absent, and that destructive actions follow the required placement rules for the chosen surface type.
|
||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||
@ -220,7 +244,7 @@ ## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||
|
||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||
RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|
||||
@ -39,22 +39,31 @@ # 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:
|
||||
- filling the spec’s UI/UX Surface Classification for every affected surface,
|
||||
- filling the spec’s Operator Surface Contract for every affected page,
|
||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
||||
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
||||
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
||||
- keeping canonical nouns stable across routes, buttons, run titles, notifications, and audit prose,
|
||||
- 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,
|
||||
- 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 a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action),
|
||||
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
|
||||
- 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,
|
||||
- moving additional secondary actions into More or the detail header,
|
||||
- placing destructive actions in More or the detail header for standard lists and using catalogued exceptions only where allowed,
|
||||
- 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 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.
|
||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||
|
||||
@ -15,6 +15,8 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -133,7 +135,11 @@ public function mount(): void
|
||||
|
||||
public function refreshStats(): void
|
||||
{
|
||||
$stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel());
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
$aggregate = $tenant instanceof Tenant
|
||||
? $this->governanceAggregate($tenant, $stats)
|
||||
: null;
|
||||
|
||||
$this->state = $stats->state;
|
||||
$this->message = $stats->message;
|
||||
@ -169,7 +175,7 @@ public function refreshStats(): void
|
||||
: null;
|
||||
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
||||
$this->summaryAssessment = $stats->summaryAssessment()->toArray();
|
||||
$this->summaryAssessment = $aggregate?->summaryAssessment->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -419,4 +425,15 @@ public function getRunUrl(): ?string
|
||||
|
||||
return OperationRunLinks::view($this->operationRunId, $tenant);
|
||||
}
|
||||
|
||||
private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate
|
||||
{
|
||||
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||
|
||||
/** @var TenantGovernanceAggregate $aggregate */
|
||||
$aggregate = $resolver->fromStats($tenant, $stats);
|
||||
|
||||
return $aggregate;
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,10 @@
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
@ -52,6 +56,20 @@ class InventoryCoverage extends Page implements HasTable
|
||||
|
||||
protected string $view = 'filament.pages.inventory-coverage';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->withDefaults(new ActionSurfaceDefaults(
|
||||
moreGroupLabel: 'More',
|
||||
exportIsDefaultBulkActionForReadOnly: false,
|
||||
))
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory coverage stays read-only and uses KPI widgets instead of header actions.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full coverage matrix.');
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -68,7 +69,7 @@ class AuditLog extends Page implements HasTable
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::HistoryAudit)
|
||||
->withDefaults(new ActionSurfaceDefaults(
|
||||
moreGroupLabel: 'More',
|
||||
exportIsDefaultBulkActionForReadOnly: false,
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -46,7 +47,7 @@ class EvidenceOverview extends Page
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -69,7 +70,7 @@ class FindingExceptionsQueue extends Page implements HasTable
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::QueueReview)
|
||||
->withDefaults(new ActionSurfaceDefaults(
|
||||
moreGroupLabel: 'More',
|
||||
exportIsDefaultBulkActionForReadOnly: false,
|
||||
|
||||
@ -14,6 +14,12 @@
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -50,6 +56,20 @@ class Operations extends Page implements HasForms, HasTable
|
||||
// Must be non-static
|
||||
protected string $view = 'filament.pages.monitoring.operations';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->withDefaults(new ActionSurfaceDefaults(
|
||||
moreGroupLabel: 'More',
|
||||
exportIsDefaultBulkActionForReadOnly: false,
|
||||
))
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve scope context and return navigation for the monitoring operations list.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Operation runs remain immutable on the monitoring list and intentionally omit bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no operation runs exist for the active workspace scope.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical tenantless operation detail page, which owns header actions.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
|
||||
|
||||
@ -7,6 +7,9 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -27,6 +30,16 @@ class NoAccess extends Page
|
||||
|
||||
protected string $view = 'filament.pages.no-access';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header provides a create-workspace recovery action when the user has no tenant access yet.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'The no-access page is a singleton recovery surface without record-level inspect affordances.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The no-access page does not render row-level secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The no-access page does not expose bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a dedicated recovery message instead of a list-style empty state.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
|
||||
@ -25,6 +25,10 @@
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -47,6 +51,20 @@ class TenantlessOperationRunViewer extends Page
|
||||
|
||||
protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||
->withDefaults(new ActionSurfaceDefaults(
|
||||
moreGroupLabel: 'More',
|
||||
exportIsDefaultBulkActionForReadOnly: false,
|
||||
))
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Canonical tenantless run viewing is a detail-only page and does not render list header actions.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'The tenantless run viewer is itself the canonical detail destination for a selected operation run.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Detail viewing does not expose bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a selected run detail instead of a list empty state.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Header keeps scope context, back navigation, refresh, related links, and resumable capture actions when applicable.');
|
||||
}
|
||||
|
||||
public OperationRun $run;
|
||||
|
||||
/**
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -62,7 +63,7 @@ class ReviewRegister extends Page implements HasTable
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.')
|
||||
@ -192,9 +193,6 @@ public function table(Table $table): Table
|
||||
FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view_review')
|
||||
->label('View review')
|
||||
->url(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')),
|
||||
Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
|
||||
@ -12,6 +12,9 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
@ -25,6 +28,16 @@ class TenantDiagnostics extends Page
|
||||
|
||||
protected string $view = 'filament.pages.tenant-diagnostics';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header exposes capability-gated tenant repair actions when inconsistent membership state is detected.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant diagnostics is already the singleton diagnostic surface for the active tenant.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The diagnostics page does not render row-level secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The diagnostics page does not expose bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Diagnostics content is always rendered instead of a list-style empty state.');
|
||||
}
|
||||
|
||||
public bool $missingOwner = false;
|
||||
|
||||
public bool $hasDuplicateMembershipsForCurrentUser = false;
|
||||
|
||||
@ -10,6 +10,9 @@
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Pages\Page;
|
||||
use Livewire\Attributes\Locked;
|
||||
@ -27,6 +30,16 @@ class TenantRequiredPermissions extends Page
|
||||
|
||||
protected string $view = 'filament.pages.tenant-required-permissions';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Required permissions keeps guidance, copy flows, and filter reset actions inside body sections instead of page header actions.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Required permissions rows are reviewed inline inside the diagnostic matrix and do not open a separate inspect destination.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Permission rows are read-only and do not expose row-level secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Required permissions does not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The inline permissions matrix provides purposeful no-data, all-clear, and no-matches states with verification or reset guidance.');
|
||||
}
|
||||
|
||||
public string $status = 'missing';
|
||||
|
||||
public string $type = 'all';
|
||||
|
||||
@ -19,10 +19,10 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
@ -91,7 +91,7 @@ public static function canView(Model $record): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Read-only history list intentionally has no list-header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.')
|
||||
@ -322,9 +322,7 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make()->label('View'),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No alert deliveries')
|
||||
->emptyStateDescription('Deliveries appear automatically when alert rules fire.')
|
||||
|
||||
@ -18,8 +18,6 @@
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
@ -191,9 +189,6 @@ public static function table(Table $table): Table
|
||||
->since(),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->label('Edit')
|
||||
->visible(fn (AlertDestination $record): bool => static::canEdit($record)),
|
||||
ActionGroup::make([
|
||||
Action::make('toggle_enabled')
|
||||
->label(fn (AlertDestination $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||
@ -253,9 +248,6 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
])->label('More'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([])->label('More'),
|
||||
])
|
||||
->emptyStateActions([
|
||||
\Filament\Actions\CreateAction::make()
|
||||
->label('Create target')
|
||||
|
||||
@ -20,8 +20,6 @@
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -248,9 +246,6 @@ public static function table(Table $table): Table
|
||||
->color(fn (bool $state): string => $state ? 'success' : 'gray'),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->label('Edit')
|
||||
->visible(fn (AlertRule $record): bool => static::canEdit($record)),
|
||||
ActionGroup::make([
|
||||
Action::make('toggle_enabled')
|
||||
->label(fn (AlertRule $record): string => $record->is_enabled ? 'Disable' : 'Enable')
|
||||
@ -311,9 +306,6 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
])->label('More'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No alert rules')
|
||||
->emptyStateDescription('Create a rule to route notifications when monitored events fire.')
|
||||
->emptyStateIcon('heroicon-o-bell');
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use BackedEnum;
|
||||
use DateTimeZone;
|
||||
use Filament\Actions\Action;
|
||||
@ -39,7 +40,6 @@
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\Select;
|
||||
@ -191,11 +191,11 @@ public static function canDeleteAny(): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndEdit, ActionSurfaceType::CrudListFirstResource)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More" in workflow-first, destructive-last order.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in workflow-first, destructive-last order.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Edit page provides save/cancel controls.');
|
||||
}
|
||||
@ -571,54 +571,6 @@ public static function table(Table $table): Table
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
EditAction::make()
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('archive')
|
||||
->label('Archive')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||
|
||||
Gate::authorize('delete', $record);
|
||||
|
||||
if ($record->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup_schedule.archived',
|
||||
resourceType: 'backup_schedule',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->getKey(),
|
||||
'backup_schedule_name' => $record->name,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup schedule archived')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('restore')
|
||||
->label('Restore')
|
||||
@ -661,11 +613,56 @@ public static function table(Table $table): Table
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('archive')
|
||||
->label('Archive')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||
|
||||
Gate::authorize('delete', $record);
|
||||
|
||||
if ($record->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup_schedule.archived',
|
||||
resourceType: 'backup_schedule',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->getKey(),
|
||||
'backup_schedule_name' => $record->name,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup schedule archived')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Closure;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
@ -25,25 +24,12 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager
|
||||
|
||||
protected static ?string $title = 'Executions';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||
{
|
||||
if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) {
|
||||
$this->resolveOwnerScopedOperationRun($context['recordKey']);
|
||||
}
|
||||
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary because this relation manager has no secondary row actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
|
||||
}
|
||||
@ -54,6 +40,12 @@ public function table(Table $table): Table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||
->recordUrl(function (OperationRun $record): string {
|
||||
$record = $this->resolveOwnerScopedOperationRun($record);
|
||||
$tenant = Tenant::currentOrFail();
|
||||
|
||||
return OperationRunLinks::view($record, $tenant);
|
||||
})
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Enqueued')
|
||||
@ -96,18 +88,7 @@ public function table(Table $table): Table
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
Actions\Action::make('view')
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(function (OperationRun $record): string {
|
||||
$record = $this->resolveOwnerScopedOperationRun($record);
|
||||
$tenant = Tenant::currentOrFail();
|
||||
|
||||
return OperationRunLinks::view($record, $tenant);
|
||||
})
|
||||
->openUrlInNewTab(true),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No schedule runs yet')
|
||||
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
@ -21,6 +22,10 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
@ -64,6 +69,16 @@ public function mountAction(string $name, array $arguments = [], array $context
|
||||
return parent::mountAction($name, $arguments, $context);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Refresh and Add Policies actions are available in the relation header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Remove remains grouped under More.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk remove remains grouped under More.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the Add Policies CTA.');
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$refreshTable = Actions\Action::make('refreshTable')
|
||||
@ -257,6 +272,7 @@ public function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (BackupItem $record): ?string => $this->backupItemInspectUrl($record))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')
|
||||
->label('Item')
|
||||
@ -358,23 +374,6 @@ public function table(Table $table): Table
|
||||
])
|
||||
->actions([
|
||||
Actions\ActionGroup::make([
|
||||
Actions\ViewAction::make()
|
||||
->label(fn (BackupItem $record): string => $record->policy_version_id ? 'View version' : 'View policy')
|
||||
->url(function (BackupItem $record): ?string {
|
||||
$tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current();
|
||||
|
||||
if ($record->policy_version_id) {
|
||||
return PolicyVersionResource::getUrl('view', ['record' => $record->policy_version_id], tenant: $tenant);
|
||||
}
|
||||
|
||||
if (! $record->policy_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant);
|
||||
})
|
||||
->hidden(fn (BackupItem $record) => ! $record->policy_version_id && ! $record->policy_id)
|
||||
->openUrlInNewTab(true),
|
||||
$removeItem,
|
||||
])
|
||||
->label('More')
|
||||
@ -449,7 +448,39 @@ private static function applyRestoreModeFilter(Builder $query, mixed $value): Bu
|
||||
return $query->whereIn('policy_type', $types);
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int
|
||||
private function backupItemInspectUrl(BackupItem $record): ?string
|
||||
{
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
$resolvedId = $this->resolveOwnerScopedBackupItemId($backupSet, $record);
|
||||
|
||||
$resolvedRecord = $backupSet->items()
|
||||
->with(['policy', 'policyVersion', 'policyVersion.policy'])
|
||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
||||
->whereKey($resolvedId)
|
||||
->first();
|
||||
|
||||
if (! $resolvedRecord instanceof BackupItem) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($resolvedRecord->policy_version_id) {
|
||||
return PolicyVersionResource::getUrl('view', ['record' => $resolvedRecord->policy_version_id], tenant: $tenant);
|
||||
}
|
||||
|
||||
if (! $resolvedRecord->policy_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PolicyResource::getUrl('view', ['record' => $resolvedRecord->policy_id], tenant: $tenant);
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedBackupItemId(BackupSet $backupSet, mixed $record): int
|
||||
{
|
||||
$recordId = $this->normalizeBackupItemKey($record);
|
||||
|
||||
@ -472,7 +503,7 @@ private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array
|
||||
private function resolveOwnerScopedBackupItemIdsFromKeys(BackupSet $backupSet, array $recordKeys): array
|
||||
{
|
||||
$requestedIds = collect($recordKeys)
|
||||
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
|
||||
|
||||
@ -32,11 +32,11 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
@ -129,10 +129,10 @@ public static function canView(Model $record): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Create baseline profile (capability-gated).')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions (edit, archive) under "More".')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->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 + edit actions.');
|
||||
@ -340,6 +340,7 @@ public static function table(Table $table): Table
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||
->recordUrl(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
@ -412,10 +413,6 @@ public static function table(Table $table): Table
|
||||
->options(FilterOptionCatalog::baselineProfileStatuses()),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view')
|
||||
->label('View')
|
||||
->url(fn (BaselineProfile $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->icon('heroicon-o-eye'),
|
||||
ActionGroup::make([
|
||||
Action::make('edit')
|
||||
->label('Edit')
|
||||
@ -425,9 +422,7 @@ public static function table(Table $table): Table
|
||||
self::archiveTableAction($workspace),
|
||||
])->label('More'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([])->label('More'),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No baseline profiles')
|
||||
->emptyStateDescription('Create a baseline profile to define what "good" looks like for your tenants.')
|
||||
->emptyStateActions([
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -120,7 +121,7 @@ public static function canView(Model $record): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Snapshots are created by capture runs; no list-header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; rows navigate directly to the detail page.')
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use BackedEnum;
|
||||
@ -108,11 +109,11 @@ public static function canView(Model $record): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.')
|
||||
->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.');
|
||||
}
|
||||
@ -257,33 +258,32 @@ public static function table(Table $table): Table
|
||||
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('view_snapshot')
|
||||
->label('View snapshot')
|
||||
->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('expire')
|
||||
->label('Expire snapshot')
|
||||
->color('danger')
|
||||
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
||||
->requiresConfirmation()
|
||||
->action(function (EvidenceSnapshot $record): void {
|
||||
$user = auth()->user();
|
||||
Actions\ActionGroup::make([
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('expire')
|
||||
->label('Expire snapshot')
|
||||
->color('danger')
|
||||
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
||||
->requiresConfirmation()
|
||||
->action(function (EvidenceSnapshot $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(EvidenceSnapshotService::class)->expire($record, $user);
|
||||
static::truthEnvelope($record->refresh(), fresh: true);
|
||||
app(EvidenceSnapshotService::class)->expire($record, $user);
|
||||
static::truthEnvelope($record->refresh(), fresh: true);
|
||||
|
||||
Notification::make()->success()->title('Snapshot expired')->send();
|
||||
}),
|
||||
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
Notification::make()->success()->title('Snapshot expired')->send();
|
||||
}),
|
||||
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
])->label('More'),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No evidence snapshots yet')
|
||||
|
||||
@ -131,7 +131,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary workflow actions are grouped under "More"; the only inline row action is the related-record drill-down.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.');
|
||||
@ -1643,6 +1643,7 @@ public static function reopenAction(): Actions\Action
|
||||
*/
|
||||
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
||||
{
|
||||
$pageRecord = $record;
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
@ -1671,6 +1672,8 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
||||
|
||||
try {
|
||||
$callback($record, $tenant, $user);
|
||||
|
||||
$pageRecord->refresh();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
Notification::make()
|
||||
->title('Workflow action failed')
|
||||
|
||||
@ -33,12 +33,12 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
@ -68,7 +68,7 @@ class OperationRunResource extends Resource
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->withDefaults(new ActionSurfaceDefaults(
|
||||
moreGroupLabel: 'More',
|
||||
exportIsDefaultBulkActionForReadOnly: false,
|
||||
@ -77,7 +77,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
ActionSurfaceSlot::ListHeader,
|
||||
'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.',
|
||||
)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(
|
||||
ActionSurfaceSlot::ListBulkMoreGroup,
|
||||
'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.',
|
||||
@ -128,6 +128,7 @@ public static function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (OperationRun $record): string => OperationRunLinks::tenantlessView($record))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
@ -242,11 +243,7 @@ public static function table(Table $table): Table
|
||||
'until' => now()->toDateString(),
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make()
|
||||
->label('View run')
|
||||
->url(fn (OperationRun $record): string => OperationRunLinks::tenantlessView($record)),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No operation runs found')
|
||||
->emptyStateDescription('Queued, running, and completed operations will appear here when work is triggered in this scope.')
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -97,11 +98,11 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides header actions when applicable.');
|
||||
}
|
||||
@ -365,6 +366,7 @@ public static function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('display_name')
|
||||
->label('Policy')
|
||||
@ -490,109 +492,7 @@ public static function table(Table $table): Table
|
||||
->all()),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
ActionGroup::make([
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('ignore')
|
||||
->label('Ignore')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->action(function (Policy $record): void {
|
||||
$record->ignore();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy ignored')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to ignore policies.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
|
||||
->action(function (Policy $record): void {
|
||||
$record->unignore();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy restored')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to restore policies.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('sync')
|
||||
->label('Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->action(function (Policy $record, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.sync_one',
|
||||
inputs: [
|
||||
'scope' => 'one',
|
||||
'policy_id' => (int) $record->getKey(),
|
||||
],
|
||||
initiator: $user
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, [(int) $record->getKey()], $opRun);
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('export')
|
||||
->label('Export to Backup')
|
||||
@ -663,12 +563,345 @@ public static function table(Table $table): Table
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('sync')
|
||||
->label('Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->action(function (Policy $record, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.sync_one',
|
||||
inputs: [
|
||||
'scope' => 'one',
|
||||
'policy_id' => (int) $record->getKey(),
|
||||
],
|
||||
initiator: $user
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, [(int) $record->getKey()], $opRun);
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
|
||||
->action(function (Policy $record): void {
|
||||
$record->unignore();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy restored')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to restore policies.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('ignore')
|
||||
->label('Ignore')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->action(function (Policy $record): void {
|
||||
$record->ignore();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy ignored')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to ignore policies.')
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_export')
|
||||
->label('Export to Backup')
|
||||
->icon('heroicon-o-archive-box-arrow-down')
|
||||
->form([
|
||||
Forms\Components\TextInput::make('backup_name')
|
||||
->label('Backup Name')
|
||||
->required()
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Collection $records, array $data): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy.export',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
|
||||
if ($count >= 20) {
|
||||
BulkPolicyExportJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
backupName: (string) $data['backup_name'],
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
BulkPolicyExportJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
backupName: (string) $data['backup_name'],
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'backup_name' => (string) $data['backup_name'],
|
||||
'policy_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_sync')
|
||||
->label('Sync Policies')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||
$value = $visibilityFilterState['value'] ?? null;
|
||||
|
||||
return $value === 'ignored';
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$ids = $records
|
||||
->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.sync',
|
||||
inputs: [
|
||||
'scope' => 'subset',
|
||||
'policy_ids' => $ids,
|
||||
],
|
||||
initiator: $user
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, $ids, $opRun);
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Policies')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||
$value = $visibilityFilterState['value'] ?? null;
|
||||
|
||||
return ! in_array($value, [null, 'ignored'], true);
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy.unignore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
|
||||
if ($count >= 20) {
|
||||
BulkPolicyUnignoreJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
BulkPolicyUnignoreJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'policy_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Ignore Policies')
|
||||
@ -766,242 +999,6 @@ public static function table(Table $table): Table
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Policies')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||
$value = $visibilityFilterState['value'] ?? null;
|
||||
|
||||
return ! in_array($value, [null, 'ignored'], true);
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy.unignore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
|
||||
if ($count >= 20) {
|
||||
BulkPolicyUnignoreJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
BulkPolicyUnignoreJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'policy_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_sync')
|
||||
->label('Sync Policies')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||
$value = $visibilityFilterState['value'] ?? null;
|
||||
|
||||
return $value === 'ignored';
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$ids = $records
|
||||
->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.sync',
|
||||
inputs: [
|
||||
'scope' => 'subset',
|
||||
'policy_ids' => $ids,
|
||||
],
|
||||
initiator: $user
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
SyncPoliciesJob::dispatch((int) $tenant->getKey(), null, $ids, $opRun);
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_export')
|
||||
->label('Export to Backup')
|
||||
->icon('heroicon-o-archive-box-arrow-down')
|
||||
->form([
|
||||
Forms\Components\TextInput::make('backup_name')
|
||||
->label('Backup Name')
|
||||
->required()
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Collection $records, array $data): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy.export',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
|
||||
if ($count >= 20) {
|
||||
BulkPolicyExportJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
backupName: (string) $data['backup_name'],
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
BulkPolicyExportJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
backupName: (string) $data['backup_name'],
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'backup_name' => (string) $data['backup_name'],
|
||||
'policy_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No policies synced yet')
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
@ -49,8 +50,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two row actions are present, so no secondary row menu is needed.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while restore remains the only inline row shortcut.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.');
|
||||
}
|
||||
@ -181,13 +182,11 @@ public function table(Table $table): Table
|
||||
])
|
||||
->defaultSort('version_number', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||
->recordUrl(fn (PolicyVersion $record): string => PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||
->filters([])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
$restoreToIntune,
|
||||
Actions\ViewAction::make()
|
||||
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||
->openUrlInNewTab(false),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No versions captured')
|
||||
|
||||
@ -82,7 +82,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions include capability-gated create.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Edit and provider operations are grouped under "More" while clickable-row view remains primary.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Provider connections intentionally omit bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines empty-state guidance and CTA.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header mutations in this resource.');
|
||||
|
||||
@ -37,6 +37,10 @@
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\RestoreRunIdempotency;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -103,6 +107,17 @@ public static function canCreate(): bool
|
||||
&& $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create restore run is available from the list header whenever records already exist.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while rerun and archive lifecycle actions stay grouped under More.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk restore-run maintenance actions are grouped under More.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the New restore run CTA.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header actions.');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
@ -862,8 +877,8 @@ public static function table(Table $table): Table
|
||||
FilterPresets::dateRange('started_at', 'Started', 'started_at'),
|
||||
FilterPresets::archived(),
|
||||
])
|
||||
->recordUrl(fn (RestoreRun $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
ActionGroup::make([
|
||||
static::rerunActionWithGate(),
|
||||
UiEnforcement::forTableAction(
|
||||
@ -975,7 +990,9 @@ public static function table(Table $table): Table
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
@ -1232,7 +1249,7 @@ public static function table(Table $table): Table
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
]),
|
||||
])->label('More'),
|
||||
])
|
||||
->emptyStateHeading('No restore runs')
|
||||
->emptyStateDescription('Start a restoration from a backup set.')
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use BackedEnum;
|
||||
@ -97,11 +98,11 @@ public static function canView(Model $record): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two primary row actions (Download, Expire); no secondary menu needed.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download and Expire remain direct row shortcuts.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
||||
}
|
||||
|
||||
@ -49,6 +49,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
@ -145,11 +146,11 @@ public static function canDeleteAny(): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->withListRowPrimaryActionLimit(2)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||
->withListRowPrimaryActionLimit(1)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most two row actions stay primary; lifecycle-adjacent and contextual secondary actions move under "More".')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->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.');
|
||||
@ -245,6 +246,7 @@ public static function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (Tenant $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
@ -317,49 +319,11 @@ public static function table(Table $table): Table
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('view')
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
|
||||
Actions\Action::make('related_onboarding')
|
||||
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding')
|
||||
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
|
||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
||||
->color('success')
|
||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
|
||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'restore')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
static::restoreTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
|
||||
->color('danger')
|
||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
|
||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'archive')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
static::archiveTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
ActionGroup::make([
|
||||
Actions\Action::make('related_onboarding_overflow')
|
||||
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
|
||||
@ -367,6 +331,37 @@ public static function table(Table $table): Table
|
||||
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
||||
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
|
||||
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
|
||||
Actions\Action::make('openTenant')
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right')
|
||||
->color('primary')
|
||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record))
|
||||
->visible(fn (Tenant $record) => $record->isActive()),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record]))
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\Action::make('open_in_entra')
|
||||
->label('Open in Entra')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record) => static::entraUrl($record))
|
||||
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
@ -479,37 +474,6 @@ public static function table(Table $table): Table
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply(),
|
||||
Actions\Action::make('openTenant')
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right')
|
||||
->color('primary')
|
||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', panel: 'tenant', tenant: $record))
|
||||
->visible(fn (Tenant $record) => $record->isActive()),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record]))
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Grant admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\Action::make('open_in_entra')
|
||||
->label('Open in Entra')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record) => static::entraUrl($record))
|
||||
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('verify')
|
||||
->label('Verify configuration')
|
||||
@ -635,6 +599,23 @@ public static function table(Table $table): Table
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
|
||||
->color('success')
|
||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
|
||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
|
||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
|
||||
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'restore')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
static::restoreTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
static::rbacAction(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('forceDelete')
|
||||
@ -692,6 +673,23 @@ public static function table(Table $table): Table
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
|
||||
->color('danger')
|
||||
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
|
||||
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
|
||||
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
|
||||
->visible(fn (Tenant $record): bool => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->key === 'archive')
|
||||
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
|
||||
static::archiveTenant($record, $auditLogger);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
|
||||
@ -2,12 +2,17 @@
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ManageTenantMemberships;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -15,11 +20,48 @@
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantMembershipsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'memberships';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant membership rows are managed inline and have no separate inspect destination.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Change role and remove stay direct for focused inline membership management.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.');
|
||||
}
|
||||
|
||||
public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
|
||||
{
|
||||
if (! $ownerRecord instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($pageClass !== ManageTenantMemberships::class) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($ownerRecord)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $ownerRecord, Capabilities::TENANT_MEMBERSHIP_VIEW);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use BackedEnum;
|
||||
@ -115,12 +116,12 @@ public static function canView(Model $record): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->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, 'Primary row actions stay limited to View review and Export executive pack.')
|
||||
->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.');
|
||||
}
|
||||
|
||||
@ -311,9 +312,6 @@ public static function table(Table $table): Table
|
||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('view_review')
|
||||
->label('View review')
|
||||
->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('export_executive_pack')
|
||||
->label('Export executive pack')
|
||||
|
||||
@ -9,7 +9,11 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\WorkspaceRole;
|
||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
@ -21,6 +25,16 @@ class WorkspaceMembershipsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'memberships';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Add member action is available in the relation header.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace membership rows are managed inline and have no separate inspect destination.')
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Change role stays inline while destructive removal is grouped under More.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk membership mutations are intentionally omitted.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed; add member remains available in the header.');
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
@ -177,46 +191,47 @@ public function table(Table $table): Table
|
||||
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||
->tooltip('You do not have permission to manage workspace memberships.')
|
||||
->apply(),
|
||||
ActionGroup::make([
|
||||
WorkspaceUiEnforcement::forTableAction(
|
||||
Action::make('remove')
|
||||
->label(__('Remove'))
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void {
|
||||
$workspace = $this->getOwnerRecord();
|
||||
|
||||
WorkspaceUiEnforcement::forTableAction(
|
||||
Action::make('remove')
|
||||
->label(__('Remove'))
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (WorkspaceMembership $record, WorkspaceMembershipManager $manager): void {
|
||||
$workspace = $this->getOwnerRecord();
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
abort(404);
|
||||
}
|
||||
$actor = auth()->user();
|
||||
if (! $actor instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$actor = auth()->user();
|
||||
if (! $actor instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
try {
|
||||
$manager->removeMember(workspace: $workspace, actor: $actor, membership: $record);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title(__('Failed to remove member'))
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
try {
|
||||
$manager->removeMember(workspace: $workspace, actor: $actor, membership: $record);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title(__('Failed to remove member'))
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->title(__('Member removed'))->success()->send();
|
||||
$this->resetTable();
|
||||
}),
|
||||
fn () => $this->getOwnerRecord(),
|
||||
)
|
||||
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||
->tooltip('You do not have permission to manage workspace memberships.')
|
||||
->destructive()
|
||||
->apply(),
|
||||
Notification::make()->title(__('Member removed'))->success()->send();
|
||||
$this->resetTable();
|
||||
}),
|
||||
fn () => $this->getOwnerRecord(),
|
||||
)
|
||||
->requireCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE)
|
||||
->tooltip('You do not have permission to manage workspace memberships.')
|
||||
->destructive()
|
||||
->apply(),
|
||||
])->label('More'),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading(__('No workspace members'))
|
||||
|
||||
@ -12,9 +12,11 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
@ -94,10 +96,10 @@ public static function canEdit(Model $record): bool
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace list intentionally uses only primary View/Edit row actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while the secondary Edit shortcut lives under "More".')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace list intentionally omits bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines a capability-gated empty-state create CTA.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Workspace view page exposes a capability-gated edit action.');
|
||||
@ -151,6 +153,7 @@ public static function table(Table $table): Table
|
||||
return $table
|
||||
->defaultSort('name')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
|
||||
->recordUrl(fn (Workspace $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
@ -160,13 +163,16 @@ public static function table(Table $table): Table
|
||||
->sortable(),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
WorkspaceUiEnforcement::forTableAction(
|
||||
Actions\EditAction::make(),
|
||||
fn (): ?Workspace => null,
|
||||
)
|
||||
->requireCapability(Capabilities::WORKSPACE_MANAGE)
|
||||
->apply(),
|
||||
ActionGroup::make([
|
||||
WorkspaceUiEnforcement::forTableAction(
|
||||
Actions\EditAction::make(),
|
||||
fn (): ?Workspace => null,
|
||||
)
|
||||
->requireCapability(Capabilities::WORKSPACE_MANAGE)
|
||||
->apply(),
|
||||
])
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->emptyStateHeading('No workspaces')
|
||||
->emptyStateDescription('Create your first workspace.')
|
||||
|
||||
@ -10,6 +10,11 @@
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
@ -31,6 +36,16 @@ class Tenants extends Page implements HasTable
|
||||
|
||||
protected string $view = 'filament.system.pages.directory.tenants';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'System tenant directory stays scan-first and does not expose page header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Tenant directory rows navigate directly to the detail page and have no secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System tenant directory does not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains that tenants appear here after onboarding and inventory sync.');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
@ -14,6 +14,11 @@
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\System\SystemDirectoryLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
@ -35,6 +40,16 @@ class Workspaces extends Page implements HasTable
|
||||
|
||||
protected string $view = 'filament.system.pages.directory.workspaces';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'System workspace directory stays scan-first and does not expose page header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace directory rows navigate directly to the detail page and have no secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System workspace directory does not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains that workspaces appear here once the platform inventory is seeded.');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
@ -15,6 +15,11 @@
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -39,6 +44,16 @@ class Failures extends Page implements HasTable
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.failures';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'System failures stay scan-first and rely on row triage rather than page header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Failed-run triage stays per run and intentionally omits bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when there are no failed runs to triage.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$count = OperationRun::query()
|
||||
|
||||
@ -13,6 +13,11 @@
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -37,6 +42,16 @@ class Runs extends Page implements HasTable
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.runs';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'System ops runs rely on inline row triage and do not expose page header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'System ops triage stays per run and intentionally omits bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no system runs have been queued yet.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
@ -15,6 +15,11 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\SystemConsole\StuckRunClassifier;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -39,6 +44,16 @@ class Stuck extends Page implements HasTable
|
||||
|
||||
protected string $view = 'filament.system.pages.ops.stuck';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'System stuck-run triage relies on row actions and does not expose page header actions.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Stuck-run triage stays per run and intentionally omits bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no queued or running runs cross the stuck thresholds.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical system run detail page, which owns header actions.');
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$count = app(StuckRunClassifier::class)
|
||||
|
||||
@ -7,6 +7,10 @@
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
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 Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
@ -28,6 +32,16 @@ class AccessLogs extends Page implements HasTable
|
||||
|
||||
protected string $view = 'filament.system.pages.security.access-logs';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::HistoryAudit)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Access logs remain scan-first and do not expose page header actions.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Access logs intentionally keep auth and break-glass events inline without a separate inspect view.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Access logs are immutable and intentionally omit bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no platform auth or break-glass events match the current log scope.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The access log page does not open per-record detail headers; review stays inline in the table.');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth('platform')->user();
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
@ -38,19 +39,18 @@ protected function getViewData(): array
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
$aggregate = $this->governanceAggregate($tenant);
|
||||
|
||||
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
||||
if ($aggregate->compareState === 'no_assignment') {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
$runUrl = $stats->operationRunId !== null
|
||||
? OperationRunLinks::view($stats->operationRunId, $tenant)
|
||||
$runUrl = $aggregate->stats->operationRunId !== null
|
||||
? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant)
|
||||
: null;
|
||||
$findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
$summaryAssessment = $stats->summaryAssessment();
|
||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||
$nextActionUrl = match ($aggregate->nextActionTarget) {
|
||||
'run' => $runUrl,
|
||||
'findings' => $findingsUrl,
|
||||
'landing' => $tenantLandingUrl,
|
||||
@ -59,13 +59,24 @@ protected function getViewData(): array
|
||||
|
||||
return [
|
||||
'hasAssignment' => true,
|
||||
'profileName' => $stats->profileName,
|
||||
'lastComparedAt' => $stats->lastComparedHuman,
|
||||
'profileName' => $aggregate->profileName,
|
||||
'lastComparedAt' => $aggregate->lastComparedLabel,
|
||||
'landingUrl' => $tenantLandingUrl,
|
||||
'runUrl' => $runUrl,
|
||||
'findingsUrl' => $findingsUrl,
|
||||
'nextActionUrl' => $nextActionUrl,
|
||||
'summaryAssessment' => $summaryAssessment->toArray(),
|
||||
'summaryAssessment' => $aggregate->summaryAssessment->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||
{
|
||||
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||
|
||||
/** @var TenantGovernanceAggregate $aggregate */
|
||||
$aggregate = $resolver->forTenant($tenant);
|
||||
|
||||
return $aggregate;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
|
||||
namespace App\Filament\Widgets\Dashboard;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
@ -31,51 +31,15 @@ protected function getViewData(): array
|
||||
}
|
||||
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$compareStats = BaselineCompareStats::forTenant($tenant);
|
||||
$compareAssessment = $compareStats->summaryAssessment();
|
||||
$aggregate = $this->governanceAggregate($tenant);
|
||||
$compareAssessment = $aggregate->summaryAssessment;
|
||||
|
||||
$items = [];
|
||||
|
||||
$overdueOpenCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->whereNotNull('due_at')
|
||||
->where('due_at', '<', now())
|
||||
->count();
|
||||
|
||||
$lapsedGovernanceCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||
->where(function ($query): void {
|
||||
$query
|
||||
->whereDoesntHave('findingException')
|
||||
->orWhereHas('findingException', function ($exceptionQuery): void {
|
||||
$exceptionQuery->whereIn('current_validity_state', [
|
||||
\App\Models\FindingException::VALIDITY_EXPIRED,
|
||||
\App\Models\FindingException::VALIDITY_REVOKED,
|
||||
\App\Models\FindingException::VALIDITY_REJECTED,
|
||||
\App\Models\FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
]);
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
$expiringGovernanceCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||
->whereHas('findingException', function ($query): void {
|
||||
$query->where('current_validity_state', \App\Models\FindingException::VALIDITY_EXPIRING);
|
||||
})
|
||||
->count();
|
||||
|
||||
$highSeverityCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->whereIn('severity', [
|
||||
Finding::SEVERITY_HIGH,
|
||||
Finding::SEVERITY_CRITICAL,
|
||||
])
|
||||
->count();
|
||||
$overdueOpenCount = $aggregate->overdueOpenFindingsCount;
|
||||
$lapsedGovernanceCount = $aggregate->lapsedGovernanceCount;
|
||||
$expiringGovernanceCount = $aggregate->expiringGovernanceCount;
|
||||
$highSeverityCount = $aggregate->highSeverityActiveFindingsCount;
|
||||
|
||||
if ($lapsedGovernanceCount > 0) {
|
||||
$items[] = [
|
||||
@ -120,7 +84,7 @@ protected function getViewData(): array
|
||||
'supportingMessage' => $compareAssessment->supportingMessage,
|
||||
'badge' => 'Baseline',
|
||||
'badgeColor' => $compareAssessment->tone,
|
||||
'nextStep' => $compareAssessment->nextActionLabel(),
|
||||
'nextStep' => $aggregate->nextActionLabel,
|
||||
];
|
||||
}
|
||||
|
||||
@ -145,7 +109,7 @@ protected function getViewData(): array
|
||||
$healthyChecks = [
|
||||
[
|
||||
'title' => 'Baseline compare looks trustworthy',
|
||||
'body' => $compareAssessment->headline,
|
||||
'body' => $aggregate->headline,
|
||||
],
|
||||
[
|
||||
'title' => 'No overdue findings',
|
||||
@ -172,4 +136,15 @@ protected function getViewData(): array
|
||||
'healthyChecks' => $healthyChecks,
|
||||
];
|
||||
}
|
||||
|
||||
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||
{
|
||||
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||
|
||||
/** @var TenantGovernanceAggregate $aggregate */
|
||||
$aggregate = $resolver->forTenant($tenant);
|
||||
|
||||
return $aggregate;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\Baselines\TenantGovernanceAggregate;
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
@ -30,31 +31,39 @@ protected function getViewData(): array
|
||||
];
|
||||
}
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
$summaryAssessment = $stats->summaryAssessment();
|
||||
$runUrl = null;
|
||||
|
||||
if ($stats->operationRunId !== null) {
|
||||
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
||||
}
|
||||
$aggregate = $this->governanceAggregate($tenant);
|
||||
$runUrl = $aggregate->stats->operationRunId !== null
|
||||
? OperationRunLinks::view($aggregate->stats->operationRunId, $tenant)
|
||||
: null;
|
||||
|
||||
$landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||
$nextActionUrl = match ($aggregate->nextActionTarget) {
|
||||
'run' => $runUrl,
|
||||
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||
'landing' => $landingUrl,
|
||||
default => null,
|
||||
};
|
||||
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
||||
|| $summaryAssessment->stateFamily === 'action_required';
|
||||
$shouldShow = in_array($aggregate->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
||||
|| $aggregate->stateFamily === 'action_required';
|
||||
|
||||
return [
|
||||
'shouldShow' => $shouldShow,
|
||||
'landingUrl' => $landingUrl,
|
||||
'runUrl' => $runUrl,
|
||||
'nextActionUrl' => $nextActionUrl,
|
||||
'summaryAssessment' => $summaryAssessment->toArray(),
|
||||
'state' => $stats->state,
|
||||
'summaryAssessment' => $aggregate->summaryAssessment->toArray(),
|
||||
'state' => $aggregate->compareState,
|
||||
];
|
||||
}
|
||||
|
||||
private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||
{
|
||||
/** @var TenantGovernanceAggregateResolver $resolver */
|
||||
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||
|
||||
/** @var TenantGovernanceAggregate $aggregate */
|
||||
$aggregate = $resolver->forTenant($tenant);
|
||||
|
||||
return $aggregate;
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,7 +108,9 @@ private function getMembership(User $user, Tenant $tenant): ?array
|
||||
/**
|
||||
* Prime membership cache for a set of tenants in one query.
|
||||
*
|
||||
* Used to avoid N+1 queries for bulk selection authorization.
|
||||
* Used to avoid N+1 queries for bulk selection authorization while still
|
||||
* reflecting membership changes that may have happened earlier in the same
|
||||
* request or test process.
|
||||
*
|
||||
* @param array<int, int|string> $tenantIds
|
||||
*/
|
||||
@ -120,26 +122,14 @@ public function primeMemberships(User $user, array $tenantIds): void
|
||||
return;
|
||||
}
|
||||
|
||||
$missingTenantIds = [];
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$cacheKey = "membership_{$user->id}_{$tenantId}";
|
||||
if (! array_key_exists($cacheKey, $this->resolvedMemberships)) {
|
||||
$missingTenantIds[] = $tenantId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingTenantIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$memberships = TenantMembership::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('tenant_id', $missingTenantIds)
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->get(['tenant_id', 'role', 'source', 'source_ref']);
|
||||
|
||||
$byTenantId = $memberships->keyBy('tenant_id');
|
||||
|
||||
foreach ($missingTenantIds as $tenantId) {
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$cacheKey = "membership_{$user->id}_{$tenantId}";
|
||||
$membership = $byTenantId->get($tenantId);
|
||||
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
||||
|
||||
@ -5,12 +5,15 @@
|
||||
namespace App\Services\TenantReviews;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
@ -20,6 +23,7 @@ public function __construct(
|
||||
private readonly TenantReviewReadinessGate $readinessGate,
|
||||
private readonly TenantReviewService $reviewService,
|
||||
private readonly WorkspaceAuditLogger $auditLogger,
|
||||
private readonly RequestScopedDerivedStateStore $derivedStateStore,
|
||||
) {}
|
||||
|
||||
public function publish(TenantReview $review, User $user): TenantReview
|
||||
@ -64,6 +68,8 @@ public function publish(TenantReview $review, User $user): TenantReview
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
$this->invalidateArtifactTruthCache($review);
|
||||
|
||||
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
||||
}
|
||||
|
||||
@ -104,6 +110,8 @@ public function archive(TenantReview $review, User $user): TenantReview
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
$this->invalidateArtifactTruthCache($review);
|
||||
|
||||
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
||||
}
|
||||
|
||||
@ -126,7 +134,7 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap
|
||||
throw new InvalidArgumentException('An eligible evidence snapshot is required to create the next review.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($review, $user, $snapshot, $tenant): TenantReview {
|
||||
$nextReview = DB::transaction(function () use ($review, $user, $snapshot, $tenant): TenantReview {
|
||||
$nextReview = $this->reviewService->create($tenant, $snapshot, $user);
|
||||
|
||||
if ((int) $nextReview->getKey() !== (int) $review->getKey()) {
|
||||
@ -156,5 +164,23 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap
|
||||
|
||||
return $nextReview->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
|
||||
});
|
||||
|
||||
$this->invalidateArtifactTruthCache($review);
|
||||
$this->invalidateArtifactTruthCache($nextReview);
|
||||
|
||||
return $nextReview;
|
||||
}
|
||||
|
||||
private function invalidateArtifactTruthCache(TenantReview $review): void
|
||||
{
|
||||
$this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $review, 'tenant_review');
|
||||
|
||||
$review->loadMissing('currentExportReviewPack');
|
||||
|
||||
$pack = $review->currentExportReviewPack;
|
||||
|
||||
if ($pack instanceof ReviewPack) {
|
||||
$this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $pack, 'review_pack');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,7 +153,7 @@ private function viewAction(): TenantActionDescriptor
|
||||
family: TenantActionFamily::Neutral,
|
||||
label: 'View',
|
||||
icon: 'heroicon-o-eye',
|
||||
group: 'primary',
|
||||
group: 'inspect',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -825,6 +825,35 @@ public function summaryAssessment(): BaselineCompareSummaryAssessment
|
||||
return $assessor->assess($this);
|
||||
}
|
||||
|
||||
public function toTenantGovernanceAggregate(Tenant $tenant): TenantGovernanceAggregate
|
||||
{
|
||||
$summaryAssessment = $this->summaryAssessment();
|
||||
|
||||
return new TenantGovernanceAggregate(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
profileName: $this->profileName,
|
||||
compareState: $this->state,
|
||||
stateFamily: $summaryAssessment->stateFamily,
|
||||
tone: $summaryAssessment->tone,
|
||||
headline: $summaryAssessment->headline,
|
||||
supportingMessage: $summaryAssessment->supportingMessage,
|
||||
reasonCode: $summaryAssessment->reasonCode,
|
||||
lastComparedLabel: $summaryAssessment->lastComparedLabel,
|
||||
visibleDriftFindingsCount: $summaryAssessment->findingsVisibleCount,
|
||||
overdueOpenFindingsCount: $this->overdueOpenFindingsCount,
|
||||
expiringGovernanceCount: $this->expiringGovernanceCount,
|
||||
lapsedGovernanceCount: $this->lapsedGovernanceCount,
|
||||
activeNonNewFindingsCount: $this->activeNonNewFindingsCount,
|
||||
highSeverityActiveFindingsCount: $this->highSeverityActiveFindingsCount,
|
||||
nextActionLabel: $summaryAssessment->nextActionLabel(),
|
||||
nextActionTarget: $summaryAssessment->nextActionTarget(),
|
||||
positiveClaimAllowed: $summaryAssessment->positiveClaimAllowed,
|
||||
stats: $this,
|
||||
summaryAssessment: $summaryAssessment,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* label: string,
|
||||
|
||||
111
app/Support/Baselines/TenantGovernanceAggregate.php
Normal file
111
app/Support/Baselines/TenantGovernanceAggregate.php
Normal file
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class TenantGovernanceAggregate
|
||||
{
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $workspaceId,
|
||||
public ?string $profileName,
|
||||
public string $compareState,
|
||||
public string $stateFamily,
|
||||
public string $tone,
|
||||
public string $headline,
|
||||
public ?string $supportingMessage,
|
||||
public ?string $reasonCode,
|
||||
public ?string $lastComparedLabel,
|
||||
public int $visibleDriftFindingsCount,
|
||||
public int $overdueOpenFindingsCount,
|
||||
public int $expiringGovernanceCount,
|
||||
public int $lapsedGovernanceCount,
|
||||
public int $activeNonNewFindingsCount,
|
||||
public int $highSeverityActiveFindingsCount,
|
||||
public string $nextActionLabel,
|
||||
public string $nextActionTarget,
|
||||
public bool $positiveClaimAllowed,
|
||||
public BaselineCompareStats $stats,
|
||||
public BaselineCompareSummaryAssessment $summaryAssessment,
|
||||
) {
|
||||
if ($this->tenantId <= 0) {
|
||||
throw new InvalidArgumentException('Tenant governance aggregates require a positive tenant id.');
|
||||
}
|
||||
|
||||
if ($this->workspaceId <= 0) {
|
||||
throw new InvalidArgumentException('Tenant governance aggregates require a positive workspace id.');
|
||||
}
|
||||
|
||||
if (trim($this->compareState) === '') {
|
||||
throw new InvalidArgumentException('Tenant governance aggregates require a compare state.');
|
||||
}
|
||||
|
||||
if (trim($this->headline) === '') {
|
||||
throw new InvalidArgumentException('Tenant governance aggregates require a headline.');
|
||||
}
|
||||
|
||||
if (trim($this->nextActionLabel) === '') {
|
||||
throw new InvalidArgumentException('Tenant governance aggregates require a next-action label.');
|
||||
}
|
||||
|
||||
if (! in_array($this->nextActionTarget, [
|
||||
BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
|
||||
BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING,
|
||||
BaselineCompareSummaryAssessment::NEXT_TARGET_RUN,
|
||||
BaselineCompareSummaryAssessment::NEXT_TARGET_NONE,
|
||||
], true)) {
|
||||
throw new InvalidArgumentException('Tenant governance aggregates require a supported next-action target.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* tenantId: int,
|
||||
* workspaceId: int,
|
||||
* profileName: ?string,
|
||||
* compareState: string,
|
||||
* stateFamily: string,
|
||||
* tone: string,
|
||||
* headline: string,
|
||||
* supportingMessage: ?string,
|
||||
* reasonCode: ?string,
|
||||
* lastComparedLabel: ?string,
|
||||
* visibleDriftFindingsCount: int,
|
||||
* overdueOpenFindingsCount: int,
|
||||
* expiringGovernanceCount: int,
|
||||
* lapsedGovernanceCount: int,
|
||||
* activeNonNewFindingsCount: int,
|
||||
* highSeverityActiveFindingsCount: int,
|
||||
* nextActionLabel: string,
|
||||
* nextActionTarget: string,
|
||||
* positiveClaimAllowed: bool
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'tenantId' => $this->tenantId,
|
||||
'workspaceId' => $this->workspaceId,
|
||||
'profileName' => $this->profileName,
|
||||
'compareState' => $this->compareState,
|
||||
'stateFamily' => $this->stateFamily,
|
||||
'tone' => $this->tone,
|
||||
'headline' => $this->headline,
|
||||
'supportingMessage' => $this->supportingMessage,
|
||||
'reasonCode' => $this->reasonCode,
|
||||
'lastComparedLabel' => $this->lastComparedLabel,
|
||||
'visibleDriftFindingsCount' => $this->visibleDriftFindingsCount,
|
||||
'overdueOpenFindingsCount' => $this->overdueOpenFindingsCount,
|
||||
'expiringGovernanceCount' => $this->expiringGovernanceCount,
|
||||
'lapsedGovernanceCount' => $this->lapsedGovernanceCount,
|
||||
'activeNonNewFindingsCount' => $this->activeNonNewFindingsCount,
|
||||
'highSeverityActiveFindingsCount' => $this->highSeverityActiveFindingsCount,
|
||||
'nextActionLabel' => $this->nextActionLabel,
|
||||
'nextActionTarget' => $this->nextActionTarget,
|
||||
'positiveClaimAllowed' => $this->positiveClaimAllowed,
|
||||
];
|
||||
}
|
||||
}
|
||||
71
app/Support/Baselines/TenantGovernanceAggregateResolver.php
Normal file
71
app/Support/Baselines/TenantGovernanceAggregateResolver.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Baselines;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||
|
||||
final class TenantGovernanceAggregateResolver
|
||||
{
|
||||
public const string VARIANT_TENANT_GOVERNANCE_SUMMARY = 'tenant_governance_summary';
|
||||
|
||||
public function __construct(
|
||||
private readonly RequestScopedDerivedStateStore $derivedStateStore,
|
||||
) {}
|
||||
|
||||
public function forTenant(?Tenant $tenant, bool $fresh = false): ?TenantGovernanceAggregate
|
||||
{
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveAggregate(
|
||||
tenant: $tenant,
|
||||
resolver: fn (): TenantGovernanceAggregate => BaselineCompareStats::forTenant($tenant)
|
||||
->toTenantGovernanceAggregate($tenant),
|
||||
fresh: $fresh,
|
||||
);
|
||||
}
|
||||
|
||||
public function fromStats(?Tenant $tenant, BaselineCompareStats $stats, bool $fresh = false): ?TenantGovernanceAggregate
|
||||
{
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveAggregate(
|
||||
tenant: $tenant,
|
||||
resolver: fn (): TenantGovernanceAggregate => $stats->toTenantGovernanceAggregate($tenant),
|
||||
fresh: $fresh,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveAggregate(Tenant $tenant, callable $resolver, bool $fresh = false): TenantGovernanceAggregate
|
||||
{
|
||||
$key = DerivedStateKey::fromModel(
|
||||
DerivedStateFamily::TenantGovernanceAggregate,
|
||||
$tenant,
|
||||
self::VARIANT_TENANT_GOVERNANCE_SUMMARY,
|
||||
);
|
||||
|
||||
$value = $fresh
|
||||
? $this->derivedStateStore->resolveFresh(
|
||||
$key,
|
||||
$resolver,
|
||||
DerivedStateFamily::TenantGovernanceAggregate->defaultFreshnessPolicy(),
|
||||
DerivedStateFamily::TenantGovernanceAggregate->allowsNegativeResultCache(),
|
||||
)
|
||||
: $this->derivedStateStore->resolve(
|
||||
$key,
|
||||
$resolver,
|
||||
DerivedStateFamily::TenantGovernanceAggregate->defaultFreshnessPolicy(),
|
||||
DerivedStateFamily::TenantGovernanceAggregate->allowsNegativeResultCache(),
|
||||
);
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@ -58,7 +58,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
|
||||
secondaryLabel: 'Backup set #'.$backupSet->getKey(),
|
||||
linkTarget: new ReferenceLinkTarget(
|
||||
targetKind: ReferenceClass::BackupSet->value,
|
||||
url: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $backupSet->tenant),
|
||||
url: BackupSetResource::getUrl('view', ['record' => $backupSet], panel: 'tenant', tenant: $backupSet->tenant),
|
||||
actionLabel: 'View backup set',
|
||||
contextBadge: 'Tenant',
|
||||
),
|
||||
|
||||
@ -7,11 +7,16 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
|
||||
final class ActionSurfaceDeclaration
|
||||
{
|
||||
private const int BEHAVIOR_AWARE_VERSION = 2;
|
||||
|
||||
private const string LIST_ROW_PRIMARY_ACTION_LIMIT = 'list_row_primary_action_limit';
|
||||
|
||||
private const string PRIMARY_LINK_COLUMN_REASON = 'primary_link_column_reason';
|
||||
|
||||
/**
|
||||
* @var array<string, ActionSurfaceSlotRequirement>
|
||||
*/
|
||||
@ -33,6 +38,7 @@ public function __construct(
|
||||
public readonly int $version,
|
||||
public readonly ActionSurfaceComponentType $componentType,
|
||||
public readonly ActionSurfaceProfile $profile,
|
||||
public readonly ?ActionSurfaceType $surfaceType = null,
|
||||
?ActionSurfaceDefaults $defaults = null,
|
||||
) {
|
||||
$this->defaults = $defaults ?? new ActionSurfaceDefaults;
|
||||
@ -41,28 +47,47 @@ public function __construct(
|
||||
public static function make(
|
||||
ActionSurfaceComponentType $componentType,
|
||||
ActionSurfaceProfile $profile,
|
||||
?ActionSurfaceType $surfaceType = null,
|
||||
int $version = 1,
|
||||
): self {
|
||||
return new self(
|
||||
version: $version,
|
||||
version: self::normalizedVersion($surfaceType, $version),
|
||||
componentType: $componentType,
|
||||
profile: $profile,
|
||||
surfaceType: $surfaceType,
|
||||
);
|
||||
}
|
||||
|
||||
public static function forResource(ActionSurfaceProfile $profile, int $version = 1): self
|
||||
{
|
||||
return self::make(ActionSurfaceComponentType::Resource, $profile, $version);
|
||||
public static function forResource(
|
||||
ActionSurfaceProfile $profile,
|
||||
?ActionSurfaceType $surfaceType = null,
|
||||
int $version = 1,
|
||||
): self {
|
||||
return self::make(ActionSurfaceComponentType::Resource, $profile, $surfaceType, $version);
|
||||
}
|
||||
|
||||
public static function forPage(ActionSurfaceProfile $profile, int $version = 1): self
|
||||
{
|
||||
return self::make(ActionSurfaceComponentType::Page, $profile, $version);
|
||||
public static function forPage(
|
||||
ActionSurfaceProfile $profile,
|
||||
?ActionSurfaceType $surfaceType = null,
|
||||
int $version = 1,
|
||||
): self {
|
||||
return self::make(ActionSurfaceComponentType::Page, $profile, $surfaceType, $version);
|
||||
}
|
||||
|
||||
public static function forRelationManager(ActionSurfaceProfile $profile, int $version = 1): self
|
||||
public static function forRelationManager(
|
||||
ActionSurfaceProfile $profile,
|
||||
?ActionSurfaceType $surfaceType = null,
|
||||
int $version = 1,
|
||||
): self {
|
||||
return self::make(ActionSurfaceComponentType::RelationManager, $profile, $surfaceType, $version);
|
||||
}
|
||||
|
||||
public function withSurfaceType(ActionSurfaceType $surfaceType): self
|
||||
{
|
||||
return self::make(ActionSurfaceComponentType::RelationManager, $profile, $version);
|
||||
return $this->replicate(
|
||||
surfaceType: $surfaceType,
|
||||
version: self::normalizedVersion($surfaceType, $this->version),
|
||||
);
|
||||
}
|
||||
|
||||
public function withDefaults(ActionSurfaceDefaults $defaults): self
|
||||
@ -133,6 +158,23 @@ public function listRowPrimaryActionLimit(): ?int
|
||||
return is_int($limit) ? $limit : null;
|
||||
}
|
||||
|
||||
public function withPrimaryLinkColumnReason(string $reason): self
|
||||
{
|
||||
return $this->setMetadata(self::PRIMARY_LINK_COLUMN_REASON, $reason);
|
||||
}
|
||||
|
||||
public function primaryLinkColumnReason(): ?string
|
||||
{
|
||||
$reason = $this->metadata(self::PRIMARY_LINK_COLUMN_REASON);
|
||||
|
||||
return is_string($reason) ? $reason : null;
|
||||
}
|
||||
|
||||
public function requiresBehaviorAwareContract(): bool
|
||||
{
|
||||
return $this->version >= self::BEHAVIOR_AWARE_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ActionSurfaceSlotRequirement>
|
||||
*/
|
||||
@ -156,4 +198,30 @@ public function metadataValues(): array
|
||||
{
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
private static function normalizedVersion(?ActionSurfaceType $surfaceType, int $version): int
|
||||
{
|
||||
if (! $surfaceType instanceof ActionSurfaceType) {
|
||||
return $version;
|
||||
}
|
||||
|
||||
return max(self::BEHAVIOR_AWARE_VERSION, $version);
|
||||
}
|
||||
|
||||
private function replicate(?ActionSurfaceType $surfaceType, int $version): self
|
||||
{
|
||||
$declaration = new self(
|
||||
version: $version,
|
||||
componentType: $this->componentType,
|
||||
profile: $this->profile,
|
||||
surfaceType: $surfaceType,
|
||||
defaults: $this->defaults,
|
||||
);
|
||||
|
||||
$declaration->slots = $this->slots;
|
||||
$declaration->exemptions = $this->exemptions;
|
||||
$declaration->metadata = $this->metadata;
|
||||
|
||||
return $declaration;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
@ -62,6 +63,20 @@ className: $className,
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->systemPageFiles() as $path) {
|
||||
$className = $this->classNameFromPath($path);
|
||||
|
||||
if (! $this->isDeclaredSystemTablePage($className)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
||||
className: $className,
|
||||
componentType: ActionSurfaceComponentType::Page,
|
||||
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->relationManagerFiles() as $path) {
|
||||
$className = $this->classNameFromPath($path);
|
||||
|
||||
@ -124,6 +139,16 @@ private function pageFiles(): array
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function systemPageFiles(): array
|
||||
{
|
||||
return $this->collectPhpFiles($this->appPath.'/Filament/System/Pages', static function (string $path): bool {
|
||||
return str_ends_with($path, '.php');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
@ -193,6 +218,16 @@ private function discoverAdminScopedClasses(): array
|
||||
return $classes;
|
||||
}
|
||||
|
||||
private function isDeclaredSystemTablePage(string $className): bool
|
||||
{
|
||||
if (! class_exists($className)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return is_subclass_of($className, HasTable::class)
|
||||
&& method_exists($className, 'actionSurfaceDeclaration');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
|
||||
@ -19,24 +19,16 @@ public static function baseline(): self
|
||||
{
|
||||
return new self(array_merge([
|
||||
// Baseline allowlist for legacy surfaces. Keep shrinking this list.
|
||||
// Declared system table pages are discovered directly; deferred system tooling stays out of scope by not opting in.
|
||||
'App\\Filament\\Pages\\Auth\\Login' => 'Auth entry page is out-of-scope for action-surface retrofits in spec 082.',
|
||||
'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\\InventoryCoverage' => 'Inventory coverage intentionally omits inspect affordances because rows are runtime-derived metadata; spec 124 requires search, sort, filters, and a resettable empty state instead.',
|
||||
'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts page retrofit deferred; no action-surface declaration yet.',
|
||||
'App\\Filament\\Pages\\Monitoring\\Operations' => 'Monitoring operations page retrofit deferred; canonical route behavior already covered elsewhere.',
|
||||
'App\\Filament\\Pages\\NoAccess' => 'No-access page has no actionable surface by design.',
|
||||
'App\\Filament\\Pages\\Operations\\TenantlessOperationRunViewer' => 'Tenantless run viewer retrofit deferred; run-link semantics are covered by monitoring tests.',
|
||||
'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\\TenantDiagnostics' => 'Diagnostics page retrofit deferred to tenant-RBAC diagnostics spec.',
|
||||
'App\\Filament\\Pages\\TenantRequiredPermissions' => 'Permissions page retrofit deferred; capability checks already enforced by dedicated tests.',
|
||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.',
|
||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.',
|
||||
'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.',
|
||||
'App\\Filament\\Resources\\TenantResource\\RelationManagers\\TenantMembershipsRelationManager' => 'Tenant memberships relation manager retrofit deferred to RBAC membership track.',
|
||||
'App\\Filament\\Resources\\Workspaces\\RelationManagers\\WorkspaceMembershipsRelationManager' => 'Workspace memberships relation manager retrofit deferred to workspace RBAC track.',
|
||||
], TenantOwnedModelFamilies::actionSurfaceBaselineExemptions()));
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
|
||||
final class ActionSurfaceProfileDefinition
|
||||
{
|
||||
@ -55,4 +56,36 @@ public function requiresExportDefaultBulk(ActionSurfaceProfile $profile): bool
|
||||
ActionSurfaceProfile::RunLog,
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ActionSurfaceType>
|
||||
*/
|
||||
public function allowedSurfaceTypes(ActionSurfaceProfile $profile): array
|
||||
{
|
||||
return match ($profile) {
|
||||
ActionSurfaceProfile::CrudListAndEdit,
|
||||
ActionSurfaceProfile::CrudListAndView => [
|
||||
ActionSurfaceType::CrudListFirstResource,
|
||||
ActionSurfaceType::ReadOnlyRegistryReport,
|
||||
ActionSurfaceType::ConfigLite,
|
||||
],
|
||||
ActionSurfaceProfile::ListOnlyReadOnly => [
|
||||
ActionSurfaceType::ReadOnlyRegistryReport,
|
||||
],
|
||||
ActionSurfaceProfile::RunLog => [
|
||||
ActionSurfaceType::ReadOnlyRegistryReport,
|
||||
ActionSurfaceType::QueueReview,
|
||||
ActionSurfaceType::HistoryAudit,
|
||||
],
|
||||
ActionSurfaceProfile::RelationManager => [
|
||||
ActionSurfaceType::CrudListFirstResource,
|
||||
ActionSurfaceType::ReadOnlyRegistryReport,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
public function allowsSurfaceType(ActionSurfaceProfile $profile, ActionSurfaceType $surfaceType): bool
|
||||
{
|
||||
return in_array($surfaceType, $this->allowedSurfaceTypes($profile), true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,6 +95,7 @@ className: $component->className,
|
||||
}
|
||||
|
||||
$this->validateRequiredSlots($component->className, $declaration, $issues);
|
||||
$this->validateBehaviorAwareContract($component->className, $declaration, $issues);
|
||||
$this->validateExemptions($component->className, $declaration, $issues);
|
||||
$this->validateExportDefaults($component->className, $declaration, $issues);
|
||||
}
|
||||
@ -222,6 +223,93 @@ private function validateInspectAffordanceSlot(
|
||||
ActionSurfaceSlotRequirement $requirement,
|
||||
array &$issues,
|
||||
): void {
|
||||
$this->resolveInspectAffordance($className, $requirement, $issues);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||
*/
|
||||
private function validateBehaviorAwareContract(
|
||||
string $className,
|
||||
ActionSurfaceDeclaration $declaration,
|
||||
array &$issues,
|
||||
): void {
|
||||
if (! $declaration->requiresBehaviorAwareContract()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($declaration->surfaceType === null) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: 'Behavior-aware declarations must define a surface type.',
|
||||
hint: 'Pass an ActionSurfaceType when creating the declaration.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->profileDefinition->allowsSurfaceType($declaration->profile, $declaration->surfaceType)) {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
message: sprintf(
|
||||
'Surface type "%s" is incompatible with profile "%s".',
|
||||
$declaration->surfaceType->value,
|
||||
$declaration->profile->value,
|
||||
),
|
||||
hint: 'Choose a surface type allowed for the profile or change the profile to match the rendered list behavior.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$requirement = $declaration->slot(ActionSurfaceSlot::InspectAffordance);
|
||||
|
||||
if (! $requirement instanceof ActionSurfaceSlotRequirement || $requirement->isExempt()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$affordance = $this->resolveInspectAffordance($className, $requirement, $issues);
|
||||
|
||||
if (! $affordance instanceof ActionSurfaceInspectAffordance) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $declaration->surfaceType->allowsInspectAffordance($affordance)) {
|
||||
$allowed = implode(', ', array_map(
|
||||
static fn (ActionSurfaceInspectAffordance $allowedAffordance): string => $allowedAffordance->value,
|
||||
$declaration->surfaceType->allowedInspectAffordances(),
|
||||
));
|
||||
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
slot: ActionSurfaceSlot::InspectAffordance,
|
||||
message: sprintf(
|
||||
'Inspect affordance "%s" is incompatible with surface type "%s".',
|
||||
$affordance->value,
|
||||
$declaration->surfaceType->value,
|
||||
),
|
||||
hint: sprintf('Allowed: %s.', $allowed),
|
||||
);
|
||||
}
|
||||
|
||||
if ($affordance->isPrimaryLinkColumn() && trim((string) $declaration->primaryLinkColumnReason()) === '') {
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
className: $className,
|
||||
slot: ActionSurfaceSlot::InspectAffordance,
|
||||
message: 'Primary link column inspect affordance requires a non-empty reason.',
|
||||
hint: 'Call ->withPrimaryLinkColumnReason("why row click is not the right primary inspect model").',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||
*/
|
||||
private function resolveInspectAffordance(
|
||||
string $className,
|
||||
ActionSurfaceSlotRequirement $requirement,
|
||||
array &$issues,
|
||||
): ?ActionSurfaceInspectAffordance {
|
||||
$mode = $requirement->details;
|
||||
|
||||
if (! is_string($mode) || trim($mode) === '') {
|
||||
@ -232,11 +320,13 @@ className: $className,
|
||||
hint: 'Use ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value).',
|
||||
);
|
||||
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ActionSurfaceInspectAffordance::tryFrom($mode) instanceof ActionSurfaceInspectAffordance) {
|
||||
return;
|
||||
$affordance = ActionSurfaceInspectAffordance::tryFrom($mode);
|
||||
|
||||
if ($affordance instanceof ActionSurfaceInspectAffordance) {
|
||||
return $affordance;
|
||||
}
|
||||
|
||||
$issues[] = new ActionSurfaceValidationIssue(
|
||||
@ -245,6 +335,8 @@ className: $className,
|
||||
message: sprintf('Invalid inspect affordance mode "%s".', $mode),
|
||||
hint: 'Allowed: clickable_row, view_action, primary_link_column.',
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -9,4 +9,19 @@ enum ActionSurfaceInspectAffordance: string
|
||||
case ClickableRow = 'clickable_row';
|
||||
case ViewAction = 'view_action';
|
||||
case PrimaryLinkColumn = 'primary_link_column';
|
||||
|
||||
public function isExplicitInspect(): bool
|
||||
{
|
||||
return $this === self::ViewAction;
|
||||
}
|
||||
|
||||
public function isPrimaryLinkColumn(): bool
|
||||
{
|
||||
return $this === self::PrimaryLinkColumn;
|
||||
}
|
||||
|
||||
public function isSingleClickOpen(): bool
|
||||
{
|
||||
return $this === self::ClickableRow || $this === self::PrimaryLinkColumn;
|
||||
}
|
||||
}
|
||||
|
||||
60
app/Support/Ui/ActionSurface/Enums/ActionSurfaceType.php
Normal file
60
app/Support/Ui/ActionSurface/Enums/ActionSurfaceType.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\ActionSurface\Enums;
|
||||
|
||||
enum ActionSurfaceType: string
|
||||
{
|
||||
case CrudListFirstResource = 'crud_list_first_resource';
|
||||
case ReadOnlyRegistryReport = 'read_only_registry_report';
|
||||
case QueueReview = 'queue_review';
|
||||
case HistoryAudit = 'history_audit';
|
||||
case ConfigLite = 'config_lite';
|
||||
|
||||
/**
|
||||
* @return array<int, ActionSurfaceInspectAffordance>
|
||||
*/
|
||||
public function allowedInspectAffordances(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::CrudListFirstResource,
|
||||
self::ReadOnlyRegistryReport,
|
||||
self::ConfigLite => [
|
||||
ActionSurfaceInspectAffordance::ClickableRow,
|
||||
ActionSurfaceInspectAffordance::PrimaryLinkColumn,
|
||||
],
|
||||
self::QueueReview,
|
||||
self::HistoryAudit => [
|
||||
ActionSurfaceInspectAffordance::ViewAction,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
public function allowsInspectAffordance(ActionSurfaceInspectAffordance $affordance): bool
|
||||
{
|
||||
return in_array($affordance, $this->allowedInspectAffordances(), true);
|
||||
}
|
||||
|
||||
public function expectsExplicitInspect(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::QueueReview,
|
||||
self::HistoryAudit => true,
|
||||
self::CrudListFirstResource,
|
||||
self::ReadOnlyRegistryReport,
|
||||
self::ConfigLite => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function supportsPrimaryLinkColumnException(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::CrudListFirstResource,
|
||||
self::ReadOnlyRegistryReport,
|
||||
self::ConfigLite => true,
|
||||
self::QueueReview,
|
||||
self::HistoryAudit => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -12,10 +12,14 @@ enum DerivedStateFamily: string
|
||||
case RelatedNavigationPrimary = 'related_navigation_primary';
|
||||
case RelatedNavigationDetail = 'related_navigation_detail';
|
||||
case RelatedNavigationHeader = 'related_navigation_header';
|
||||
case TenantGovernanceAggregate = 'tenant_governance_aggregate';
|
||||
|
||||
public function allowsNegativeResultCache(): bool
|
||||
{
|
||||
return true;
|
||||
return match ($this) {
|
||||
self::TenantGovernanceAggregate => false,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
public function defaultFreshnessPolicy(): string
|
||||
|
||||
@ -90,8 +90,8 @@ public static function firstSlice(): array
|
||||
'resource' => RestoreRunResource::class,
|
||||
'tenant_relationship' => 'tenant',
|
||||
'search_posture' => 'not_applicable',
|
||||
'action_surface' => 'baseline_exemption',
|
||||
'action_surface_reason' => 'Restore run resource retrofit is deferred to the restore track and remains explicitly exempt in the action-surface baseline.',
|
||||
'action_surface' => 'declared',
|
||||
'action_surface_reason' => 'RestoreRunResource declares its action surface contract directly.',
|
||||
'notes' => 'Restore runs are not part of global search.',
|
||||
],
|
||||
'Finding' => [
|
||||
|
||||
@ -120,7 +120,7 @@ ### Missing (no code, no spec beyond brainstorming)
|
||||
|
||||
## Architecture & Principles (Non-Negotiables)
|
||||
|
||||
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.14.0)
|
||||
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v2.0.0)
|
||||
|
||||
### Core Principles
|
||||
|
||||
@ -130,7 +130,7 @@ ### Core Principles
|
||||
4. **Deterministic Capabilities** — Backup/restore/risk flags derived from config via `CoverageCapabilitiesResolver`.
|
||||
5. **Workspace Isolation** — Non-member → 404; workspace is primary session context. Enforced via `DenyNonMemberTenantAccess` middleware + `EnsureFilamentTenantSelected`.
|
||||
6. **Tenant Isolation** — Every read/write must be tenant-scoped; `DerivesWorkspaceIdFromTenant` concern auto-fills `workspace_id` from tenant.
|
||||
7. **Operator Surface Principles** — `/admin` defaults are operator-first, diagnostics are progressively disclosed, status dimensions stay distinct, mutation scope is explicit before execution, and every materially changed operator page carries an explicit page contract.
|
||||
7. **UI/UX Constitution v1** — Every operator-facing admin surface is classified before implementation, each list gets exactly one primary inspect/open model, scope signals must be truthful, critical operational truth stays default-visible, and exceptions are catalogued and tested.
|
||||
8. **Filament-native first / no ad-hoc styling** — Admin and operator UI must use Filament-native components or shared primitives before any local Blade/Tailwind assembly; page-local status styling is not an acceptable substitute.
|
||||
9. **Proportionality first** — New structure, layers, persistence, and semantic machinery must be justified by current release truth, current operator workflow, and why a narrower solution is insufficient.
|
||||
10. **Anti-bloat guardrails** — No premature abstraction, no new persisted truth without independent source-of-truth need, no new domain state without behavioral consequence, and specs that add structural complexity must carry an explicit proportionality review.
|
||||
@ -158,7 +158,7 @@ ### Operations UX
|
||||
|
||||
### Filament Standards
|
||||
|
||||
- **Action Surface Contract**: List/View/Create/Edit pages each have defined required surfaces (Spec 082, 090).
|
||||
- **Action Surface Contract**: List/View/Create/Edit pages each have defined required surfaces, one primary inspect model, no redundant View beside row click, and no empty overflow or bulk groups (Specs 082, 090).
|
||||
- **Layout**: Main/Aside layout, sections required, view pages use Infolists.
|
||||
- **Badge Semantics**: Centralized via `BadgeCatalog`/`BadgeRenderer` (Specs 059, 060).
|
||||
- **Filament-native UI**: Native Filament components and shared primitives come before any local styling or replacement markup for semantic UI elements.
|
||||
|
||||
@ -3,7 +3,7 @@ # Product Principles
|
||||
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
|
||||
> New specs must align with these. If a principle needs to change, update this file first.
|
||||
|
||||
**Last reviewed**: 2026-03-27
|
||||
**Last reviewed**: 2026-03-28
|
||||
|
||||
---
|
||||
|
||||
@ -101,14 +101,25 @@ ### Data minimization & safe logging
|
||||
|
||||
## UI & Information Architecture
|
||||
|
||||
### UI/UX constitution governs operator surfaces
|
||||
Every operator-facing admin surface must be classified before implementation.
|
||||
Allowed surface types are CRUD / List-first, Queue / Review, History / Audit, Config-lite, Read-only Registry / Report, and Detail-first Operational.
|
||||
Ad-hoc interaction models are forbidden.
|
||||
|
||||
### One primary inspect model per list
|
||||
Every list has exactly one dominant inspect/open model.
|
||||
No redundant View beside row click or identifier click.
|
||||
Queue and Audit surfaces use explicit Inspect; Config-lite alone may use edit-as-inspect.
|
||||
|
||||
### UX-001: Layout & IA Standards
|
||||
Main/Aside layout. Sections required. View pages use Infolists.
|
||||
Empty states with specific title + explanation + exactly 1 CTA.
|
||||
|
||||
### Action Surface Contract (non-negotiable)
|
||||
Required surfaces per page type (list/view/create/edit).
|
||||
Max 2 visible row actions. Destructive requires confirmation.
|
||||
Every spec with UI changes must include a UI Action Matrix.
|
||||
Standard CRUD and Registry rows expose exactly one primary open path plus at most one inline safe shortcut.
|
||||
Destructive actions are grouped and confirmed. Empty overflow or bulk groups are forbidden.
|
||||
Every spec with UI changes must include both a UI/UX Surface Classification and a UI Action Matrix.
|
||||
|
||||
### Badge semantics centralized
|
||||
All status badges via `BadgeCatalog` / `BadgeRenderer`. No ad-hoc badge mappings.
|
||||
@ -124,7 +135,7 @@ ### UI semantics stay lightweight
|
||||
|
||||
### Canonical navigation and terminology
|
||||
Consistent naming, consistent routing, consistent mental model.
|
||||
No competing terms for the same concept.
|
||||
No competing terms for the same concept. Scope signals must remain truthful.
|
||||
|
||||
### Operator-first surfaces
|
||||
`/admin` defaults are for operators, not raw implementation visibility.
|
||||
@ -134,6 +145,14 @@ ### Distinct status and mutation semantics
|
||||
Execution outcome, data completeness, governance result, and lifecycle/readiness stay separate when they all exist.
|
||||
Every state-changing action tells the operator whether it affects TenantPilot only, the Microsoft tenant, or simulation only before execution.
|
||||
|
||||
### Critical truth is default-visible
|
||||
Critical operational truth must not hide only in tooltips, overflow menus, default-off columns, or detail pages when the list prepares decisions.
|
||||
Standard CRUD surfaces stay scanable and do not collapse multiple truth dimensions into one generic status.
|
||||
|
||||
### Exceptions are catalogued and tested
|
||||
Only named UI exception types are allowed.
|
||||
Every exception must be documented in the spec and PR with rationale and dedicated tests.
|
||||
|
||||
### Page contract requirement
|
||||
Every new or materially refactored operator-facing page defines its persona, surface type, primary operator question,
|
||||
default-visible information, diagnostics-only information, status dimensions, mutation scope, primary actions, and dangerous actions.
|
||||
|
||||
@ -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-27
|
||||
**Last reviewed**: 2026-03-28
|
||||
|
||||
---
|
||||
|
||||
@ -42,7 +42,7 @@ ## Related Docs
|
||||
|
||||
| Document | Location | Purpose |
|
||||
|---|---|---|
|
||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-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, UI-SURF-001, UI-HARD-001, UI-EX-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) |
|
||||
|
||||
@ -3,7 +3,7 @@ # Filament Actions UX Standard
|
||||
> Canonical rules for row actions, bulk actions, header actions, and inspect affordances on all Filament table surfaces.
|
||||
> This standard consolidates the Action Surface Contract from the constitution and `docs/ui/action-surface-contract.md`.
|
||||
|
||||
**Last reviewed**: 2026-03-09
|
||||
**Last reviewed**: 2026-03-30
|
||||
|
||||
---
|
||||
|
||||
@ -31,19 +31,36 @@ ## Inspect Affordance (Required)
|
||||
|
||||
Every list-style surface must provide a way to open a record.
|
||||
|
||||
### Preferred: clickable rows
|
||||
### Default: clickable rows for list-first and registry surfaces
|
||||
|
||||
```php
|
||||
$table->recordUrl(fn ($record) => /* route to view/edit */)
|
||||
```
|
||||
|
||||
### Alternative: primary link column or View action
|
||||
Use this default for:
|
||||
|
||||
Use only when clickable rows are impractical.
|
||||
- CRUD / List-first resources
|
||||
- Read-only registries / reports
|
||||
- Reporting and evidence registers such as Review Register and Evidence Overview
|
||||
|
||||
### Explicit inspect for queue and audit surfaces
|
||||
|
||||
Use an `Inspect` row action or equivalent same-page selected detail when chronology or queue context must remain visible.
|
||||
|
||||
- Audit / history pages use explicit inspect
|
||||
- Queue / review pages use explicit inspect
|
||||
- These surfaces should not also expose row click as a competing open path
|
||||
|
||||
### Alternative: primary link column
|
||||
|
||||
Use only when clickable rows are impractical on a clickable-row surface.
|
||||
|
||||
- `PrimaryLinkColumn` requires a non-empty declaration reason
|
||||
- It is not a fallback for queue / review or audit surfaces
|
||||
|
||||
### Rule: no lone "View" button
|
||||
|
||||
If "View" is the only row action, prefer clickable rows and set `actions([])` to avoid an unnecessary Actions column.
|
||||
If an open action is the only row action on a clickable-row surface, prefer row click and set `actions([])` to avoid an unnecessary Actions column.
|
||||
|
||||
---
|
||||
|
||||
@ -52,6 +69,7 @@ ## Row Action Limits
|
||||
- **Max 2 visible** row actions (typically View/Edit or Edit/Delete)
|
||||
- Everything else goes into an `ActionGroup` labeled "More"
|
||||
- Destructive actions must never be the primary visible action
|
||||
- On clickable-row surfaces, prefer zero inline row actions unless a single justified shortcut materially improves the workflow
|
||||
|
||||
---
|
||||
|
||||
@ -60,6 +78,7 @@ ## Bulk Actions
|
||||
- Bulk actions must be grouped via `BulkActionGroup`
|
||||
- Destructive bulk actions require confirmation
|
||||
- Typed confirmation may be required for large/bulk changes
|
||||
- Do not leave an empty `BulkActionGroup` placeholder visible once filters, record state, or RBAC remove every effective action
|
||||
|
||||
---
|
||||
|
||||
@ -80,18 +99,29 @@ ### Standard action labels
|
||||
|
||||
| Action | Label | Icon guidance |
|
||||
|---|---|---|
|
||||
| View record | "View" or clickable row | heroicon-o-eye |
|
||||
| Inspect record | "Inspect" | heroicon-o-eye |
|
||||
| Edit record | "Edit" | heroicon-o-pencil-square |
|
||||
| Delete record | "Delete" | heroicon-o-trash |
|
||||
| Archive record | "Archive" | heroicon-o-archive-box |
|
||||
| Restore record | "Restore" | heroicon-o-arrow-uturn-left |
|
||||
| Force delete | "Force Delete" | heroicon-o-trash |
|
||||
|
||||
Notes:
|
||||
- Clickable-row surfaces normally do not render a `View` or `Inspect` row action at all.
|
||||
- Reporting/evidence registers keep row click primary and should not add a duplicate `View review`-style action.
|
||||
|
||||
### Action ordering in "More" group
|
||||
|
||||
1. Non-destructive operations first
|
||||
2. Destructive operations last
|
||||
3. Separated by divider if Filament supports it
|
||||
1. Navigation or inspect helpers first
|
||||
2. Non-destructive workflow or lifecycle actions next
|
||||
3. Destructive actions last
|
||||
4. Do not render an empty `ActionGroup` placeholder
|
||||
|
||||
Representative expectations:
|
||||
|
||||
- `Policies`: `Export`, then `Sync`, then destructive ignore/delete actions
|
||||
- `Backup schedules`: `Run now` / `Retry` before archive or force delete
|
||||
- `Workspaces`: row click stays primary and the secondary `Edit` shortcut lives under `More`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -6,20 +6,72 @@ ## Inspect affordance (required)
|
||||
|
||||
Any list-style surface that exposes records must provide an **inspect affordance** so an admin can open a record.
|
||||
|
||||
Accepted implementations:
|
||||
### Surface-type decision tree
|
||||
|
||||
The inspect model is driven by the declaration `surfaceType`:
|
||||
|
||||
- **CRUD / List-first Resource**
|
||||
Use one-click open by default, normally `recordUrl()`. `PrimaryLinkColumn` is allowed only as an explicit exception with a concrete reason.
|
||||
- **Read-only Registry / Report**
|
||||
Use one-click open by default, normally `recordUrl()`. This includes scan-first reporting surfaces such as Monitoring Operations, Review Register, Evidence Overview, and read-only registry resources.
|
||||
- **Queue / Review**
|
||||
Use explicit inspect (`Inspect` row action or equivalent same-page selected detail). Do not make the full row clickable.
|
||||
- **History / Audit**
|
||||
Use explicit inspect (`Inspect` row action or equivalent same-page selected detail). Do not make the full row clickable.
|
||||
- **Config-lite**
|
||||
Edit-as-inspect is allowed, but it still uses one obvious open path and must not add a competing `View` action.
|
||||
|
||||
### Accepted implementations
|
||||
|
||||
- **Clickable rows** (preferred): set `recordUrl()` for the table.
|
||||
- **View action**: a `ViewAction` in the row actions.
|
||||
- **Primary link column**: a column that is clearly the primary affordance to open the record.
|
||||
- **Inspect action**: a row action used only on queue / review or history / audit surfaces where context must stay on the same page.
|
||||
- **Primary link column**: a column that is clearly the primary affordance to open the record, with an explicit `PrimaryLinkColumn` reason in the declaration.
|
||||
|
||||
### Rule: no lone “View” button
|
||||
|
||||
Avoid rendering a table that only has a single `View` row action. This creates visual noise and adds an unnecessary Actions column.
|
||||
Avoid rendering a table that only has a single inspect-style row action on a clickable-row surface. This creates visual noise and adds an unnecessary Actions column.
|
||||
|
||||
Preferred approach:
|
||||
|
||||
- Make the row clickable via `recordUrl()` and set `actions([])` so no Actions column is rendered.
|
||||
|
||||
### PrimaryLinkColumn exception rule
|
||||
|
||||
Use `PrimaryLinkColumn` only when full-row click is the wrong interaction model for that specific surface.
|
||||
|
||||
- The declaration must use a clickable surface type (`CrudListFirstResource`, `ReadOnlyRegistryReport`, or `ConfigLite`).
|
||||
- The declaration must include a non-empty `primaryLinkColumnReason`.
|
||||
- Queue / review and history / audit surfaces may not use `PrimaryLinkColumn` as a shortcut around explicit inspect.
|
||||
|
||||
### Reporting / evidence register rule
|
||||
|
||||
Review and evidence registers are governed as **ReadOnlyRegistryReport** surfaces.
|
||||
|
||||
- `ReviewRegister` and `EvidenceOverview` keep clickable-row inspection as the primary open path.
|
||||
- Do not add a duplicate `View review` or equivalent open action beside the row click.
|
||||
- Safe non-inspect shortcuts may remain when they are clearly secondary.
|
||||
|
||||
## More-menu ordering
|
||||
|
||||
Governed `ActionGroup` and `BulkActionGroup` menus use one stable order:
|
||||
|
||||
- Navigation or inspect helpers first
|
||||
- Non-destructive workflow or lifecycle actions next
|
||||
- Destructive actions last
|
||||
|
||||
Examples:
|
||||
|
||||
- `Policies`: export before sync, sync before ignore/delete
|
||||
- `Backup schedules`: run/retry before archive or force delete
|
||||
- `Tenants`: related onboarding and safe navigation shortcuts before sync or verification, with archive/force delete trailing
|
||||
|
||||
## Placeholder groups are forbidden
|
||||
|
||||
`ActionGroup` and `BulkActionGroup` exist to hold real secondary actions, not to reserve layout space.
|
||||
|
||||
- Do not render an empty `More` menu after visibility, record-state, or RBAC filtering removes every effective action.
|
||||
- On clickable-row surfaces with only one safe shortcut, that shortcut may still live under `More` when it preserves a cleaner scan-first list.
|
||||
|
||||
## RBAC / safety
|
||||
|
||||
- If the current user cannot inspect a record, `recordUrl()` must return `null` for that record.
|
||||
|
||||
@ -43,7 +43,7 @@ ### User Story 2 - Consistent, enterprise-grade actions and empty-state guidance
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a list view with records, **When** a user inspects available actions, **Then** they see at most two visible row actions and all secondary actions are grouped under a consistent label.
|
||||
1. **Given** a list view with records, **When** a user inspects available actions, **Then** they see exactly one inspect model for the list (prefer clickable rows when a View page exists), at most two visible non-destructive row actions, and all secondary actions are grouped under a consistent label.
|
||||
2. **Given** a list view with zero records, **When** the page is shown, **Then** it includes at least one CTA that helps resolve the empty state.
|
||||
3. **Given** a list view that supports selection, **When** the user selects one or more records, **Then** at least one bulk action is available or the UI explicitly documents why bulk is intentionally not offered.
|
||||
|
||||
@ -100,7 +100,8 @@ ### Functional Requirements
|
||||
- lists any exemptions with reasons.
|
||||
- **FR-005 (Automated gate)**: The system MUST automatically validate contract compliance for all in-scope UI components in the code review pipeline, and MUST fail with actionable messages when requirements are not met.
|
||||
- **FR-006 (Consistency rules)**: The system MUST enforce the following UX conventions through (a) declaration validation for all in-scope surfaces and (b) runtime tests on representative surfaces:
|
||||
- no more than two visible row actions (typically View/Edit),
|
||||
- one inspect affordance per list surface; when a View page exists, prefer row-click inspection and do not pair it with a redundant lone `View` row button,
|
||||
- no more than two visible non-destructive row actions, and when inspection is row-click driven keep at most one additional inline shortcut,
|
||||
- secondary actions grouped under the standard label “More”,
|
||||
- bulk actions grouped under the standard label “More”,
|
||||
- destructive actions are never primary.
|
||||
@ -154,9 +155,9 @@ ## UI Action Matrix *(mandatory when admin UI components are changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| CRUD list + detail | Tenant panel + Admin panel | Primary: Create (if allowed). Optional: domain CTA (e.g., Sync/Run) | Primary: View, Edit (if allowed). Secondary: grouped under “More” | At least 1 bulk action (e.g., Archive/Restore/Export) | Primary CTA: Create or domain CTA | Primary: Edit (if allowed). Secondary grouped under “More” | Consistent Save + Cancel; no destructive primary | Yes (for mutations + operation-start) | Exempt bulk only with explicit reason (e.g., no safe bulk operation exists) |
|
||||
| ReadOnly list + detail | Tenant panel + Admin panel | At least 1 CTA that provides value (e.g., Export/Refresh) | Primary: View. Secondary grouped under “More” | Bulk: Export (default) | CTA that resolves empty state (e.g., Refresh now) | “More” group for secondary actions | N/A | Maybe (typically for operation-start actions) | Exempt Export only with explicit reason (e.g., legal/security constraints) |
|
||||
| RunLog list + detail | Tenant panel + Admin panel | Optional CTA routing to Operations hub | Primary: View | Bulk: Export (default) or Prune if retention exists; otherwise exempt with reason | Optional CTA routing to Operations hub | “View run” canonical link; optional Export | N/A | Maybe (for prune/retention changes) | “View run” must be canonical tenantless as primary deep link |
|
||||
| CRUD list + detail | Tenant panel + Admin panel | Primary: Create (if allowed). Optional: domain CTA (e.g., Sync/Run) | Inspect via clickable row or `View` action, not both. Optional: one inline safe shortcut such as `Edit`; secondary actions grouped under “More” | At least 1 bulk action (e.g., Archive/Restore/Export) | Primary CTA: Create or domain CTA | Primary: Edit (if allowed). Secondary grouped under “More” | Consistent Save + Cancel; no destructive primary | Yes (for mutations + operation-start) | Exempt bulk only with explicit reason (e.g., no safe bulk operation exists) |
|
||||
| ReadOnly list + detail | Tenant panel + Admin panel | At least 1 CTA that provides value (e.g., Export/Refresh) | Inspect via clickable row or `View` action, not both. Secondary grouped under “More” when needed | Bulk: Export (default) | CTA that resolves empty state (e.g., Refresh now) | “More” group for secondary actions | N/A | Maybe (typically for operation-start actions) | Exempt Export only with explicit reason (e.g., legal/security constraints) |
|
||||
| RunLog list + detail | Tenant panel + Admin panel | Optional CTA routing to Operations hub | Inspect via clickable row or `View` action, not both | Bulk: Export (default) or Prune if retention exists; otherwise exempt with reason | Optional CTA routing to Operations hub | “View run” canonical link; optional Export | N/A | Maybe (for prune/retention changes) | “View run” must be canonical tenantless as primary deep link |
|
||||
| Embedded relationship sub-list | Under parent detail | Header: Add/Attach/Create/Refresh depending on context | Primary: View and one context action (e.g., Detach/Remove). Secondary grouped under “More” | At least 1 bulk action or exemption with reason | CTA: next step (Add/Attach/Refresh) | N/A | N/A | Yes (for mutations) | Bulk may be exempted if relationship action is inherently single-record |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
@ -238,9 +238,9 @@ ## Dependencies
|
||||
|
||||
## Follow-up Spec Candidates
|
||||
|
||||
- **Spec 167 — Finding Resolution Origin & Workflow Truth Foundation** if the current data model cannot stably distinguish workflow resolution from no-longer-observed truth on all required surfaces
|
||||
- **Spec 168 — Exception Expiry Alerts & Governance Notifications** for proactive alerting around expiring or expired governance
|
||||
- **Spec 169 — Finding Detail Workflow History & Reopen Context** for deeper recurrence, prior-resolution, and historical workflow storytelling
|
||||
- **Finding Resolution Origin & Workflow Truth Foundation** if the current data model cannot stably distinguish workflow resolution from no-longer-observed truth on all required surfaces
|
||||
- **Exception Expiry Alerts & Governance Notifications** for proactive alerting around expiring or expired governance
|
||||
- **Finding Detail Workflow History & Reopen Context** for deeper recurrence, prior-resolution, and historical workflow storytelling
|
||||
|
||||
## Definition of Done
|
||||
|
||||
|
||||
@ -296,6 +296,86 @@ x-derived-state-consumers:
|
||||
max: 1
|
||||
- needle: '->operationLinksFresh($this->run, $this->relatedLinksTenant())'
|
||||
max: 1
|
||||
- surface: tenant.dashboard.baseline_governance
|
||||
family: tenant_governance_aggregate
|
||||
variant: tenant_governance_summary
|
||||
accessPattern: widget_safe
|
||||
scopeInputs:
|
||||
- record_class
|
||||
- record_key
|
||||
- workspace_id
|
||||
- tenant_id
|
||||
freshnessPolicy: invalidate_after_mutation
|
||||
guardScope:
|
||||
- app/Filament/Widgets/Dashboard/BaselineCompareNow.php
|
||||
requiredMarkers:
|
||||
- 'private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate'
|
||||
- '$this->governanceAggregate($tenant)'
|
||||
- 'summaryAssessment'
|
||||
maxOccurrences:
|
||||
- needle: 'BaselineCompareStats::forTenant('
|
||||
max: 0
|
||||
- surface: tenant.banner.baseline_compare_coverage
|
||||
family: tenant_governance_aggregate
|
||||
variant: tenant_governance_summary
|
||||
accessPattern: widget_safe
|
||||
scopeInputs:
|
||||
- record_class
|
||||
- record_key
|
||||
- workspace_id
|
||||
- tenant_id
|
||||
freshnessPolicy: invalidate_after_mutation
|
||||
guardScope:
|
||||
- app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php
|
||||
requiredMarkers:
|
||||
- 'private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate'
|
||||
- '$this->governanceAggregate($tenant)'
|
||||
- 'nextActionUrl'
|
||||
maxOccurrences:
|
||||
- needle: 'BaselineCompareStats::forTenant('
|
||||
max: 0
|
||||
- surface: tenant.page.baseline_compare_landing
|
||||
family: tenant_governance_aggregate
|
||||
variant: tenant_governance_summary
|
||||
accessPattern: page_safe
|
||||
scopeInputs:
|
||||
- record_class
|
||||
- record_key
|
||||
- workspace_id
|
||||
- tenant_id
|
||||
freshnessPolicy: invalidate_after_mutation
|
||||
guardScope:
|
||||
- app/Filament/Pages/BaselineCompareLanding.php
|
||||
requiredMarkers:
|
||||
- 'private function governanceAggregate(Tenant $tenant, BaselineCompareStats $stats): TenantGovernanceAggregate'
|
||||
- '$this->governanceAggregate($tenant, $stats)'
|
||||
- 'Compare now'
|
||||
maxOccurrences:
|
||||
- needle: 'BaselineCompareStats::forTenant('
|
||||
max: 1
|
||||
- needle: '$stats->summaryAssessment()'
|
||||
max: 0
|
||||
- surface: tenant.dashboard.needs_attention
|
||||
family: tenant_governance_aggregate
|
||||
variant: tenant_governance_summary
|
||||
accessPattern: widget_safe
|
||||
scopeInputs:
|
||||
- record_class
|
||||
- record_key
|
||||
- workspace_id
|
||||
- tenant_id
|
||||
freshnessPolicy: invalidate_after_mutation
|
||||
guardScope:
|
||||
- app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
requiredMarkers:
|
||||
- 'private function governanceAggregate(Tenant $tenant): TenantGovernanceAggregate'
|
||||
- '$this->governanceAggregate($tenant)'
|
||||
- 'Baseline compare posture'
|
||||
maxOccurrences:
|
||||
- needle: 'Finding::query()'
|
||||
max: 0
|
||||
- needle: 'BaselineCompareStats::forTenant('
|
||||
max: 0
|
||||
paths:
|
||||
/contracts/derived-state/resolve:
|
||||
post:
|
||||
@ -514,6 +594,7 @@ components:
|
||||
- row_safe
|
||||
- page_safe
|
||||
- direct_once
|
||||
- widget_safe
|
||||
scopeInputs:
|
||||
type: array
|
||||
description: Scope or capability inputs that affect the result for this consumer.
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Tenant Governance Aggregate Contract
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-28
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation passed on first draft. The spec introduces one narrow derived aggregate contract, explicitly avoids new persistence and new state families, and keeps the work bounded to existing tenant-governance summary surfaces.
|
||||
@ -0,0 +1,335 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tenant Governance Aggregate Internal Surface Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for the shared tenant-governance summary aggregate
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 168. It documents the
|
||||
derived tenant-governance aggregate and the way tenant dashboard, banner, and
|
||||
Baseline Compare landing surfaces consume it. The rendered routes still return
|
||||
HTML. The structured schemas below describe the internal page and widget models
|
||||
that must be derivable before rendering. This does not add a public HTTP API.
|
||||
servers:
|
||||
- url: /internal
|
||||
x-governance-consumers:
|
||||
- surface: tenant.dashboard.needs_attention
|
||||
summarySource: tenant_governance_aggregate
|
||||
accessPattern: widget_safe
|
||||
guardScope:
|
||||
- app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
requiredMarkers:
|
||||
- 'TenantGovernanceAggregate'
|
||||
- 'Needs Attention'
|
||||
maxOccurrences:
|
||||
- needle: 'Finding::query()'
|
||||
max: 0
|
||||
- surface: tenant.dashboard.baseline_governance
|
||||
summarySource: tenant_governance_aggregate
|
||||
accessPattern: widget_safe
|
||||
guardScope:
|
||||
- app/Filament/Widgets/Dashboard/BaselineCompareNow.php
|
||||
requiredMarkers:
|
||||
- 'TenantGovernanceAggregate'
|
||||
- 'Baseline Governance'
|
||||
- surface: tenant.banner.baseline_compare_coverage
|
||||
summarySource: tenant_governance_aggregate
|
||||
accessPattern: widget_safe
|
||||
guardScope:
|
||||
- app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php
|
||||
requiredMarkers:
|
||||
- 'TenantGovernanceAggregate'
|
||||
- 'nextActionUrl'
|
||||
- surface: tenant.page.baseline_compare_landing
|
||||
summarySource: tenant_governance_aggregate
|
||||
accessPattern: page_safe
|
||||
guardScope:
|
||||
- app/Filament/Pages/BaselineCompareLanding.php
|
||||
requiredMarkers:
|
||||
- 'TenantGovernanceAggregate'
|
||||
- 'Compare now'
|
||||
paths:
|
||||
/tenants/{tenant}/governance-aggregate:
|
||||
get:
|
||||
summary: Resolve the derived tenant-governance aggregate for one tenant
|
||||
operationId: resolveTenantGovernanceAggregate
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Tenant route key or canonical tenant identifier for the current tenant scope.
|
||||
- name: surface
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/GovernanceSurface'
|
||||
responses:
|
||||
'200':
|
||||
description: Aggregate resolved for the current tenant scope
|
||||
content:
|
||||
application/vnd.tenantpilot.tenant-governance-aggregate+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantGovernanceAggregate'
|
||||
'403':
|
||||
description: Actor is in scope but lacks the capability required to view the target surface
|
||||
'404':
|
||||
description: Tenant is outside workspace or tenant entitlement scope
|
||||
/admin/t/{tenant}:
|
||||
get:
|
||||
summary: Tenant dashboard governance summary surfaces
|
||||
operationId: viewTenantDashboardGovernance
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered tenant dashboard with shared governance summary surfaces
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.tenant-dashboard-governance+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TenantDashboardGovernanceBundle'
|
||||
'403':
|
||||
description: Actor is in scope but lacks the required tenant capability
|
||||
'404':
|
||||
description: Tenant is not visible in the current workspace or membership scope
|
||||
/admin/t/{tenant}/baseline-compare-landing:
|
||||
get:
|
||||
summary: Baseline Compare landing governance summary and diagnostics split
|
||||
operationId: viewBaselineCompareLandingGovernance
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Rendered Baseline Compare landing page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.baseline-compare-landing-governance+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BaselineCompareLandingGovernanceView'
|
||||
'403':
|
||||
description: Actor is in scope but lacks the required tenant capability
|
||||
'404':
|
||||
description: Tenant is not visible in the current workspace or membership scope
|
||||
components:
|
||||
schemas:
|
||||
GovernanceSurface:
|
||||
type: string
|
||||
enum:
|
||||
- dashboard_needs_attention
|
||||
- dashboard_baseline_governance
|
||||
- baseline_compare_landing
|
||||
- coverage_banner
|
||||
NextActionTarget:
|
||||
type: string
|
||||
enum:
|
||||
- findings
|
||||
- run
|
||||
- landing
|
||||
- none
|
||||
GovernanceCounts:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- visibleDriftFindingsCount
|
||||
- overdueOpenFindingsCount
|
||||
- expiringGovernanceCount
|
||||
- lapsedGovernanceCount
|
||||
- activeNonNewFindingsCount
|
||||
- highSeverityActiveFindingsCount
|
||||
properties:
|
||||
visibleDriftFindingsCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
overdueOpenFindingsCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
expiringGovernanceCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
lapsedGovernanceCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
activeNonNewFindingsCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
highSeverityActiveFindingsCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
NextActionIntent:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- label
|
||||
- target
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
target:
|
||||
$ref: '#/components/schemas/NextActionTarget'
|
||||
ComparePosture:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- compareState
|
||||
- stateFamily
|
||||
- tone
|
||||
- headline
|
||||
- positiveClaimAllowed
|
||||
properties:
|
||||
compareState:
|
||||
type: string
|
||||
enum:
|
||||
- no_tenant
|
||||
- no_assignment
|
||||
- no_snapshot
|
||||
- idle
|
||||
- comparing
|
||||
- failed
|
||||
- ready
|
||||
stateFamily:
|
||||
type: string
|
||||
enum:
|
||||
- positive
|
||||
- caution
|
||||
- stale
|
||||
- action_required
|
||||
- in_progress
|
||||
- unavailable
|
||||
tone:
|
||||
type: string
|
||||
enum:
|
||||
- success
|
||||
- warning
|
||||
- danger
|
||||
- info
|
||||
- gray
|
||||
headline:
|
||||
type: string
|
||||
supportingMessage:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
reasonCode:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
lastComparedLabel:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
positiveClaimAllowed:
|
||||
type: boolean
|
||||
TenantGovernanceAggregate:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- tenantId
|
||||
- workspaceId
|
||||
- posture
|
||||
- counts
|
||||
- nextAction
|
||||
properties:
|
||||
tenantId:
|
||||
type: integer
|
||||
workspaceId:
|
||||
type: integer
|
||||
profileName:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
posture:
|
||||
$ref: '#/components/schemas/ComparePosture'
|
||||
counts:
|
||||
$ref: '#/components/schemas/GovernanceCounts'
|
||||
nextAction:
|
||||
$ref: '#/components/schemas/NextActionIntent'
|
||||
operationRunId:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
diagnosticsOwner:
|
||||
type: string
|
||||
enum:
|
||||
- baseline_compare_stats
|
||||
NeedsAttentionItem:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- title
|
||||
- badge
|
||||
- badgeColor
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
supportingMessage:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
badge:
|
||||
type: string
|
||||
badgeColor:
|
||||
type: string
|
||||
nextStep:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
TenantDashboardGovernanceBundle:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- aggregate
|
||||
properties:
|
||||
aggregate:
|
||||
$ref: '#/components/schemas/TenantGovernanceAggregate'
|
||||
needsAttentionItems:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/NeedsAttentionItem'
|
||||
baselineGovernanceCard:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- aggregate
|
||||
properties:
|
||||
aggregate:
|
||||
$ref: '#/components/schemas/TenantGovernanceAggregate'
|
||||
BaselineCompareLandingGovernanceView:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- aggregate
|
||||
- diagnosticsPolicy
|
||||
properties:
|
||||
aggregate:
|
||||
$ref: '#/components/schemas/TenantGovernanceAggregate'
|
||||
diagnosticsPolicy:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- compareDiagnosticsRemainLocal
|
||||
- evidenceGapDetailsRemainLocal
|
||||
- compareActionUnchanged
|
||||
properties:
|
||||
compareDiagnosticsRemainLocal:
|
||||
type: boolean
|
||||
evidenceGapDetailsRemainLocal:
|
||||
type: boolean
|
||||
compareActionUnchanged:
|
||||
type: boolean
|
||||
213
specs/168-tenant-governance-aggregate-contract/data-model.md
Normal file
213
specs/168-tenant-governance-aggregate-contract/data-model.md
Normal file
@ -0,0 +1,213 @@
|
||||
# Phase 1 Data Model: Tenant Governance Aggregate Contract
|
||||
|
||||
## Overview
|
||||
|
||||
This feature does not add a database table or persisted summary artifact. It formalizes the existing persistent source truths that already drive tenant governance posture and adds one derived runtime contract plus request-scoped reuse rules for the shared summary family.
|
||||
|
||||
## Persistent Source Truths
|
||||
|
||||
### Tenant
|
||||
|
||||
**Purpose**: The tenant is the scope boundary for the aggregate and for every covered surface.
|
||||
|
||||
**Key fields**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `external_id`
|
||||
|
||||
**Validation rules**:
|
||||
- Aggregate resolution is allowed only for one explicit tenant scope at a time.
|
||||
- Workspace membership and tenant entitlement remain authoritative before any summary surface renders.
|
||||
|
||||
### BaselineTenantAssignment and BaselineProfile
|
||||
|
||||
**Purpose**: Define whether the tenant has an assigned baseline and which profile and snapshot chain determine compare availability.
|
||||
|
||||
**Key fields**:
|
||||
- `baseline_tenant_assignments.tenant_id`
|
||||
- `baseline_tenant_assignments.baseline_profile_id`
|
||||
- `baseline_profiles.id`
|
||||
- `baseline_profiles.name`
|
||||
- `baseline_profiles.active_snapshot_id`
|
||||
|
||||
**Validation rules**:
|
||||
- Missing assignment and missing snapshot remain derived availability states, not new persisted governance states.
|
||||
|
||||
### BaselineSnapshot
|
||||
|
||||
**Purpose**: Supplies consumable compare-snapshot truth for the assigned baseline profile.
|
||||
|
||||
**Key fields**:
|
||||
- `id`
|
||||
- `baseline_profile_id`
|
||||
- lifecycle/completion fields already used by `BaselineSnapshotTruthResolver`
|
||||
|
||||
**Validation rules**:
|
||||
- Snapshot usability remains governed by existing compare truth logic.
|
||||
- The aggregate must not invent a second snapshot-availability rule set.
|
||||
|
||||
### OperationRun
|
||||
|
||||
**Purpose**: Supplies baseline-compare progress, completion, failure, and freshness context.
|
||||
|
||||
**Key fields**:
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `workspace_id`
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `completed_at`
|
||||
- `context`
|
||||
|
||||
**Validation rules**:
|
||||
- Only existing baseline-compare runs influence compare posture in this slice.
|
||||
- The aggregate does not introduce a new run type or a second operational state model.
|
||||
|
||||
### Finding and FindingException
|
||||
|
||||
**Purpose**: Supply overdue workflow state, visible drift pressure, and accepted-risk governance validity.
|
||||
|
||||
**Key fields**:
|
||||
- `findings.tenant_id`
|
||||
- `findings.status`
|
||||
- `findings.severity`
|
||||
- `findings.due_at`
|
||||
- `finding_exceptions.current_validity_state`
|
||||
|
||||
**Validation rules**:
|
||||
- Overdue, expiring, lapsed, active-non-new, and high-severity-active counts remain derived from current findings truth.
|
||||
- The aggregate must not redefine accepted-risk validity semantics.
|
||||
|
||||
## Existing Runtime Source Objects
|
||||
|
||||
### BaselineCompareStats
|
||||
|
||||
**Purpose**: Existing query-backed compare truth object that already combines compare availability, diagnostics, and governance-attention counts for one tenant.
|
||||
|
||||
**Key fields consumed by this feature**:
|
||||
- `state`
|
||||
- `profileName`
|
||||
- `operationRunId`
|
||||
- `findingsCount`
|
||||
- `lastComparedHuman`
|
||||
- `lastComparedIso`
|
||||
- `reasonCode`
|
||||
- `overdueOpenFindingsCount`
|
||||
- `expiringGovernanceCount`
|
||||
- `lapsedGovernanceCount`
|
||||
- `activeNonNewFindingsCount`
|
||||
- `highSeverityActiveFindingsCount`
|
||||
|
||||
**Relationship to the new aggregate**:
|
||||
- The aggregate is built from one `BaselineCompareStats` resolution.
|
||||
- Landing diagnostics continue to read `BaselineCompareStats` directly.
|
||||
|
||||
### BaselineCompareSummaryAssessment
|
||||
|
||||
**Purpose**: Existing summary interpretation object that maps `BaselineCompareStats` to posture family, tone, headline, supporting message, and next-action target.
|
||||
|
||||
**Key fields consumed by this feature**:
|
||||
- `stateFamily`
|
||||
- `headline`
|
||||
- `supportingMessage`
|
||||
- `tone`
|
||||
- `positiveClaimAllowed`
|
||||
- `reasonCode`
|
||||
- `nextActionTarget()`
|
||||
- `nextActionLabel()`
|
||||
|
||||
**Relationship to the new aggregate**:
|
||||
- The aggregate uses the summary assessment as its summary-semantics input.
|
||||
- The feature must not fork a second state-family interpretation path.
|
||||
|
||||
## New Derived Runtime Entities
|
||||
|
||||
### TenantGovernanceAggregate
|
||||
|
||||
**Purpose**: One tenant-scoped operator summary contract that owns the shared posture, count family, and next-action intent for tenant-governance summary surfaces.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `tenant_id` | int | yes | Tenant scope for the aggregate |
|
||||
| `workspace_id` | int | yes | Workspace scope for request-local reuse safety |
|
||||
| `profile_name` | string nullable | no | Assigned baseline profile name when available |
|
||||
| `compare_state` | string | yes | Existing compare availability or execution state from `BaselineCompareStats` |
|
||||
| `state_family` | string | yes | Existing summary posture family from `BaselineCompareSummaryAssessment` |
|
||||
| `tone` | string | yes | Existing tone family used by covered summary surfaces |
|
||||
| `headline` | string | yes | Operator-facing summary headline |
|
||||
| `supporting_message` | string nullable | no | Secondary operator-facing explanation |
|
||||
| `reason_code` | string nullable | no | Summary reason code when available |
|
||||
| `last_compared_label` | string nullable | no | Human-readable freshness label |
|
||||
| `visible_drift_findings_count` | int | yes | Visible drift findings count from compare stats |
|
||||
| `overdue_open_findings_count` | int | yes | Overdue open findings count |
|
||||
| `expiring_governance_count` | int | yes | Accepted-risk governance nearing expiry |
|
||||
| `lapsed_governance_count` | int | yes | Accepted-risk governance no longer valid |
|
||||
| `active_non_new_findings_count` | int | yes | Active non-new findings pressure |
|
||||
| `high_severity_active_findings_count` | int | yes | High-severity active findings pressure |
|
||||
| `next_action_label` | string | yes | Stable operator-facing next-step label |
|
||||
| `next_action_target` | enum(`findings`,`run`,`landing`,`none`) | yes | Stable next-action intent; surfaces map this to local URLs |
|
||||
| `positive_claim_allowed` | bool | yes | Whether the current summary posture qualifies as a trustworthy all-clear |
|
||||
| `stats` | `BaselineCompareStats` | yes | Embedded source truth used when a consumer also needs compare diagnostics |
|
||||
| `summary_assessment` | `BaselineCompareSummaryAssessment` | yes | Embedded summary truth used by all covered surfaces |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- The aggregate must be built from exactly one `BaselineCompareStats` instance and exactly one `BaselineCompareSummaryAssessment` derived from that stats instance.
|
||||
- Count fields must be copied from the same stats instance; surfaces must not recompute them locally.
|
||||
- Final URLs, local badges, and layout-specific copy remain outside the aggregate.
|
||||
|
||||
### TenantGovernanceAggregateResolver
|
||||
|
||||
**Purpose**: Service seam that resolves one `TenantGovernanceAggregate` per tenant scope and handles request-local reuse.
|
||||
|
||||
#### Fields / Inputs
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `tenant` | `Tenant` nullable | yes | Target tenant; nullable only for explicit no-tenant handling paths |
|
||||
| `workspace_id` | int nullable | no | Optional explicit scope input for guardable key composition |
|
||||
| `surface_variant` | string | yes | Stable consumer variant used when request-local reuse or guard declarations need surface-specific context |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- The resolver must stay derived-only.
|
||||
- Reuse must be request-local only.
|
||||
- A no-tenant or wrong-tenant path must never reuse a previous tenant’s aggregate.
|
||||
|
||||
### DerivedStateFamily::TenantGovernanceAggregate
|
||||
|
||||
**Purpose**: Extends the existing Spec 167 request-scoped derived-state contract so the aggregate follows the same consumer-declaration and guard rules as other supported deterministic families.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `family` | enum value | yes | Stable family identifier for request-local aggregate reuse |
|
||||
| `default_freshness_policy` | enum | yes | Expected to remain `invalidate_after_mutation` for landing refresh paths |
|
||||
| `allows_negative_result_cache` | bool | yes | Aggregate resolution should be reusable for deterministic no-tenant or unavailable states only if the final key contract explicitly supports it |
|
||||
|
||||
## Consumer Mapping
|
||||
|
||||
| Consumer | Aggregate responsibility | Local responsibility |
|
||||
|---|---|---|
|
||||
| `NeedsAttention` | Overdue, expiring, lapsed, high-severity counts; baseline posture headline; next-action intent | Operations-in-progress count and widget-specific healthy fallback layout |
|
||||
| `BaselineCompareNow` | Baseline posture family, headline, supporting message, next-action intent | Tenant-panel URL mapping for findings, run detail, and landing drill-down |
|
||||
| `BaselineCompareCoverageBanner` | Banner visibility posture family, tone, headline, supporting message, next-action intent | Banner-specific show/hide threshold and local URL mapping |
|
||||
| `BaselineCompareLanding` | Default-visible posture zone and next-action intent | Compare diagnostics, evidence-gap detail, duplicate-name detail, and existing `Compare now` action |
|
||||
|
||||
## Derived State Lifecycle
|
||||
|
||||
1. A covered tenant summary surface asks the resolver for the current tenant aggregate.
|
||||
2. The resolver resolves or reuses one request-scoped aggregate for that tenant scope.
|
||||
3. Covered surfaces read the same posture family, count family, and next-action intent from the shared aggregate.
|
||||
4. If an in-request mutation or refresh path makes the current aggregate stale, the resolver uses explicit invalidation or fresh-resolution semantics.
|
||||
5. The next request builds a new aggregate from current source truth.
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- No schema migration is required.
|
||||
- Existing `BaselineCompareStats` tests remain the low-level source-truth tests.
|
||||
- If the implementation adds a new derived-state family, it must also extend the Spec 167 consumer-declaration contract and guard test.
|
||||
260
specs/168-tenant-governance-aggregate-contract/plan.md
Normal file
260
specs/168-tenant-governance-aggregate-contract/plan.md
Normal file
@ -0,0 +1,260 @@
|
||||
# Implementation Plan: Tenant Governance Aggregate Contract
|
||||
|
||||
**Branch**: `168-tenant-governance-aggregate-contract` | **Date**: 2026-03-28 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/168-tenant-governance-aggregate-contract/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/168-tenant-governance-aggregate-contract/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Introduce one explicit, derived, tenant-scoped governance aggregate that turns the existing compare posture and governance-attention counts into a single cross-surface contract. The first implementation slice will build the aggregate from `BaselineCompareStats` and its summary assessment, reuse the request-scoped derived-state infrastructure added in Spec 167, and align the tenant dashboard `Baseline Governance` card, tenant governance banner, and Baseline Compare landing around the same posture family and next-action intent while preserving existing landing action semantics and diagnostics hierarchy. A follow-on slice then moves `NeedsAttention` off its local findings-count ownership so the tenant dashboard can render both governance summary cards from one request-local contract without adding persistence or new mutation behavior.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareStats`, `BaselineCompareSummaryAssessor`, `BaselineCompareLanding`, `BaselineCompareNow`, `NeedsAttention`, `BaselineCompareCoverageBanner`, and `RequestScopedDerivedStateStore` from Spec 167
|
||||
**Storage**: PostgreSQL unchanged; no new persistence, cache store, or durable summary artifact
|
||||
**Testing**: Pest 4 unit and feature tests, including Livewire component coverage and derived-state guard tests, run through Laravel Sail
|
||||
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging/production
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: One governance-aggregate resolution per request for the same tenant and summary scope; no duplicate aggregate-owned findings queries across the tenant dashboard render; covered summary surfaces keep DB-only render behavior and reuse one stable posture family
|
||||
**Constraints**: Derived-only implementation, no new Graph calls, no cross-request cache, no new mutation surfaces, existing `Compare now` confirmation and authorization remain unchanged, diagnostics stay secondary, and no cross-tenant or cross-workspace summary leakage is allowed
|
||||
**Scale/Scope**: One tenant at a time, with phased adoption across four tenant-facing summary consumers: MVP parity for `BaselineCompareNow`, `BaselineCompareCoverageBanner`, and `BaselineCompareLanding`, followed by `NeedsAttention` adoption for multi-card request stability
|
||||
|
||||
## 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 aggregate is derived from current findings, exception validity, baseline assignment, snapshot availability, and compare-run truth; no new source-of-truth path is introduced. |
|
||||
| Read/write separation | PASS | PASS | The feature changes read-time summary ownership only. Existing `Compare now` remains the only mutation-adjacent action and stays unchanged. |
|
||||
| Graph contract path | N/A | N/A | No Graph calls, provider contracts, or `config/graph_contracts.php` changes are required. |
|
||||
| Deterministic capabilities | PASS | PASS | Authorization and drill-down capability enforcement remain existing server-side checks; the aggregate is read-only and tenant-scoped. |
|
||||
| Workspace + tenant isolation | PASS | PASS | The aggregate is resolved for one tenant at a time and must never outlive the current request or tenant context. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | No new permissions, no role-string checks, and no change to 404-for-non-members / 403-for-in-scope-capability-denial semantics. |
|
||||
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun` type or feedback path. Existing baseline-compare run visibility remains the operational source. |
|
||||
| Data minimization | PASS | PASS | No persistence is added; reuse remains request-local only. |
|
||||
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED ABSTRACTION | PASS WITH JUSTIFIED ABSTRACTION | One narrow runtime contract plus one resolver is justified because four surfaces already share the same summary family and `NeedsAttention` still re-queries data already owned elsewhere. |
|
||||
| Persisted truth / behavioral state | PASS | PASS | No new tables, artifacts, reason-code families, or persisted statuses are introduced. |
|
||||
| UI semantics / few layers | PASS | PASS | The aggregate stays below the existing surface layouts. It replaces split summary ownership instead of adding a second presentation framework. |
|
||||
| Badge semantics (BADGE-001) | PASS | PASS | Existing summary tone and badge mapping stay authoritative; the aggregate supplies data, not ad hoc visual semantics. |
|
||||
| Filament-native UI / Action Surface Contract | PASS | PASS | Widgets and landing page remain native Filament surfaces. No new row, bulk, or destructive actions are introduced. |
|
||||
| Filament UX-001 | PASS | PASS | No create/edit/view layout redesign is proposed; only existing summary zones change data ownership. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | The design remains inside the existing Filament v5 + Livewire v4 stack with no legacy API introduction. |
|
||||
| Provider registration location | PASS | PASS | No panel or provider registration changes; Laravel 11+ registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No globally searchable resource changes are proposed in this slice. |
|
||||
| Destructive action safety | PASS | PASS | No new destructive actions are added. The existing Baseline Compare landing action remains `->action(...)->requiresConfirmation()` and capability-gated. |
|
||||
| Asset strategy | PASS | PASS | No new assets or `filament:assets` deployment changes are required. |
|
||||
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds parity, memoization, and tenant-scope safety tests that protect operator-visible truth rather than thin adapters only. |
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/168-tenant-governance-aggregate-contract/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Build the new aggregate from `BaselineCompareStats` and `BaselineCompareSummaryAssessment` instead of creating a second query-backed summary path.
|
||||
- Introduce one narrow `TenantGovernanceAggregate` runtime contract so summary ownership is explicit without moving landing-page diagnostics into a bloated new object.
|
||||
- Reuse the existing request-scoped derived-state infrastructure from Spec 167 rather than adding widget-local or resource-local caches.
|
||||
- Keep next-action intent inside the aggregate but leave final URLs and panel-specific drill-down mapping local to each surface.
|
||||
- Let `BaselineCompareLanding` keep `BaselineCompareStats` for deep diagnostics while switching its default-visible posture zone to the shared aggregate.
|
||||
- Protect the feature with focused parity, memoization, and guard tests instead of ad hoc performance scripts.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/168-tenant-governance-aggregate-contract/`:
|
||||
|
||||
- `data-model.md`: persistent source truths plus the new derived tenant-governance runtime contract
|
||||
- `contracts/tenant-governance-aggregate.openapi.yaml`: internal logical contract for resolving and consuming the aggregate on tenant summary surfaces
|
||||
- `quickstart.md`: focused implementation and verification workflow
|
||||
|
||||
Design decisions:
|
||||
|
||||
- The shared contract is one derived runtime object, not a new persisted summary or reporting subsystem.
|
||||
- `BaselineCompareStats` remains the heavy query-backed source for compare posture, findings counts, and landing diagnostics.
|
||||
- The aggregate owns shared summary semantics only: posture family, count family, and next-action intent.
|
||||
- Request-local reuse will extend the Spec 167 derived-state contract instead of introducing local static caches in widgets or pages.
|
||||
- Consumer surfaces will adopt named helper seams so CI can guard against future reintroduction of local findings queries or direct duplicate aggregate resolution.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/168-tenant-governance-aggregate-contract/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── tenant-governance-aggregate.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ ├── BaselineCompareLanding.php
|
||||
│ │ └── TenantDashboard.php
|
||||
│ └── Widgets/
|
||||
│ ├── Dashboard/
|
||||
│ │ ├── BaselineCompareNow.php
|
||||
│ │ └── NeedsAttention.php
|
||||
│ └── Tenant/
|
||||
│ └── BaselineCompareCoverageBanner.php
|
||||
├── Support/
|
||||
│ ├── Baselines/
|
||||
│ │ ├── BaselineCompareStats.php
|
||||
│ │ ├── BaselineCompareSummaryAssessor.php
|
||||
│ │ ├── BaselineCompareSummaryAssessment.php
|
||||
│ │ ├── TenantGovernanceAggregate.php
|
||||
│ │ └── TenantGovernanceAggregateResolver.php
|
||||
│ └── Ui/
|
||||
│ └── DerivedState/
|
||||
│ └── DerivedStateFamily.php
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Baselines/
|
||||
│ │ ├── BaselineCompareStatsTest.php
|
||||
│ │ ├── BaselineCompareSummaryAssessmentTest.php
|
||||
│ │ └── TenantGovernanceAggregateResolverTest.php
|
||||
│ ├── Filament/
|
||||
│ │ ├── BaselineCompareSummaryConsistencyTest.php
|
||||
│ │ ├── BaselineCompareNowWidgetTest.php
|
||||
│ │ ├── BaselineCompareCoverageBannerTest.php
|
||||
│ │ ├── BaselineCompareLandingAdminTenantParityTest.php
|
||||
│ │ ├── BaselineCompareLandingDuplicateNamesBannerTest.php
|
||||
│ │ ├── BaselineCompareLandingRbacLabelsTest.php
|
||||
│ │ ├── BaselineCompareLandingStartSurfaceTest.php
|
||||
│ │ ├── BaselineCompareLandingWhyNoFindingsTest.php
|
||||
│ │ ├── NeedsAttentionWidgetTest.php
|
||||
│ │ └── TenantGovernanceAggregateMemoizationTest.php
|
||||
│ └── Guards/
|
||||
│ └── DerivedStateConsumerAdoptionGuardTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the existing Laravel monolith structure. Add one narrow runtime contract plus resolver under `app/Support/Baselines`, adopt it through the current Filament widgets and landing page, and extend the existing request-scoped derived-state infrastructure instead of creating new base directories or a broader presentation layer.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Introduce the Aggregate Contract and Resolver
|
||||
|
||||
**Goal**: Add one explicit tenant-governance runtime object and one resolver that derives it from existing compare truth.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `app/Support/Baselines/TenantGovernanceAggregate.php` | Add the derived runtime contract that holds the shared summary posture, count family, and next-action intent for one tenant |
|
||||
| A.2 | `app/Support/Baselines/TenantGovernanceAggregateResolver.php` | Add the resolver that derives the aggregate from `BaselineCompareStats` and `BaselineCompareSummaryAssessment` without adding persistence |
|
||||
| A.3 | `app/Support/Baselines/BaselineCompareStats.php` | Expose the aggregate's count family from the existing source path and keep the landing diagnostics sourced from the same stats object |
|
||||
|
||||
### Phase B — Reuse the Existing Derived-State Infrastructure
|
||||
|
||||
**Goal**: Make repeated reads of the same tenant aggregate request-stable without adding ad hoc widget caches.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `app/Support/Ui/DerivedState/DerivedStateFamily.php` | Add a dedicated family for tenant-governance aggregate reuse, unless the final implementation can reuse the existing family contract without ambiguity |
|
||||
| B.2 | `app/Support/Baselines/TenantGovernanceAggregateResolver.php` and `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml` | Route aggregate resolution through the existing request-scoped derived-state store and declare the supported first-slice consumer paths for the new family |
|
||||
| B.3 | `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` | Extend the allowed-family and guarded-consumer validation so first-slice summary surfaces cannot fall back to local caches or direct duplicate findings queries |
|
||||
|
||||
### Phase C — Adopt the First Shared Summary Surfaces
|
||||
|
||||
**Goal**: Make the tenant dashboard governance card, landing page, and tenant banner consume one shared semantic source before the second dashboard summary surface joins the contract.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` | Replace direct `BaselineCompareStats` summary reads with the aggregate while keeping local URL mapping for findings, run, and landing destinations |
|
||||
| C.2 | `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php` | Drive banner visibility, tone, and next-action intent from the same aggregate-backed posture family |
|
||||
| C.3 | `app/Filament/Pages/BaselineCompareLanding.php` | Use the aggregate for the default-visible posture summary while preserving existing compare diagnostics, evidence-gap detail, and `Compare now` behavior |
|
||||
| C.4 | `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php` | Preserve unchanged compare-start authorization and confirmation semantics while proving diagnostics remain secondary to the aggregate-owned posture zone |
|
||||
|
||||
### Phase D — Align Next-Action Intent
|
||||
|
||||
**Goal**: Make the first shared summary surfaces point operators toward the same class of next step for the same tenant state.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `app/Support/Baselines/TenantGovernanceAggregate.php` and `app/Support/Baselines/TenantGovernanceAggregateResolver.php` | Add next-action label and target ownership to the aggregate contract |
|
||||
| D.2 | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`, `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php`, and `app/Filament/Pages/BaselineCompareLanding.php` | Map shared next-action intent to local URLs and operator-facing labels without reintroducing local business rules |
|
||||
| D.3 | `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php` | Prove next-action parity for running, failed, unavailable, open-findings, overdue-without-new-drift, lapsed-without-new-drift, caution, and stale states |
|
||||
|
||||
### Phase E — Extend to `NeedsAttention` and Multi-Card Stability
|
||||
|
||||
**Goal**: Bring the second dashboard summary surface onto the same request-local contract and remove duplicate findings-count ownership.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | Replace local `Finding::query()` ownership for overdue, expiring, lapsed, and high-severity summary counts with the shared aggregate and keep only local operations-in-progress logic |
|
||||
| E.2 | `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php`, `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` | Prove request-local reuse, no-tenant safety, tenant-switch safety, and extension of the guarded consumer contract to cover removal of hidden local findings-count ownership in `NeedsAttention` |
|
||||
|
||||
### Phase F — Regression Protection and Verification
|
||||
|
||||
**Goal**: Prove semantic consistency, request reuse, and tenant-scope safety.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| F.1 | `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php` | Add focused resolver coverage for the full FR-168-014 matrix: unavailable prerequisites, in-progress compare, failed compare, open findings requiring action, overdue-without-new-drift, lapsed-without-new-drift, caution, stale, and trustworthy all-clear |
|
||||
| F.2 | `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php`, and `tests/Feature/Filament/NeedsAttentionWidgetTest.php` | Assert that covered surfaces share the same count family, posture family, diagnostics hierarchy, and next-action intent for seeded tenant states |
|
||||
| F.3 | `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php` and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php` | Assert that one request stores one derived governance aggregate per tenant scope, no tenant state leaks, and no covered consumer reintroduces local findings-count ownership |
|
||||
| F.4 | `vendor/bin/sail bin pint --dirty --format agent` and focused Pest runs | Apply formatting and run the smallest verification pack that covers stats, landing authorization and hierarchy, widgets, memoization, and the derived-state guard |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D-001 — The new contract is summary-focused and derived from existing compare truth
|
||||
|
||||
The shared aggregate must not become a second diagnostics object. `BaselineCompareStats` already owns compare availability, findings counts, and low-level detail. The aggregate will summarize the operator-facing posture family built from that truth.
|
||||
|
||||
### D-002 — Request reuse must flow through Spec 167 infrastructure, not local caches
|
||||
|
||||
The repo already has `RequestScopedDerivedStateStore` and a guard model for supported families. This feature should extend that path instead of introducing widget-level static arrays or request-attribute caches.
|
||||
|
||||
### D-003 — Next-action intent belongs in the aggregate; final URLs stay local
|
||||
|
||||
The operator problem includes conflicting next steps, so the aggregate must own whether the next action is “findings”, “run”, “landing”, or “none”. Each surface will continue to map that intent to panel-specific URLs and capability-aware affordances.
|
||||
|
||||
### D-004 — Baseline Compare landing keeps diagnostics but loses summary re-ownership
|
||||
|
||||
The landing page remains the home for deep compare diagnostics and the compare-start action. It should stop being a separate semantic owner for the default-visible posture zone.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Aggregate and compare stats drift into parallel truths | High | Medium | Build the aggregate directly from one `BaselineCompareStats` resolution and keep diagnostics sourced from that same object |
|
||||
| Request-scoped reuse omits tenant or route-sensitive inputs | High | Medium | Keep the aggregate user-agnostic, key it by workspace and tenant scope, and add tenant-switch / no-tenant regression tests |
|
||||
| `NeedsAttention` keeps hidden local count ownership | High | Medium | Add consumer guard declarations plus tests that cover overdue, lapsed, and expiring states with zero new drift |
|
||||
| Landing summary and dashboard summary stay semantically misaligned | Medium | Medium | Add cross-surface parity tests that assert one posture family and one next-action intent across dashboard, banner, and landing |
|
||||
| The aggregate grows into a second presentation framework | Medium | Low | Limit it to summary posture, counts, and next-action intent; leave URLs, layout, and diagnostics local |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Add focused resolver tests that cover the full FR-168-014 matrix: unavailable prerequisites, compare in progress, compare failed, open findings requiring action, overdue-without-new-drift, lapsed governance without new drift, cautionary limited-confidence results, stale results, and trustworthy all-clear results.
|
||||
- Keep existing Baseline Compare stats and summary-assessment tests as the canonical low-level truth tests.
|
||||
- Extend Filament widget and landing tests so covered surfaces assert the same headline family, tone, next-action label, preserved drill-down destination continuity, and diagnostics-secondary hierarchy for the same tenant state.
|
||||
- Preserve existing `Compare now` confirmation, capability gating, and tenant-safe landing authorization behavior through explicit landing regression coverage.
|
||||
- Add one request-scoped memoization test that renders a page with multiple consumers and proves a single stored governance aggregate exists for that tenant scope.
|
||||
- Extend the Spec 167 consumer-adoption guard so the new family and consumer surfaces cannot regress back to local ad hoc caches or repeated findings-count queries.
|
||||
- Preserve Livewire v4-compatible component tests and run the minimum focused Sail verification pack before implementation completion.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| New derived runtime contract plus derived-state family | Four existing summary surfaces already share one operator posture family, and the repo already standardized request-local reuse for similar deterministic derived state | Per-widget hardening would preserve split semantic ownership and allow local findings queries or ad hoc caches to reappear |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Tenant operators can already see overlapping dashboard cards, banners, and landing summaries that answer the same governance question through different logic paths, especially when no new drift exists but overdue or unhealthy governance still does.
|
||||
- **Existing structure is insufficient because**: `BaselineCompareStats` already holds most of the needed truth, but no explicit summary contract owns the shared posture family. `NeedsAttention` still re-queries counts already derivable from compare stats, which keeps ownership split across surfaces.
|
||||
- **Narrowest correct implementation**: Add one derived `TenantGovernanceAggregate` plus one resolver that builds from existing compare truth and reuses the existing request-scoped derived-state store.
|
||||
- **Ownership cost created**: One new runtime DTO/resolver pair, one derived-state family declaration, one consumer guard extension, and a focused set of parity + memoization tests.
|
||||
- **Alternative intentionally rejected**: Continuing to harden each widget or page independently was rejected because it would preserve multiple semantic owners and repeated queries. A persisted summary record or cross-request cache was rejected because the current-release need is request-time consistency only.
|
||||
- **Release truth**: Current-release truth. The affected surfaces are already shipped and already overlap semantically.
|
||||
81
specs/168-tenant-governance-aggregate-contract/quickstart.md
Normal file
81
specs/168-tenant-governance-aggregate-contract/quickstart.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Quickstart: Tenant Governance Aggregate Contract
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that one derived tenant-governance aggregate now drives the shared summary posture across the tenant dashboard, the tenant governance banner, and the Baseline Compare landing without adding persistence or reintroducing local findings-count ownership.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start Sail.
|
||||
2. Ensure a tenant exists with an assigned baseline profile and a consumable snapshot.
|
||||
3. Seed compare-run scenarios for: no result yet, compare in progress, compare failed, trustworthy no-drift, stale no-drift, overdue findings without new drift, and lapsed governance without new drift.
|
||||
4. Ensure the current user is a tenant member with access to the tenant dashboard and Baseline Compare landing.
|
||||
|
||||
## Implementation Validation Order
|
||||
|
||||
### 1. Run low-level compare and aggregate coverage
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareStatsTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php
|
||||
vendor/bin/sail artisan test --compact --filter=TenantGovernanceAggregate
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- Existing compare truth still resolves the same availability and posture states.
|
||||
- The new aggregate contract maps those states into one stable summary object without introducing a second query-backed truth path.
|
||||
|
||||
### 2. Run focused cross-surface parity coverage
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareNowWidgetTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareCoverageBannerTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- `NeedsAttention`, `BaselineCompareNow`, `BaselineCompareCoverageBanner`, and the landing summary agree on posture family, headline, and next-action intent for the same tenant state.
|
||||
- A tenant with zero visible drift but overdue or unhealthy governance still renders as action-needed on every covered surface.
|
||||
- The landing keeps `Compare now` confirmation and capability gating unchanged, and diagnostics remain clearly secondary to the shared summary posture.
|
||||
|
||||
### 3. Run request-local reuse and guard coverage
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact --filter=TenantGovernanceAggregateMemoization
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- One request stores one governance aggregate per tenant scope.
|
||||
- Covered surfaces do not fall back to widget-local ad hoc caches or repeated local findings queries.
|
||||
|
||||
### 4. Format touched implementation files
|
||||
|
||||
```bash
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- All touched implementation files conform to the repo’s Pint rules.
|
||||
|
||||
## Manual Smoke Check
|
||||
|
||||
1. Open `/admin/t/{tenant}` for a tenant with lapsed governance and no new drift findings.
|
||||
2. Confirm the dashboard `Needs Attention` widget and `Baseline Governance` card both show action-needed posture.
|
||||
3. Open `/admin/t/{tenant}/baseline-compare-landing` and confirm the landing summary agrees with the dashboard posture while still showing deeper diagnostics below.
|
||||
4. Use a tenant with a trustworthy no-drift result and confirm the banner hides, the dashboard falls back to healthy checks, and the landing summary presents the same all-clear posture.
|
||||
5. Use a tenant with a queued or running compare and confirm dashboard, banner, and landing all present progress-aware follow-up instead of stale “all clear” messaging.
|
||||
6. Switch from one tenant to another and confirm the second tenant does not reuse the first tenant’s summary posture.
|
||||
|
||||
## Non-Goals For This Slice
|
||||
|
||||
- No database migration.
|
||||
- No new Graph call or provider-contract work.
|
||||
- No new assets or Filament panel registration change.
|
||||
- No new mutation surface beyond the existing `Compare now` action, which remains unchanged.
|
||||
49
specs/168-tenant-governance-aggregate-contract/research.md
Normal file
49
specs/168-tenant-governance-aggregate-contract/research.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Phase 0 Research: Tenant Governance Aggregate Contract
|
||||
|
||||
## Decision: Build the shared contract from `BaselineCompareStats` and its summary assessment instead of creating a second query-backed summary path
|
||||
|
||||
**Rationale**: `BaselineCompareStats::forTenant()` already resolves baseline assignment, consumable snapshot availability, latest compare-run posture, findings visibility counts, and the governance-attention count family that this spec needs. `NeedsAttention` is the main surface still re-querying overdue, expiring, lapsed, and high-severity finding counts locally even though those values are already derivable from compare stats. Building the aggregate from `BaselineCompareStats` keeps one query-backed truth and avoids a second summary service that would drift.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create a new persisted tenant-governance summary record: rejected because the current-release problem is request-time consistency, not independent lifecycle or historical reporting truth.
|
||||
- Create a new query service that bypasses `BaselineCompareStats`: rejected because it would duplicate compare availability and attention logic that already exists.
|
||||
|
||||
## Decision: Introduce one narrow `TenantGovernanceAggregate` runtime contract instead of continuing to treat `BaselineCompareStats` as an implicit shared summary
|
||||
|
||||
**Rationale**: `BaselineCompareStats` is a broad compare-detail object that also carries diagnostics and landing-specific support data. The operator problem is narrower: multiple surfaces need one shared summary posture, count family, and next-action intent. A dedicated aggregate keeps that summary ownership explicit while allowing the landing page to keep deeper diagnostics in `BaselineCompareStats`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Use `BaselineCompareStats` directly everywhere with no new contract: rejected because it would leave summary ownership implicit and make it easier for widgets to keep local business rules.
|
||||
- Move every compare diagnostic into the new aggregate: rejected because it would bloat a summary contract into a second detail model.
|
||||
|
||||
## Decision: Reuse the existing request-scoped derived-state infrastructure from Spec 167
|
||||
|
||||
**Rationale**: The repo already binds `RequestScopedDerivedStateStore` in the Laravel container and uses it for request-local reuse of `ArtifactTruthPresenter`, `OperationUxPresenter`, and `RelatedNavigationResolver`. This feature needs the same boundary: one request-local derived result per deterministic question, explicit invalidation rules, and a guard path that prevents local ad hoc caches from coming back.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add a static cache inside `NeedsAttention`, `BaselineCompareNow`, or `BaselineCompareCoverageBanner`: rejected because Spec 167 explicitly moved the codebase away from that pattern.
|
||||
- Use `Cache::remember()` or another cross-request store: rejected because the spec requires request-local reuse only and stale cross-request posture would be harder to reason about.
|
||||
|
||||
## Decision: Keep next-action intent inside the aggregate, but keep final URLs and panel-specific drill-down mapping local to surfaces
|
||||
|
||||
**Rationale**: The semantic drift problem is not only conflicting counts; it is also conflicting operator follow-up. The aggregate must therefore answer the stable business question “what is the next action target category for this tenant state?” while leaving local surfaces free to map that answer to tenant-panel URLs, run links, or findings links appropriate to their context.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Store absolute URLs inside the aggregate: rejected because final URLs remain panel-aware and surface-local, and capability-gated destinations should stay local to the consuming surface.
|
||||
- Leave next-action computation local to every widget or page: rejected because next-action drift is part of the problem this spec is supposed to solve.
|
||||
|
||||
## Decision: Keep `BaselineCompareLanding` as the home for diagnostics, but move its default-visible posture semantics onto the aggregate
|
||||
|
||||
**Rationale**: The spec explicitly distinguishes default-visible operator posture from diagnostics-only information. `BaselineCompareLanding` should still own evidence gaps, duplicate-name diagnostics, detailed compare context, and the existing `Compare now` action. What changes is that its primary posture zone should no longer be another local semantic owner.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Leave the landing page fully outside the aggregate: rejected because the landing page is one of the authoritative summary surfaces listed in the spec.
|
||||
- Collapse the landing page into a pure aggregate view: rejected because operators still need compare diagnostics there.
|
||||
|
||||
## Decision: Protect the feature with parity, memoization, and guard tests rather than ad hoc performance scripts
|
||||
|
||||
**Rationale**: The highest-risk regressions are semantic drift, hidden local re-queries, and request-scope leakage. Focused Pest tests can assert those business truths directly, and the derived-state adoption guard can stop future surfaces from quietly reintroducing local caches or unsupported family access patterns.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Rely only on query counting or manual profiling: rejected because it would miss operator-visible drift and consumer-adoption regressions.
|
||||
- Treat this as a pure unit-test problem: rejected because the spec is about cross-surface agreement and request behavior, which requires feature-level coverage too.
|
||||
202
specs/168-tenant-governance-aggregate-contract/spec.md
Normal file
202
specs/168-tenant-governance-aggregate-contract/spec.md
Normal file
@ -0,0 +1,202 @@
|
||||
# Feature Specification: Tenant Governance Aggregate Contract
|
||||
|
||||
**Feature Branch**: `168-tenant-governance-aggregate-contract`
|
||||
**Created**: 2026-03-28
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 168 — Tenant Governance Aggregate Contract"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}` as the tenant dashboard where Baseline Governance and Needs Attention currently surface overlapping governance posture
|
||||
- `/admin/t/{tenant}/baseline-compare-landing` as the tenant baseline-compare hub that owns compare posture, next action, and supporting diagnostics
|
||||
- tenant-context governance banners and summary cards that surface baseline posture or governance attention inside the tenant panel
|
||||
- **Data Ownership**:
|
||||
- Tenant-owned: findings, finding-exception validity, overdue workflow state, and tenant-scoped compare-run visibility that together describe the tenant's current governance attention posture
|
||||
- Workspace-owned but tenant-resolved: baseline assignment, baseline profile, effective baseline snapshot availability, and workspace-level prerequisites that shape tenant compare posture
|
||||
- This feature introduces no new persisted tenant-governance record; the shared aggregate remains derived for one tenant at a time
|
||||
- **RBAC**:
|
||||
- Existing workspace membership and tenant entitlement remain required for every surface that consumes the aggregate
|
||||
- Existing tenant inspection capabilities remain the gate for opening findings, baseline compare detail, and other drill-down destinations reached from the aggregate
|
||||
- Existing compare-start authorization and confirmation rules remain unchanged for the Baseline Compare landing action
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard `Needs Attention` | Tenant operator | Summary widget | What needs action right now across governance and baseline posture? | Overdue findings, lapsed governance, expiring governance, high-severity active findings, baseline-posture summary | Deep reason codes, raw compare diagnostics, low-level evidence gaps, run internals | governance attention, due urgency, compare posture, severity urgency | Read-only summary with drill-down navigation | Review dashboard attention items, open related destination | None |
|
||||
| Tenant dashboard `Baseline Governance` card | Tenant operator | Summary card | Can I trust the latest baseline posture, and what should I do next? | Aggregate headline, supporting message, next action, visible drift count, high-severity count, last compared label | Deep coverage details, duplicate-name stats, evidence-gap drill-down, run diagnostics | compare freshness, compare outcome, governance attention, trustworthiness | Read-only summary with drill-down navigation | Open findings, open run, open Baseline Compare | None |
|
||||
| Baseline Compare landing | Tenant operator | Landing page | What is the tenant's current baseline governance posture, and should I compare now or investigate detail? | Aggregate posture, assignment or snapshot availability, findings attention, next action, compare availability | Coverage detail, evidence-gap detail, duplicate-name diagnostics, low-level run reason detail | compare availability, compare freshness, compare outcome, governance attention, coverage limits | Existing compare action unchanged; page state itself is read-focused | Review posture, open findings, start `Compare now`, open run detail | No new dangerous action; existing `Compare now` remains confirmation-gated and capability-gated |
|
||||
| Tenant governance banner | Tenant operator | Inline warning banner | Is there a governance or compare limitation I should not miss on this screen? | Shared aggregate headline, supporting message, next action intent, warning tone | Full compare diagnostics and detailed count breakdowns | compare caution or stale state, governance attention, next action intent | Read-only summary with drill-down navigation | Follow next action to findings, run detail, or Baseline Compare | None |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: No. Findings, exception validity, baseline assignment, snapshot availability, and compare-run truth remain the source data.
|
||||
- **New persisted entity/table/artifact?**: No. The aggregate is derived and request-scoped.
|
||||
- **New abstraction?**: Yes. This feature introduces one explicit tenant-scoped aggregate contract for governance summary state that multiple surfaces share.
|
||||
- **New enum/state/reason family?**: No. Existing compare posture, governance validity, and attention semantics remain the source vocabulary.
|
||||
- **New cross-domain UI framework/taxonomy?**: No. The work consolidates one summary family; it does not create a new presentation framework.
|
||||
- **Current operator problem**: Operators can see overlapping tenant-governance cards and pages that recompute similar facts independently, which makes counts, headlines, and next-action guidance harder to trust and more expensive to keep aligned.
|
||||
- **Existing structure is insufficient because**: The current structure lets baseline compare posture live in one shared path while overdue, expiring, and lapsed governance attention can still be owned locally by individual widgets or banners. That fragmentation means no single contract is accountable for the tenant's shared governance summary.
|
||||
- **Narrowest correct implementation**: Introduce one derived tenant-scoped governance aggregate that absorbs the overlapping summary facts already reused across landing, dashboard, and banner surfaces, while leaving presentation mapping local to each surface.
|
||||
- **Ownership cost**: The codebase takes on one explicit shared contract plus cross-surface regression tests and request-reuse guarantees, but removes duplicated query ownership and semantic drift from each consuming surface.
|
||||
- **Alternative intentionally rejected**: Continuing to harden each widget or page independently was rejected because it preserves duplicate ownership. Adding a persisted summary record or cross-request cache was rejected because current-release needs do not require a new durable truth source.
|
||||
- **Release truth**: Current-release truth. The overlap already exists on shipped tenant-governance surfaces and is growing with ongoing governance work.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - One Tenant, One Governance Posture (Priority: P1)
|
||||
|
||||
As a tenant operator, I want the dashboard, banner, and baseline-compare landing to agree on what needs action, so that I do not have to reconcile conflicting counts or messages before deciding what to do next.
|
||||
|
||||
**Why this priority**: Trust in the product's governance posture depends on cross-surface consistency. Conflicting summary signals create operator hesitation and support debt.
|
||||
|
||||
**Independent Test**: Can be fully tested by seeding one tenant with baseline-compare posture and governance-attention states, then rendering at least three covered surfaces and verifying that the same tenant state produces the same underlying counts, posture family, and next-action intent.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has no visible drift findings but has lapsed accepted-risk governance, **When** an authorized operator opens the dashboard and the baseline-compare landing, **Then** both surfaces present the tenant as needing attention rather than as an all-clear.
|
||||
2. **Given** a tenant has overdue open findings and expiring governance, **When** multiple tenant-governance surfaces render in the same session, **Then** they expose consistent overdue and governance-attention meaning for that tenant.
|
||||
3. **Given** a tenant has a trustworthy compare result with no findings and no governance-attention conditions, **When** the operator opens covered summary surfaces, **Then** those surfaces consistently present a healthy posture rather than mixed healthy and warning states.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Consistent Next Action Across Summary Surfaces (Priority: P1)
|
||||
|
||||
As a tenant operator, I want each governance summary surface to point me toward the same next step for the same tenant state, so that I can act quickly without guessing which surface is authoritative.
|
||||
|
||||
**Why this priority**: A shared aggregate is only useful if it produces not just aligned counts, but aligned operator action.
|
||||
|
||||
**Independent Test**: Can be fully tested by exercising compare-in-progress, failed-compare, overdue-findings, and governance-lapsed scenarios and verifying that each covered surface routes the operator toward the same class of next action for the same state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a baseline compare is currently running, **When** the operator opens the dashboard card, banner, and landing page, **Then** each surface points to progress-aware follow-up instead of mixing stale completion guidance with active-run guidance.
|
||||
2. **Given** a tenant needs attention because overdue or lapsed governance states remain, **When** the operator opens any aggregate-consuming summary surface, **Then** the primary follow-up action remains aligned with reviewing findings rather than presenting unrelated compare guidance.
|
||||
3. **Given** a tenant lacks a usable baseline compare result because no assignment or no snapshot is available, **When** summary surfaces render, **Then** they consistently communicate availability or prerequisite guidance instead of one surface appearing healthy while another appears unavailable.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Stable Multi-Card Tenant Summary (Priority: P2)
|
||||
|
||||
As a tenant operator, I want a page that shows multiple governance cards to stay stable for my current tenant, so that repeated summary cards do not disagree or flicker between different tenant-governance interpretations.
|
||||
|
||||
**Why this priority**: The immediate product problem is not only semantic drift over time but duplicate ownership inside one page render.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering a tenant page that includes more than one aggregate-consuming governance surface and verifying that the same tenant request produces one stable posture family and one stable set of governance-attention counts.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant dashboard renders both a governance card and a needs-attention summary, **When** the page loads, **Then** both surfaces reflect the same tenant posture and compatible next-step guidance.
|
||||
2. **Given** the operator switches from one tenant to another, **When** the new tenant page renders, **Then** the summary state resets to the new tenant and does not reuse the previous tenant's governance posture.
|
||||
3. **Given** no tenant is selected, **When** a tenant-governance surface is reached indirectly, **Then** it shows the appropriate empty or unavailable state and does not leak another tenant's summary.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A tenant has zero open drift findings, but overdue findings, expiring governance, or lapsed governance still exist; all aggregate-consuming attention surfaces must continue to present action-needed posture.
|
||||
- A tenant has baseline assignment but no consumable snapshot or no completed compare result yet; covered surfaces must stay consistent about availability and prerequisites.
|
||||
- A compare run is queued or running while older compare-derived counts still exist; summary surfaces must present progress-aware posture instead of mixing active-run and completed-run claims.
|
||||
- A single request renders multiple governance summary surfaces for one tenant; the request must not produce contradictory counts or posture labels because different surfaces recomputed the same attention family separately.
|
||||
- A tenant switch or no-tenant context occurs between requests; request-local reuse must not carry governance summary state across tenant boundaries.
|
||||
- A surface still needs local diagnostics beyond the shared aggregate; those diagnostics must remain secondary and must not redefine the shared attention semantics.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new long-running job type, and no new persisted artifact. It consolidates existing tenant-governance summary truth from current compare posture, findings attention, and governance validity into one shared derived contract. Existing compare operations and Monitoring observability remain unchanged.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one narrow abstraction: a shared tenant-governance aggregate contract. It does so because existing tenant surfaces already duplicate overlapping summary state and a narrower per-surface hardening would preserve split ownership. The contract remains derived, current-release, and intentionally avoids new persistence, new state families, or a cross-domain semantic framework.
|
||||
|
||||
**Constitution alignment (OPS-UX):** No new `OperationRun` type is introduced. Existing baseline-compare runs remain the only operational execution path involved, and their queued, running, failed, or completed visibility remains governed by the existing Ops-UX contract.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The feature stays entirely in the tenant/admin plane on tenant-context surfaces. It does not introduce new authorization behavior. Non-members remain deny-as-not-found, in-scope members without the required capability remain forbidden for existing compare-start or drill-down actions, and shared aggregate reuse must remain tenant-scoped so no cross-tenant summary leakage is possible.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication flow is changed.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Existing centralized badge and tone semantics for findings severity, governance validity, and compare posture remain the semantic source. This feature may make those semantics appear on more than one surface through the aggregate, but it must not introduce ad hoc per-surface status vocabularies.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The feature reuses native Filament widgets, sections, links, and badges already present on the dashboard, landing page, and banner surfaces. It should consolidate the shared semantic source rather than introducing page-local markup or a new local visual language for warning states.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing terms remain baseline governance, needs attention, baseline compare, compare now, and open findings. The feature must preserve the same domain vocabulary across dashboard cards, landing copy, banner messaging, and drill-down affordances so operators do not see one surface talk about attention while another describes the same state as healthy posture.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** This feature materially refactors operator-facing summary surfaces. Default-visible information must remain operator-first: posture, urgency, and next action appear first, while diagnostics remain secondary. Mutation scope remains unchanged: read-only summary surfaces stay read-only, and the existing `Compare now` action continues to communicate its existing compare execution scope.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** A shared aggregate is justified because direct per-surface mapping has already produced redundant truth and semantic drift. The aggregate must replace duplicate per-surface summary ownership rather than layering yet another presenter or wrapper on top of it. Tests must assert business truth across surfaces and request scope, not only thin adapter wiring.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature modifies Filament pages and widgets in the tenant panel. The Action Surface Contract remains satisfied because the summary surfaces are read-only and the only existing mutation surface involved, Baseline Compare landing, keeps explicit confirmation and capability gating for compare start. UI-FIL-001 is satisfied because no exception to native Filament summary or action primitives is required.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** The feature does not add new create or edit screens. It changes summary and landing semantics on existing tenant surfaces. The landing page must keep operator posture and next-action messaging above diagnostics, and widgets or banners must keep attention content concise, explicit, and drill-down oriented rather than becoming diagnostic walls.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-168-001**: The system MUST provide one tenant-scoped governance aggregate for the currently selected tenant that combines the summary state already shared across tenant governance cards, banners, and baseline-compare summary surfaces.
|
||||
- **FR-168-002**: The tenant-governance aggregate MUST include, at minimum, compare availability or progress state, compare outcome or freshness posture, compare trust-limiting signals relevant to summary posture, visible drift findings count, overdue open findings count, expiring governance count, lapsed governance count, active non-new findings count, and high-severity active findings count.
|
||||
- **FR-168-003**: At least three existing tenant-facing summary surfaces MUST consume the same tenant-governance aggregate contract.
|
||||
- **FR-168-004**: Aggregate-consuming surfaces MUST treat the shared aggregate as the semantic owner for overdue findings, expiring governance, lapsed governance, and tenant-level baseline posture, and MUST NOT define conflicting local business rules for those states.
|
||||
- **FR-168-005**: Aggregate-consuming surfaces MAY keep local layout, wording emphasis, and navigation affordances appropriate to their context, but they MUST preserve the same underlying posture family, counts, and next-action intent for the same tenant state.
|
||||
- **FR-168-006**: A tenant with zero visible drift findings but any overdue open findings, expiring governance, or lapsed governance MUST still render as needing attention on every aggregate-consuming attention surface.
|
||||
- **FR-168-007**: A tenant with no baseline assignment, no usable snapshot, no current compare result, an in-progress compare, a failed compare, a stale compare result, or a trustworthy all-clear result MUST resolve to one consistent tenant-governance posture across all aggregate-consuming surfaces.
|
||||
- **FR-168-008**: Repeated reads of the same tenant-governance aggregate during one request MUST reuse the same derived result instead of recomputing aggregate-owned counts separately for each consuming surface.
|
||||
- **FR-168-009**: Request-local aggregate reuse MUST remain scoped to the current tenant context and MUST NOT leak summary state across tenants, workspaces, or no-tenant states.
|
||||
- **FR-168-010**: The initial implementation MUST remain derived-only and MUST NOT require a new persisted summary record, a new cross-request cache, or a new dashboard setup step.
|
||||
- **FR-168-011**: Existing compare-start actions, findings drill-downs, and baseline-compare navigation destinations MUST remain available, and this feature MUST NOT broaden or weaken their existing RBAC and confirmation behavior.
|
||||
- **FR-168-012**: If a covered surface needs diagnostics beyond the shared aggregate, those diagnostics MUST remain secondary and MUST NOT redefine the shared attention semantics already owned by the aggregate.
|
||||
- **FR-168-013**: The feature MUST stay bounded to one tenant at a time and MUST NOT expand this aggregate into cross-tenant portfolio posture, new governance workflow semantics, or a full dashboard redesign.
|
||||
- **FR-168-014**: Regression coverage MUST verify semantic consistency across aggregate-consuming surfaces for at least these tenant states: unavailable prerequisites, compare in progress, compare failed, open findings requiring action, overdue findings without new drift, lapsed governance without new drift, caution or stale compare posture, and trustworthy all-clear.
|
||||
- **FR-168-015**: Regression coverage MUST verify that a single page render containing multiple aggregate-consuming surfaces does not re-own overdue, expiring, or lapsed counts in parallel and remains stable for the current tenant context.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Baseline Compare landing | `app/Filament/Pages/BaselineCompareLanding.php` | Existing `Compare now` action remains in the header | Not a record list surface | None | None | Existing prerequisite and unavailable messaging remains | `Compare now` only | N/A | Existing compare start remains observable through `OperationRun`; no new audit surface added here | This spec changes posture ownership and copy consistency, not the action inventory |
|
||||
| Tenant dashboard `Needs Attention` widget | `app/Filament/Widgets/Dashboard/NeedsAttention.php` | None | N/A | None | None | Healthy state remains a read-only reassurance surface | N/A | N/A | No | Read-only summary widget; covered because it currently owns overlapping attention counts |
|
||||
| Tenant dashboard `Baseline Governance` card | `app/Filament/Widgets/Dashboard/BaselineCompareNow.php` | None | N/A | None | None | Existing no-assignment state remains a read-only empty state | N/A | N/A | No | Read-only summary card; next-action links remain navigation only |
|
||||
| Tenant governance banner | `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php` | None | N/A | None | None | Banner simply hides when not relevant | N/A | N/A | No | Read-only warning banner; included because it must consume the shared aggregate family |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Tenant governance aggregate**: A derived tenant-scoped summary that combines compare posture, governance-attention counts, and next-action intent for operator-facing summary surfaces.
|
||||
- **Baseline compare posture**: The tenant's current compare availability, freshness, outcome, and trustworthiness summary.
|
||||
- **Governance attention summary**: The tenant's actionable counts for overdue findings, expiring governance, lapsed governance, and other active finding pressure that should influence summary posture.
|
||||
- **Aggregate-consuming surface**: Any tenant-facing card, widget, banner, or landing summary that reads the shared aggregate and applies only local presentation mapping.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-168-001**: In seeded regression coverage, at least three tenant-governance summary surfaces display identical overdue, expiring, and lapsed-governance counts for the same tenant in 100% of tested scenarios.
|
||||
- **SC-168-002**: In seeded regression coverage, tenants with zero visible drift findings but overdue or unhealthy governance still render as action-needed on every tested aggregate-consuming attention surface.
|
||||
- **SC-168-003**: In operator acceptance review, a tenant operator can determine the current governance posture and next action from either the dashboard or baseline-compare landing within 10 seconds.
|
||||
- **SC-168-004**: In regression coverage, a single page that renders multiple aggregate-consuming surfaces for one tenant shows no contradictory posture family, tone, or next-action combination within the same load.
|
||||
- **SC-168-005**: The feature ships without requiring any new setup step, scheduled refresh step, or durable summary artifact for operators to see the shared tenant-governance posture.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing findings workflow truth, finding-exception validity truth, and baseline-compare posture truth remain sufficient to derive the shared tenant-governance aggregate.
|
||||
- Existing tenant dashboard and baseline-compare surfaces remain in place; this spec consolidates their summary contract rather than replacing the whole tenant-governance UI.
|
||||
- Existing tenant-entitlement and compare-start authorization rules remain correct and do not need separate RBAC redesign in this slice.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Building a cross-tenant portfolio governance aggregate or workspace-wide control tower summary for this data family
|
||||
- Introducing a new persisted summary table, cache artifact, or reporting export just to support shared summary posture
|
||||
- Redefining findings workflow semantics, exception validity semantics, or compare reason-code semantics
|
||||
- Redesigning the entire tenant dashboard or baseline-compare landing beyond the shared-governance summary family
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Existing baseline assignment, snapshot availability, and compare-run truth
|
||||
- Existing findings and finding-exception governance-attention derivation
|
||||
- Existing tenant dashboard widgets, baseline-compare landing surface, and tenant governance banner surfaces
|
||||
- Existing tenant-context RBAC and drill-down destinations for findings and operation runs
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Spec 168 is complete when:
|
||||
|
||||
- one tenant-scoped governance aggregate owns the overlapping summary family used by at least three tenant-facing governance surfaces,
|
||||
- dashboard, landing, and banner-style surfaces no longer contradict one another about overdue, expiring, lapsed, or compare-posture state for the same tenant,
|
||||
- request-local reuse of the aggregate is testable and tenant-safe,
|
||||
- no new persisted summary truth or cross-request cache is required,
|
||||
- and the covered surfaces remain operator-first while keeping local presentation responsibility separate from shared business semantics.
|
||||
217
specs/168-tenant-governance-aggregate-contract/tasks.md
Normal file
217
specs/168-tenant-governance-aggregate-contract/tasks.md
Normal file
@ -0,0 +1,217 @@
|
||||
# Tasks: Tenant Governance Aggregate Contract
|
||||
|
||||
**Input**: Design documents from `/specs/168-tenant-governance-aggregate-contract/`
|
||||
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature. Use Pest coverage in `tests/Feature/Baselines/BaselineCompareStatsTest.php`, `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`, `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`, `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`.
|
||||
**Operations**: This feature does not create a new `OperationRun` type or change run lifecycle ownership. Existing baseline-compare runs remain the only execution surface involved, and all work here is read-time summary consolidation.
|
||||
**RBAC**: Existing tenant-context membership, entitlement, and 404 vs 403 semantics remain unchanged. Tasks must preserve current tenant-safe drill-down behavior and ensure request-local reuse never leaks summary state across tenants, workspaces, or no-tenant states.
|
||||
**Operator Surfaces**: The tenant dashboard `Baseline Governance` card, tenant dashboard `Needs Attention` widget, tenant governance banner, and Baseline Compare landing must keep operator-first posture, urgency, and next-step guidance above diagnostics.
|
||||
**Filament UI Action Surfaces**: No new action inventories are added. The existing Baseline Compare landing `Compare now` action remains confirmation-gated and capability-gated, and all other affected surfaces remain navigation-only summaries.
|
||||
**Filament UI UX-001**: No new create, edit, or view layouts are introduced. Existing widget, banner, and landing layouts remain intact while shared summary ownership is refactored.
|
||||
**Badges**: Existing compare-posture, severity, and governance-validity semantics must remain sourced from shared badge semantics; no new page-local mappings are introduced.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment after the shared aggregate contract is in place.
|
||||
|
||||
## Phase 1: Setup (Shared Aggregate Scaffolding)
|
||||
|
||||
**Purpose**: Create the narrow runtime and test scaffolding required for the tenant-governance aggregate.
|
||||
|
||||
- [X] T001 [P] Create the aggregate runtime scaffolding in `app/Support/Baselines/TenantGovernanceAggregate.php` and `app/Support/Baselines/TenantGovernanceAggregateResolver.php`
|
||||
- [X] T002 [P] Create the focused regression scaffolding in `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php` and `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Aggregate Contract)
|
||||
|
||||
**Purpose**: Build the shared aggregate runtime and request-scoped family contract that all user stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T003 [P] Extend source-truth regression coverage for aggregate input counts and summary assessment mapping in `tests/Feature/Baselines/BaselineCompareStatsTest.php` and `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`
|
||||
- [X] T004 Implement the derived tenant summary contract fields in `app/Support/Baselines/TenantGovernanceAggregate.php`
|
||||
- [X] T005 Implement aggregate resolution and request-scoped reuse from `BaselineCompareStats` and `BaselineCompareSummaryAssessment` in `app/Support/Baselines/TenantGovernanceAggregateResolver.php` and `app/Support/Baselines/BaselineCompareStats.php`
|
||||
- [X] T006 [P] Declare the governance aggregate derived-state family, extend the first-slice supported consumer contract, and update the guard in `app/Support/Ui/DerivedState/DerivedStateFamily.php`, `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`
|
||||
|
||||
**Checkpoint**: The tenant-governance aggregate exists as one derived request-local contract that later surface work can consume.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - One Tenant, One Governance Posture (Priority: P1)
|
||||
|
||||
**Goal**: Make the dashboard governance card, tenant banner, and Baseline Compare landing agree on posture family and governance-attention counts for the same tenant.
|
||||
|
||||
**Independent Test**: Seed one tenant with unavailable prerequisites, compare in progress, compare failed, open findings requiring action, overdue without new drift, lapsed governance without new drift, caution, stale results, and trustworthy all-clear states, then verify the dashboard governance card, banner, and landing summary resolve the same posture family and count family while keeping landing diagnostics secondary.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T007 [P] [US1] Add aggregate scenario coverage for unavailable, in-progress, failed, open-findings action-required, overdue-without-new-drift, lapsed-without-new-drift, caution, stale, and trustworthy-all-clear states in `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php`
|
||||
- [X] T008 [P] [US1] Extend cross-surface posture and count parity assertions for the full FR-168-014 state matrix, landing diagnostics-secondary hierarchy, and unchanged compare-start authorization and confirmation semantics in `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T009 [US1] Route the tenant dashboard `Baseline Governance` summary card through the shared aggregate in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`
|
||||
- [X] T010 [US1] Drive the tenant governance banner from the shared aggregate in `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php`
|
||||
- [X] T011 [US1] Replace the Baseline Compare landing default-visible posture summary with the shared aggregate while keeping diagnostics on `BaselineCompareStats`, preserving existing `Compare now` confirmation and capability semantics, and keeping diagnostics secondary in `app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- [X] T012 [US1] Keep aggregate posture, tone, and supporting-message semantics aligned to existing compare-summary rules in `app/Support/Baselines/BaselineCompareSummaryAssessor.php` and `app/Support/Baselines/BaselineCompareSummaryAssessment.php`
|
||||
- [X] T013 [US1] Run the focused posture parity pack in `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php`
|
||||
|
||||
**Checkpoint**: Three tenant-facing summary surfaces now share one governance posture and count contract for the same tenant state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Consistent Next Action Across Summary Surfaces (Priority: P1)
|
||||
|
||||
**Goal**: Make every covered summary surface point operators toward the same class of next step for the same tenant state.
|
||||
|
||||
**Independent Test**: Seed running-compare, failed-compare, unavailable-prerequisite, open-findings requiring action, overdue-without-new-drift, lapsed-governance without new drift, caution, and stale scenarios, then verify the dashboard governance card, banner, and landing summary expose the same next-action intent and matching operator-facing labels.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T014 [P] [US2] Add next-action intent regressions for running, failed, unavailable, open-findings action-required, overdue-without-new-drift, lapsed-without-new-drift, caution, and stale scenarios in `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
|
||||
- [X] T015 [P] [US2] Add dashboard governance card and banner next-step assertions, including preserved findings drill-down and open-landing navigation availability, in `tests/Feature/Filament/BaselineCompareNowWidgetTest.php` and `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T016 [US2] Add next-action label and target ownership to the aggregate contract in `app/Support/Baselines/TenantGovernanceAggregate.php` and `app/Support/Baselines/TenantGovernanceAggregateResolver.php`
|
||||
- [X] T017 [US2] Map shared next-action intent to findings, run, and landing destinations in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`
|
||||
- [X] T018 [US2] Align banner and landing next-action presentation to the shared intent in `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php` and `app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- [X] T019 [US2] Run the focused next-action pack in `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
|
||||
|
||||
**Checkpoint**: Covered summary surfaces no longer disagree about whether the operator should inspect findings, watch a run, open the landing page, or take no action.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Stable Multi-Card Tenant Summary (Priority: P2)
|
||||
|
||||
**Goal**: Keep multiple governance cards on the same tenant page stable by reusing one request-local aggregate and removing duplicate local findings-count ownership.
|
||||
|
||||
**Independent Test**: Render the tenant dashboard with both `Needs Attention` and `Baseline Governance` visible, verify both surfaces stay consistent for the same tenant in one request, then switch tenants or remove tenant context and verify no stale summary leaks.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T020 [P] [US3] Add request-local reuse, tenant-switch, and no-tenant safety coverage in `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php`
|
||||
- [X] T021 [P] [US3] Extend multi-card dashboard stability and no-local-requery coverage in `tests/Feature/Filament/NeedsAttentionWidgetTest.php` and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T022 [US3] Harden tenant-switch, workspace-scope, and no-tenant key behavior for the second dashboard summary consumer in `app/Support/Ui/DerivedState/DerivedStateFamily.php` and `app/Support/Baselines/TenantGovernanceAggregateResolver.php`
|
||||
- [X] T023 [US3] Replace local overdue, expiring, lapsed, and high-severity findings ownership with the shared aggregate in `app/Filament/Widgets/Dashboard/NeedsAttention.php`
|
||||
- [X] T024 [US3] Extend governance aggregate consumer declarations and no-local-requery guard coverage to include `NeedsAttention` in `specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml` and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`
|
||||
- [X] T025 [US3] Run the focused memoization and guard pack in `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`
|
||||
|
||||
**Checkpoint**: The tenant dashboard can render multiple governance summary surfaces in one request without contradictory counts, posture drift, or tenant leakage.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final copy alignment, formatting, and focused verification across all stories.
|
||||
|
||||
- [X] T026 [P] Align operator-facing copy and next-step labels in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`, `app/Filament/Widgets/Dashboard/NeedsAttention.php`, `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php`, and `app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- [X] T027 Run formatting for touched implementation files with `vendor/bin/sail bin pint --dirty --format agent` using `specs/168-tenant-governance-aggregate-contract/quickstart.md`
|
||||
- [X] T028 Run the final focused verification pack from `specs/168-tenant-governance-aggregate-contract/quickstart.md` against `tests/Feature/Baselines/BaselineCompareStatsTest.php`, `tests/Feature/Baselines/BaselineCompareSummaryAssessmentTest.php`, `tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php`, `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Filament/BaselineCompareCoverageBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`, `tests/Feature/Filament/BaselineCompareLandingDuplicateNamesBannerTest.php`, `tests/Feature/Filament/BaselineCompareLandingRbacLabelsTest.php`, `tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php`, and `tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Starts immediately and creates the narrow aggregate runtime and test scaffolding.
|
||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until the aggregate contract and request-scoped family are in place.
|
||||
- **User Story 1 (Phase 3)**: Starts after Foundational and delivers the MVP shared-posture slice across three surfaces.
|
||||
- **User Story 2 (Phase 4)**: Starts after User Story 1 because it refines the same summary surfaces with explicit next-action intent.
|
||||
- **User Story 3 (Phase 5)**: Starts after User Story 2 because it adopts the second dashboard summary surface only after shared next-action semantics and first-slice parity are settled.
|
||||
- **Polish (Phase 6)**: Starts after all desired user stories are complete and ends with focused verification.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Depends only on the aggregate contract and request-scoped family from Phase 2.
|
||||
- **User Story 2 (P1)**: Depends on User Story 1 because the same summary surfaces must already read the shared aggregate before their next-action mapping can be aligned.
|
||||
- **User Story 3 (P2)**: Depends on the aggregate contract from Phase 2 plus the settled shared posture and next-action semantics from User Stories 1 and 2 before the second dashboard consumer joins the shared contract.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Story tests should be written before or alongside the relevant implementation tasks and must fail before the story is considered complete.
|
||||
- Aggregate contract changes should land before surface adoption in the same story.
|
||||
- Surface refactors should land before the focused story-level regression run.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T001` and `T002` can run in parallel during Setup.
|
||||
- `T003` and `T006` can run in parallel during Foundational work.
|
||||
- `T007` and `T008` can run in parallel for User Story 1.
|
||||
- `T014` and `T015` can run in parallel for User Story 2.
|
||||
- `T020` and `T021` can run in parallel for User Story 3.
|
||||
- After `T022`, `T023` and `T024` can run in parallel because they change different files.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# User Story 1 tests in parallel:
|
||||
Task: T007 tests/Feature/Baselines/TenantGovernanceAggregateResolverTest.php
|
||||
Task: T008 tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php
|
||||
|
||||
# User Story 1 surface adoption split after aggregate semantics are fixed:
|
||||
Task: T009 app/Filament/Widgets/Dashboard/BaselineCompareNow.php
|
||||
Task: T010 app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# User Story 2 tests in parallel:
|
||||
Task: T014 tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php
|
||||
Task: T015 tests/Feature/Filament/BaselineCompareNowWidgetTest.php
|
||||
|
||||
# User Story 2 implementation split after next-action ownership lands:
|
||||
Task: T017 app/Filament/Widgets/Dashboard/BaselineCompareNow.php
|
||||
Task: T018 app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php and app/Filament/Pages/BaselineCompareLanding.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# User Story 3 tests in parallel:
|
||||
Task: T020 tests/Feature/Filament/TenantGovernanceAggregateMemoizationTest.php
|
||||
Task: T021 tests/Feature/Filament/NeedsAttentionWidgetTest.php
|
||||
|
||||
# User Story 3 implementation split after request-scoped keying is settled:
|
||||
Task: T023 app/Filament/Widgets/Dashboard/NeedsAttention.php
|
||||
Task: T024 tests/Feature/Guards/DerivedStateConsumerAdoptionGuardTest.php and specs/167-derived-state-memoization/contracts/request-scoped-derived-state.logical.openapi.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate that the dashboard governance card, tenant banner, and landing summary now share one posture and count contract.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Add User Story 2 to align next-action guidance once shared posture parity is proven.
|
||||
2. Add User Story 3 to bring the second dashboard card onto the same contract and lock in request-local stability.
|
||||
3. Finish with copy alignment, formatting, and the focused verification pack.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One developer can complete Phase 1 and Phase 2 while another prepares the story tests.
|
||||
2. After User Story 1 lands, one developer can handle User Story 2 next-action adoption while another prepares User Story 3 memoization and guard coverage.
|
||||
3. Rejoin for Phase 6 to run Pint and the focused verification pack.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks target different files with no direct dependency on unfinished code in the same phase.
|
||||
- `[US1]`, `[US2]`, and `[US3]` labels map tasks directly to the feature specification user stories.
|
||||
- The suggested MVP scope is Phase 1 through Phase 3 only.
|
||||
- No task in this plan introduces new persistence, a new Graph contract, a new Filament panel/provider registration, or a new destructive action.
|
||||
35
specs/169-action-surface-v11/checklists/requirements.md
Normal file
35
specs/169-action-surface-v11/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Action Surface Contract v1.1: Inspect Decision Rules, Menu Ordering, and Behavior Guard Coverage
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-30
|
||||
**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 against the active Spec Kit template, constitution retrofit priorities, and current repository state.
|
||||
- System-panel enrollment and relation-manager rollout work were treated as completed current-state evidence, so this spec stays bounded to the remaining foundation-and-enforcement gap.
|
||||
@ -0,0 +1,347 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Action Surface Governance Internal Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for declaration, discovery, and validation behavior in Spec 169
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 169. It documents the
|
||||
declaration shape, discovery scope, and validation behavior required to keep
|
||||
the Action Surface Contract behavior-aware. It does not add a public HTTP API.
|
||||
servers:
|
||||
- url: /internal
|
||||
x-governed-surface-families:
|
||||
- family: clickable_row_reference
|
||||
classes:
|
||||
- app/Filament/Pages/Monitoring/Operations.php
|
||||
- app/Filament/Resources/OperationRunResource.php
|
||||
guardTests:
|
||||
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
- family: explicit_inspect_history_reference
|
||||
classes:
|
||||
- app/Filament/Pages/Monitoring/AuditLog.php
|
||||
- app/Filament/System/Pages/Security/AccessLogs.php
|
||||
guardTests:
|
||||
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
- family: explicit_inspect_queue_reference
|
||||
classes:
|
||||
- app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
|
||||
guardTests:
|
||||
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
- family: reporting_registry_reference
|
||||
classes:
|
||||
- app/Filament/Pages/Monitoring/EvidenceOverview.php
|
||||
- app/Filament/Pages/Reviews/ReviewRegister.php
|
||||
guardTests:
|
||||
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
- family: destructive_last_reference
|
||||
classes:
|
||||
- app/Filament/Resources/BackupScheduleResource.php
|
||||
- app/Filament/Resources/TenantResource.php
|
||||
guardTests:
|
||||
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
- tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php
|
||||
- family: system_discovery_reference
|
||||
classes:
|
||||
- app/Filament/System/Pages/Ops/Runs.php
|
||||
- app/Filament/System/Pages/Ops/Failures.php
|
||||
- app/Filament/System/Pages/Ops/Stuck.php
|
||||
- app/Filament/System/Pages/Directory/Tenants.php
|
||||
- app/Filament/System/Pages/Directory/Workspaces.php
|
||||
- app/Filament/System/Pages/Security/AccessLogs.php
|
||||
guardTests:
|
||||
- tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
paths:
|
||||
/action-surfaces/discovered:
|
||||
get:
|
||||
summary: Discover the repository-wide action-surface validation scope
|
||||
operationId: discoverActionSurfaces
|
||||
responses:
|
||||
'200':
|
||||
description: Current discovery snapshot for validator coverage
|
||||
content:
|
||||
application/vnd.tenantatlas.action-surface-discovery+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DiscoverySnapshot'
|
||||
/action-surfaces/components/{className}:
|
||||
get:
|
||||
summary: Resolve the declaration contract for one discovered component
|
||||
operationId: resolveActionSurfaceDeclaration
|
||||
parameters:
|
||||
- name: className
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Declaration contract for the requested component
|
||||
content:
|
||||
application/vnd.tenantatlas.action-surface-declaration+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ActionSurfaceDeclarationV11'
|
||||
'404':
|
||||
description: Component is not in the primary discovery scope
|
||||
/action-surfaces/validate:
|
||||
post:
|
||||
summary: Validate discovered declarations against behavior-aware contract rules
|
||||
operationId: validateActionSurfaces
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/vnd.tenantatlas.action-surface-validation-request+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Validation completed
|
||||
content:
|
||||
application/vnd.tenantatlas.action-surface-validation-result+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationResult'
|
||||
components:
|
||||
schemas:
|
||||
ActionSurfaceComponentType:
|
||||
type: string
|
||||
enum:
|
||||
- resource
|
||||
- page
|
||||
- relation_manager
|
||||
ActionSurfaceProfile:
|
||||
type: string
|
||||
enum:
|
||||
- crud_list_and_edit
|
||||
- crud_list_and_view
|
||||
- list_only_read_only
|
||||
- run_log
|
||||
- relation_manager
|
||||
ActionSurfaceType:
|
||||
type: string
|
||||
enum:
|
||||
- crud_list_first_resource
|
||||
- read_only_registry_report
|
||||
- queue_review
|
||||
- history_audit
|
||||
- config_lite
|
||||
ActionSurfaceInspectAffordance:
|
||||
type: string
|
||||
enum:
|
||||
- clickable_row
|
||||
- view_action
|
||||
- primary_link_column
|
||||
ActionSurfaceSlot:
|
||||
type: string
|
||||
enum:
|
||||
- list_header
|
||||
- inspect_affordance
|
||||
- list_row_more_menu
|
||||
- list_bulk_more_group
|
||||
- list_empty_state
|
||||
- detail_header
|
||||
ActionSurfaceDefaults:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- moreGroupLabel
|
||||
- exportIsDefaultBulkActionForReadOnly
|
||||
properties:
|
||||
moreGroupLabel:
|
||||
type: string
|
||||
const: More
|
||||
exportIsDefaultBulkActionForReadOnly:
|
||||
type: boolean
|
||||
SlotRequirement:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- state
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- satisfied
|
||||
- exempt
|
||||
details:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
requiresTypedConfirmation:
|
||||
type: boolean
|
||||
SlotExemption:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- slot
|
||||
- reason
|
||||
properties:
|
||||
slot:
|
||||
$ref: '#/components/schemas/ActionSurfaceSlot'
|
||||
reason:
|
||||
type: string
|
||||
trackingRef:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
ActionSurfaceDeclarationV11:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- version
|
||||
- componentType
|
||||
- profile
|
||||
- surfaceType
|
||||
- defaults
|
||||
- slots
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
minimum: 1
|
||||
componentType:
|
||||
$ref: '#/components/schemas/ActionSurfaceComponentType'
|
||||
profile:
|
||||
$ref: '#/components/schemas/ActionSurfaceProfile'
|
||||
surfaceType:
|
||||
$ref: '#/components/schemas/ActionSurfaceType'
|
||||
defaults:
|
||||
$ref: '#/components/schemas/ActionSurfaceDefaults'
|
||||
slots:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/SlotRequirement'
|
||||
exemptions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SlotExemption'
|
||||
listRowPrimaryActionLimit:
|
||||
type:
|
||||
- integer
|
||||
- 'null'
|
||||
minimum: 0
|
||||
primaryLinkColumnReason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
DiscoveredComponent:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- className
|
||||
- componentType
|
||||
properties:
|
||||
className:
|
||||
type: string
|
||||
componentType:
|
||||
$ref: '#/components/schemas/ActionSurfaceComponentType'
|
||||
discoveryFamily:
|
||||
type: string
|
||||
enum:
|
||||
- resource
|
||||
- page
|
||||
- relation_manager
|
||||
- system_table_page
|
||||
DiscoverySnapshot:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- components
|
||||
- excludedFamilies
|
||||
properties:
|
||||
components:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DiscoveredComponent'
|
||||
excludedFamilies:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- widgets
|
||||
- auth_pages
|
||||
- dashboards
|
||||
- chooser_pages
|
||||
- onboarding_wizards
|
||||
- deferred_system_pages
|
||||
InspectDecisionRule:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- surfaceType
|
||||
- allowedAffordances
|
||||
- redundantViewForbidden
|
||||
properties:
|
||||
surfaceType:
|
||||
$ref: '#/components/schemas/ActionSurfaceType'
|
||||
allowedAffordances:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ActionSurfaceInspectAffordance'
|
||||
redundantViewForbidden:
|
||||
type: boolean
|
||||
primaryLinkReasonRequired:
|
||||
type: boolean
|
||||
OrderingRule:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- moreGroupLabel
|
||||
- inspectionHelpersFirst
|
||||
- workflowAfterNavigation
|
||||
- destructiveLast
|
||||
- emptyGroupsForbidden
|
||||
properties:
|
||||
moreGroupLabel:
|
||||
type: string
|
||||
const: More
|
||||
inspectionHelpersFirst:
|
||||
type: boolean
|
||||
workflowAfterNavigation:
|
||||
type: boolean
|
||||
destructiveLast:
|
||||
type: boolean
|
||||
emptyGroupsForbidden:
|
||||
type: boolean
|
||||
ValidationRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- enforceSystemPanelDiscovery
|
||||
- enforceBehaviorRules
|
||||
properties:
|
||||
enforceSystemPanelDiscovery:
|
||||
type: boolean
|
||||
const: true
|
||||
enforceBehaviorRules:
|
||||
type: boolean
|
||||
const: true
|
||||
ValidationIssue:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- className
|
||||
- message
|
||||
properties:
|
||||
className:
|
||||
type: string
|
||||
slot:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/ActionSurfaceSlot'
|
||||
- type: 'null'
|
||||
message:
|
||||
type: string
|
||||
hint:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
ValidationResult:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- componentCount
|
||||
- issues
|
||||
properties:
|
||||
componentCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
issues:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationIssue'
|
||||
195
specs/169-action-surface-v11/data-model.md
Normal file
195
specs/169-action-surface-v11/data-model.md
Normal file
@ -0,0 +1,195 @@
|
||||
# Phase 1 Data Model: Action Surface Contract v1.1
|
||||
|
||||
## Overview
|
||||
|
||||
This feature does not add a database table, persisted artifact, or cache. It extends the existing runtime action-surface contract with one new declaration-level enum and codifies the derived rules that govern discovery, inspect behavior, and overflow ordering for the enrolled reference surfaces.
|
||||
|
||||
## Existing Runtime Source Objects
|
||||
|
||||
### ActionSurfaceDeclaration v1.0
|
||||
|
||||
**Purpose**: Existing declaration object used by Resources, Pages, and RelationManagers to describe action-surface requirements.
|
||||
|
||||
**Current fields**:
|
||||
- `version`
|
||||
- `componentType`
|
||||
- `profile`
|
||||
- `defaults`
|
||||
- `slots`
|
||||
- `exemptions`
|
||||
- `metadata`
|
||||
|
||||
**Current behavior**:
|
||||
- Declares slot satisfaction or exemption.
|
||||
- Stores ad hoc metadata such as list row primary action limit.
|
||||
- Does not currently encode constitution surface type explicitly.
|
||||
|
||||
### ActionSurfaceProfile
|
||||
|
||||
**Purpose**: Existing slot-requirement family used by `ActionSurfaceProfileDefinition`.
|
||||
|
||||
**Current values**:
|
||||
- `CrudListAndEdit`
|
||||
- `CrudListAndView`
|
||||
- `ListOnlyReadOnly`
|
||||
- `RunLog`
|
||||
- `RelationManager`
|
||||
|
||||
**Validation role**:
|
||||
- Drives which slots are required.
|
||||
- Does not safely express whether a surface must be clickable-row, explicit-inspect, or edit-as-inspect.
|
||||
|
||||
### ActionSurfaceDiscovery and ActionSurfaceDiscoveredComponent
|
||||
|
||||
**Purpose**: Discover the repository’s in-scope declaration-backed components and pass them to the validator.
|
||||
|
||||
**Current fields on discovered component**:
|
||||
- `className`
|
||||
- `componentType`
|
||||
- `panelScopes`
|
||||
|
||||
**Current limitation**:
|
||||
- The main discovery pass covers declaration-backed tenant/admin surfaces but still excludes the enrolled system-panel list pages and cannot yet enforce the constitution behavior split precisely enough on the reference surfaces.
|
||||
|
||||
### ActionSurfaceValidationIssue and ActionSurfaceValidationResult
|
||||
|
||||
**Purpose**: Existing validator output used by the guard suite.
|
||||
|
||||
**Current role**:
|
||||
- Reports missing declarations, missing required slots, invalid inspect-affordance tokens, missing exemption reasons, and invalid read-only export defaults.
|
||||
|
||||
## New Runtime Entities
|
||||
|
||||
### ActionSurfaceType
|
||||
|
||||
**Purpose**: First-class constitution-aligned behavioral classification used to determine the allowed primary inspect model for a surface.
|
||||
|
||||
#### Values
|
||||
|
||||
| Value | Description | Allowed primary inspect model |
|
||||
|-------|-------------|-------------------------------|
|
||||
| `CrudListFirstResource` | Standard list-first CRUD resource where open and mutate decisions happen after entering the record | Clickable row by default; `PrimaryLinkColumn` only with explicit reason |
|
||||
| `ReadOnlyRegistryReport` | Scan-first registry or report surface with read-mostly or immutable records | Clickable row by default; `PrimaryLinkColumn` only with explicit reason |
|
||||
| `QueueReview` | Queue where the operator reviews an item in context and continues the queue | Explicit inspect / same-page selected detail; row click forbidden by default |
|
||||
| `HistoryAudit` | Immutable history or audit surface where chronology and context must be preserved | Explicit inspect / same-page selected detail; row click forbidden by default |
|
||||
| `ConfigLite` | Low-cardinality configuration where edit is the primary inspect surface | Edit-as-inspect allowed by default; no parallel View surface |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- Every declaration-backed component enrolled in the v1.1 reference pack must declare one `ActionSurfaceType`.
|
||||
- `ActionSurfaceType` determines inspect-model compatibility; `ActionSurfaceProfile` continues to determine required slots.
|
||||
- `PrimaryLinkColumn` is valid only when a concrete reason explains why row click is not the correct primary inspect model.
|
||||
|
||||
### ActionSurfaceDeclaration v1.1
|
||||
|
||||
**Purpose**: Extended declaration contract that combines slot requirements with explicit constitution behavior.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `version` | int | yes | Contract version |
|
||||
| `componentType` | enum | yes | Resource, Page, or RelationManager |
|
||||
| `profile` | `ActionSurfaceProfile` | yes | Existing slot-requirement family |
|
||||
| `surfaceType` | `ActionSurfaceType` | yes | New constitution-aligned behavior family |
|
||||
| `defaults` | `ActionSurfaceDefaults` | yes | Shared defaults such as `moreGroupLabel` |
|
||||
| `slots` | map<ActionSurfaceSlot, ActionSurfaceSlotRequirement> | yes | Slot requirements and satisfied declarations |
|
||||
| `exemptions` | map<ActionSurfaceSlot, ActionSurfaceExemption> | no | Explicit exemptions with reasons |
|
||||
| `metadata` | map<string, mixed> | no | Existing narrow metadata storage |
|
||||
| `listRowPrimaryActionLimit` | int nullable | no | Existing row-action budget metadata |
|
||||
| `primaryLinkColumnReason` | string nullable | no | Required when the inspect affordance is `PrimaryLinkColumn` |
|
||||
|
||||
#### Relationships
|
||||
|
||||
- One declaration belongs to exactly one component class.
|
||||
- One declaration has exactly one `ActionSurfaceProfile` and one `ActionSurfaceType`.
|
||||
- One declaration contains many slot requirements and optional slot exemptions.
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- `surfaceType` is required for all components inside the v1.1 primary discovery scope.
|
||||
- `surfaceType` and `InspectAffordance` must be compatible.
|
||||
- `defaults.moreGroupLabel` must remain `More`.
|
||||
- Empty or reasonless exemptions remain invalid.
|
||||
|
||||
### InspectDecisionRule
|
||||
|
||||
**Purpose**: Derived rule family that maps `surfaceType` to allowed inspect behavior.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `surfaceType` | `ActionSurfaceType` | yes | Surface family being governed |
|
||||
| `allowedAffordances` | list<`ActionSurfaceInspectAffordance`> | yes | Allowed inspect affordance values |
|
||||
| `forbiddenRedundantView` | bool | yes | Whether a lone row `View` action is forbidden |
|
||||
| `requiresPrimaryLinkReason` | bool | yes | Whether `PrimaryLinkColumn` must include a concrete reason |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- CRUD and registry surfaces must expose one obvious open path and may not render a redundant lone `View` row action.
|
||||
- Queue and audit surfaces must preserve context through explicit inspect.
|
||||
- Config-lite surfaces may open edit as inspect but may not add a competing View surface.
|
||||
|
||||
### ActionOrderingRule
|
||||
|
||||
**Purpose**: Derived rule family that governs `ActionGroup` and `BulkActionGroup` content on governed surfaces.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `groupLabel` | string | yes | Must remain `More` for governed overflow groups |
|
||||
| `inspectionHelpersFirst` | bool | yes | Navigation and safe inspection helpers come first |
|
||||
| `workflowAfterNavigation` | bool | yes | Non-destructive lifecycle and workflow actions follow safe helpers |
|
||||
| `destructiveLast` | bool | yes | Destructive actions sort last |
|
||||
| `emptyGroupForbidden` | bool | yes | Empty `ActionGroup` and `BulkActionGroup` placeholders are invalid |
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- Overflow groups must not exist only as placeholders.
|
||||
- Inspection and navigation helpers must lead the group when present.
|
||||
- Non-destructive lifecycle and workflow actions must follow safe helpers and must not trail destructive actions.
|
||||
- Destructive actions must sort last.
|
||||
- Ordering checks are enforced through representative rendered guard tests rather than generic runtime reflection across every surface.
|
||||
|
||||
### PrimaryDiscoveryScope
|
||||
|
||||
**Purpose**: Defines which repository surfaces the primary validator must discover.
|
||||
|
||||
#### Included families
|
||||
|
||||
- Enrolled monitoring and reporting pages under `app/Filament/Pages/**`
|
||||
- Enrolled representative CRUD and read-only registry resources under `app/Filament/Resources/**`
|
||||
- Declared, table-backed pages under `app/Filament/System/Pages/**` for the six enrolled system list surfaces
|
||||
|
||||
#### Excluded families
|
||||
|
||||
- Widgets
|
||||
- Auth pages
|
||||
- Dashboards
|
||||
- Choosers and onboarding wizards
|
||||
- Deferred or non-table system tooling such as Runbooks
|
||||
- Any class kept under explicit baseline exemption until a later spec enrolls it
|
||||
|
||||
#### Validation rules
|
||||
|
||||
- Discovery must not require new baseline exemptions for already enrolled system pages.
|
||||
- Out-of-scope families must remain explicitly excluded rather than silently swept in.
|
||||
|
||||
## Representative Surface Mapping
|
||||
|
||||
| Surface | Profile | Surface type | Inspect affordance | Ordering anchor |
|
||||
|---------|---------|--------------|--------------------|-----------------|
|
||||
| `Monitoring/Operations` | `RunLog` | `ReadOnlyRegistryReport` | `ClickableRow` | No row actions; row click is the anchor |
|
||||
| `Monitoring/AuditLog` | `RunLog` | `HistoryAudit` | Explicit inspect | No row click; context-preserving inspect is the anchor |
|
||||
| `Monitoring/FindingExceptionsQueue` | `RunLog` | `QueueReview` | Explicit inspect | Selected-record workflow actions stay off the standard list row |
|
||||
| `BaselineProfileResource` | `CrudListAndView` | `CrudListFirstResource` | `ClickableRow` | `More` contains safe actions first and archive last |
|
||||
| `System/Ops/Runs` | `RunLog` | `ReadOnlyRegistryReport` | `ClickableRow` to canonical system run detail | Cross-panel registry coverage anchor that preserves the canonical `Operations / Run` noun |
|
||||
| `System/Security/AccessLogs` | `RunLog` | `HistoryAudit` | Explicit inspect | System audit reference anchor |
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- No schema migration is required.
|
||||
- No new queue, notification, or asset behavior is introduced.
|
||||
- The final validator state requires explicit `surfaceType` on every discovered declaration-backed surface.
|
||||
257
specs/169-action-surface-v11/plan.md
Normal file
257
specs/169-action-surface-v11/plan.md
Normal file
@ -0,0 +1,257 @@
|
||||
# Implementation Plan: Action Surface Contract v1.1
|
||||
|
||||
**Branch**: `169-action-surface-v11` | **Date**: 2026-03-30 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/169-action-surface-v11/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/169-action-surface-v11/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Strengthen the existing action-surface contract so rendered behavior, not declaration presence alone, becomes the governing truth. The implementation adds one first-class constitution-aligned `surface_type` enum to `ActionSurfaceDeclaration` while keeping `ActionSurfaceProfile` as the slot-requirement model, extends primary discovery to the enrolled system-panel table pages under `app/Filament/System/Pages`, codifies inspect-model and `More`-menu ordering rules in the validator and reference docs, and protects the contract with both fast validator tests and representative Livewire guard tests anchored on clickable-row, explicit-inspect, and system-panel reference surfaces.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ActionSurfaceDeclaration`, `ActionSurfaceValidator`, `ActionSurfaceDiscovery`, `ActionSurfaceExemptions`, and Filament Tables / Actions APIs
|
||||
**Storage**: PostgreSQL unchanged; no new persistence, cache store, queue payload, or durable artifact
|
||||
**Testing**: Pest 4 feature tests and Livewire component tests, including validator stubs and rendered table guard coverage, executed through Laravel Sail
|
||||
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging/production
|
||||
**Project Type**: web application
|
||||
**Performance Goals**: Keep repository-wide action-surface validation deterministic and CI-friendly, add no new runtime queries or cross-request state to operator surfaces, and keep behavior-aware render checks limited to representative guard surfaces rather than every declaration-backed class
|
||||
**Constraints**: Derived-only governance slice, no new business routes or assets, no new capability or policy family, no new baseline exemptions for already enrolled surfaces, chooser/dashboard/widget/onboarding exemptions remain explicit, and all work must stay within Filament v5 / Livewire v4 conventions
|
||||
**Scale/Scope**: The enrolled reference surfaces named by Spec 169 across monitoring pages, reporting/evidence registers, representative CRUD and read-only registry resources, and the six enrolled system-panel list pages; representative render coverage for clickable-row, explicit-inspect, ordered overflow behavior, and out-of-scope preservation
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Read/write separation | PASS | PASS | This is a UI governance and guard-coverage slice only. No domain write path, operation start surface, or Graph call changes are introduced. |
|
||||
| Workspace + tenant isolation / RBAC-UX | PASS | PASS | Discovery coverage broadens validation only; it does not alter route visibility, panel access, 404 vs 403 semantics, or capability enforcement on tenant or system surfaces. |
|
||||
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED ENUM | PASS WITH JUSTIFIED ENUM | One new first-class enum and one declaration field are justified because `ActionSurfaceProfile` cannot distinguish constitution-governed inspect models across CRUD, queue, audit, and registry surfaces. No second registry or UI meta-framework is introduced. |
|
||||
| Persisted truth / behavioral state | PASS | PASS | No new table, artifact, cache, or persisted status family is introduced. The new `surface_type` enum is declaration-time behavior metadata only. |
|
||||
| UI constitution / one inspect model / placeholder ban | PASS | PASS | This feature directly enforces `UI-SURF-001`, `UI-HARD-001`, `UI-EX-001`, and `UI-REVIEW-001` by failing redundant inspect patterns, empty groups, and mismatched surface behavior. |
|
||||
| Filament-native UI | PASS | PASS | The implementation continues to govern native Filament `recordUrl()`, row actions, `ActionGroup`, and `BulkActionGroup` rather than inventing replacement UI. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | The plan keeps all work inside the current Filament v5 + Livewire v4 stack and adds only Livewire-compatible component tests. |
|
||||
| Provider registration location | PASS | PASS | No panel provider change is required; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No globally searchable resource behavior changes are introduced in this slice. |
|
||||
| Destructive action safety | PASS | PASS | Existing destructive actions remain executed through Filament actions with confirmation and authorization; this feature only validates placement and ordering. |
|
||||
| Asset strategy | PASS | PASS | No new panel or shared assets are added, so there is no `filament:assets` deployment impact. |
|
||||
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan uses fast validator stubs for semantic rules and representative rendered tests for business-visible behavior instead of expanding broad presentation-only test matrices. |
|
||||
## Project Structure
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/169-action-surface-v11/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Add a first-class `ActionSurfaceType` enum to `ActionSurfaceDeclaration` while keeping `ActionSurfaceProfile` as the slot-requirement model.
|
||||
- Keep inspect-model compatibility rules close to the existing contract stack by implementing them in the validator and enum helpers rather than a second registry or framework layer.
|
||||
- Extend discovery narrowly to declared system-panel table pages under `app/Filament/System/Pages`, using table + declaration opt-in so auth, dashboards, widgets, runbooks, and other deferred surfaces remain out of scope.
|
||||
- Keep current panel-scope metadata unchanged for this slice because no active consumer relies on a system-panel scope enum, and the feature’s requirement is validation coverage rather than a new panel taxonomy.
|
||||
- Split guard coverage between fast validator tests and representative Livewire render tests so declaration-level drift and rendered-behavior drift are both blocked.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/169-action-surface-v11/`:
|
||||
|
||||
- `research.md`: phase-0 decisions and rejected alternatives
|
||||
- `data-model.md`: declaration v1.1 contract, surface-type enum, decision rules, and discovery-scope model
|
||||
- `contracts/action-surface-governance.logical.openapi.yaml`: internal logical contract for declaration, discovery, and validation behavior
|
||||
- `quickstart.md`: focused implementation and verification workflow
|
||||
|
||||
Design decisions:
|
||||
|
||||
- `ActionSurfaceType` is explicit declaration data, not derived metadata.
|
||||
- `ActionSurfaceProfile` remains in place to drive required-slot validation.
|
||||
- `PrimaryLinkColumn` remains an exception path and requires an explicit reason instead of a new exception object model.
|
||||
- System-panel discovery uses filesystem scope plus declaration opt-in instead of a hardcoded class allowlist.
|
||||
- Behavior-aware ordering checks stay in representative tests rather than trying to introspect every Filament action tree generically.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/169-action-surface-v11/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── action-surface-governance.logical.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── Monitoring/
|
||||
│ │ ├── AuditLog.php
|
||||
│ │ ├── FindingExceptionsQueue.php
|
||||
│ │ └── Operations.php
|
||||
│ ├── Resources/
|
||||
│ │ ├── BackupScheduleResource.php
|
||||
│ │ ├── BaselineProfileResource.php
|
||||
│ │ ├── OperationRunResource.php
|
||||
│ │ ├── PolicyResource.php
|
||||
│ │ ├── TenantResource.php
|
||||
│ │ └── Workspaces/
|
||||
│ │ └── WorkspaceResource.php
|
||||
│ └── System/
|
||||
│ └── Pages/
|
||||
│ ├── Directory/
|
||||
│ │ ├── Tenants.php
|
||||
│ │ └── Workspaces.php
|
||||
│ ├── Ops/
|
||||
│ │ ├── Failures.php
|
||||
│ │ ├── Runs.php
|
||||
│ │ ├── Runbooks.php
|
||||
│ │ └── Stuck.php
|
||||
│ ├── RepairWorkspaceOwners.php
|
||||
│ └── Security/
|
||||
│ └── AccessLogs.php
|
||||
├── Support/
|
||||
│ └── Ui/
|
||||
│ └── ActionSurface/
|
||||
│ ├── ActionSurfaceDeclaration.php
|
||||
│ ├── ActionSurfaceDiscovery.php
|
||||
│ ├── ActionSurfaceExemptions.php
|
||||
│ ├── ActionSurfaceProfileDefinition.php
|
||||
│ ├── ActionSurfaceValidator.php
|
||||
│ └── Enums/
|
||||
│ ├── ActionSurfaceInspectAffordance.php
|
||||
│ ├── ActionSurfacePanelScope.php
|
||||
│ ├── ActionSurfaceProfile.php
|
||||
│ └── ActionSurfaceType.php
|
||||
docs/
|
||||
├── product/
|
||||
│ └── standards/
|
||||
│ └── filament-actions-ux.md
|
||||
└── ui/
|
||||
└── action-surface-contract.md
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Guards/
|
||||
│ │ ├── ActionSurfaceContractTest.php
|
||||
│ │ └── ActionSurfaceValidatorTest.php
|
||||
│ └── Rbac/
|
||||
│ └── TenantActionSurfaceConsistencyTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the existing Laravel monolith structure. Extend the current action-surface support stack under `app/Support/Ui/ActionSurface`, update the enrolled reference surfaces named by the spec, sync the two developer-facing standards docs, and protect the feature through the current Pest guard suite instead of creating a new module or documentation tree.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A — Introduce the explicit surface-type contract
|
||||
|
||||
**Goal**: Add one constitution-aligned enum and declaration field without creating a second action-governance framework.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `app/Support/Ui/ActionSurface/Enums/ActionSurfaceType.php` | Add the new first-class enum with the five enforced surface families: CRUD / List-first Resource, Read-only Registry / Report, Queue / Review, History / Audit, and Config-lite. |
|
||||
| A.2 | `app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php` | Extend the declaration with a first-class `surfaceType` field and fluent/factory support, keeping `profile` as the slot-requirement model and preserving existing defaults, slots, exemptions, and metadata. |
|
||||
| A.3 | `app/Support/Ui/ActionSurface/Enums/ActionSurfaceInspectAffordance.php` and `app/Support/Ui/ActionSurface/ActionSurfaceProfileDefinition.php` | Keep current affordance/profile models intact while adding the minimum helper semantics needed to evaluate allowed affordance combinations. |
|
||||
|
||||
### Phase B — Roll out surface types across the enrolled reference surfaces
|
||||
|
||||
**Goal**: Move the reference surfaces enrolled by the spec to explicit declaration-level surface typing before validator enforcement turns strict.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `app/Filament/Pages/Monitoring/Operations.php`, `AuditLog.php`, `FindingExceptionsQueue.php`, `EvidenceOverview.php`, and `app/Filament/Pages/Reviews/ReviewRegister.php` | Establish the primary clickable-row, explicit-inspect, and reporting-registry reference pages with explicit surface types that match the constitution. |
|
||||
| B.2 | Representative CRUD and read-only registry resources such as `TenantResource`, `PolicyResource`, `BackupScheduleResource`, `BaselineProfileResource`, `WorkspaceResource`, `AlertDeliveryResource`, `BaselineSnapshotResource`, `EvidenceSnapshotResource`, `ReviewPackResource`, and `TenantReviewResource` | Align the enrolled resource reference families with the new explicit surface-type field so inspect-model and ordering checks have stable anchors. |
|
||||
| B.3 | `app/Filament/Resources/OperationRunResource.php` and the enrolled system list pages under `app/Filament/System/Pages/**` | Keep the run-log and cross-panel registry references in the same rollout slice so the validator can fail on missing types without leaving the reference pack in a mixed state. |
|
||||
|
||||
### Phase C — Bring system-panel table pages into the primary discovery pass
|
||||
|
||||
**Goal**: Eliminate the split between the main validator and the targeted system-page assertions.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php` | Add narrow discovery for `app/Filament/System/Pages/**` that includes only table-backed pages with declarations, so the six enrolled system list pages enter the primary validator. |
|
||||
| C.2 | `app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php` | Preserve explicit exemptions for deferred families and verify no stale baseline exemptions remain for the six enrolled system pages. |
|
||||
| C.3 | `app/Filament/System/Pages/Ops/Runs.php`, `Failures.php`, `Stuck.php`, `Directory/Tenants.php`, `Directory/Workspaces.php`, and `Security/AccessLogs.php` | Confirm these pages remain declaration-backed reference surfaces under the main validator without sweeping in auth, dashboard, runbook, or break-glass pages. |
|
||||
|
||||
### Phase D — Enforce inspect-model and ordering behavior in the validator and docs
|
||||
|
||||
**Goal**: Make the contract fail for behaviorally wrong declarations, not just missing slots.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `app/Support/Ui/ActionSurface/ActionSurfaceValidator.php` | Add behavior-aware rules: require explicit `surfaceType`, validate allowed inspect affordances by surface type, reject redundant lone `View` patterns on clickable-row surfaces, and require an explicit reason when `PrimaryLinkColumn` is used as an exception path. |
|
||||
| D.2 | `docs/ui/action-surface-contract.md` and `docs/product/standards/filament-actions-ux.md` | Update the developer-facing reference docs together so the constitution-aligned inspect and ordering rules match the validator and the test suite. |
|
||||
| D.3 | `tests/Feature/Guards/ActionSurfaceValidatorTest.php` | Extend the stub-based validator suite to cover missing `surfaceType`, invalid surface-type/affordance pairings, and required exception reasons. |
|
||||
|
||||
### Phase E — Add representative rendered guard coverage
|
||||
|
||||
**Goal**: Prove the declaration cannot claim conformance while the actual Filament table behavior drifts.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `tests/Feature/Guards/ActionSurfaceContractTest.php` | Add or extend rendered table tests for clickable-row references, explicit-inspect history/audit references, explicit-inspect queue/review references, reporting-registry coverage, system-panel discovery coverage, and helper-first / workflow-next / destructive-last `More` menu ordering. |
|
||||
| E.2 | `tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php` | Keep one RBAC-aware tenant resource reference proving row-click and `More`-menu semantics remain aligned with capability gating and overflow placement. |
|
||||
| E.3 | `vendor/bin/sail bin pint --dirty --format agent` plus focused Pest runs | Format touched files and run the narrow verification pack for validator, contract guard, and tenant action-surface consistency coverage. |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D-001 — `surfaceType` is explicit and separate from `profile`
|
||||
|
||||
`ActionSurfaceProfile` remains the technical slot-requirement model. The new `surfaceType` is the constitution-governed behavioral classification. Keeping them separate avoids forcing slot rules and operator interaction semantics into the same enum.
|
||||
|
||||
### D-002 — Inspect rules stay close to the existing contract stack
|
||||
|
||||
The feature should not introduce a second registry or action-governance subsystem. The narrowest implementation is one new enum plus validator logic and small helper methods where needed.
|
||||
|
||||
### D-003 — System discovery is opt-in, not broad
|
||||
|
||||
System-panel coverage is achieved by discovering declared, table-backed pages under `app/Filament/System/Pages`, not by sweeping every system page into the validator. This preserves explicit exemptions for auth, dashboards, choosers, and other deferred surfaces.
|
||||
|
||||
### D-004 — Representative render tests enforce business-visible truth
|
||||
|
||||
The validator can prove declaration semantics, but it cannot prove Filament table behavior alone. Representative Livewire tests must anchor the real clickable-row, explicit-inspect, and helper-first / workflow-next / destructive-last rules.
|
||||
|
||||
### D-005 — `PrimaryLinkColumn` remains a justified exception path
|
||||
|
||||
The spec needs stronger control over linked-column inspect affordances, but not a new exception object model. A required explicit reason in the declaration is sufficient for this slice.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Declaration rollout is incomplete when `surfaceType` becomes required | High | Medium | Introduce the contract and update the entire enrolled reference pack in the same implementation slice before making missing `surfaceType` a validator failure. |
|
||||
| Discovery becomes too broad and sweeps in auth, dashboard, or deferred system surfaces | High | Medium | Limit the new discovery path to declared, table-backed system pages and preserve explicit baseline exemptions for deferred families. |
|
||||
| `surfaceType` and `profile` drift semantically | Medium | Medium | Document their separate responsibilities in code, docs, and tests, and anchor each critical surface family with representative declarations. |
|
||||
| More-menu ordering tests become brittle because of exact action sequences | Medium | Medium | Assert ordering invariants such as helper-first, workflow-next, destructive-last, and non-empty groups instead of pinning every menu to a full exact sequence. |
|
||||
| `PrimaryLinkColumn` remains under-specified | Medium | Low | Require explicit reason text and representative validation coverage before allowing the exception path to pass. |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Extend `tests/Feature/Guards/ActionSurfaceValidatorTest.php` with stub declarations that cover required `surfaceType`, invalid affordance combinations, and explicit exception-reason requirements.
|
||||
- Extend `tests/Feature/Guards/ActionSurfaceContractTest.php` with representative Livewire coverage for Monitoring Operations or `OperationRunResource`, Audit Log, Finding Exceptions Queue, a reporting or evidence register, one CRUD `More` menu that proves helper-first, workflow-next, destructive-last ordering, and the six primary system-panel list pages discovered by the validator.
|
||||
- Keep one tenant-plane RBAC-aware reference test in `tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php` so action-surface behavior remains compatible with disabled-vs-forbidden gating rules.
|
||||
- Run the narrow Sail verification pack from `quickstart.md` before considering the slice complete.
|
||||
- Ask whether the user wants the full suite after focused tests pass.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| New first-class `surfaceType` enum and declaration field | The existing `ActionSurfaceProfile` cannot distinguish constitution-level inspect behavior between queue, audit, registry, CRUD, and config-lite surfaces, so behavior-aware validation needs explicit type data. | Reusing `profile` or hiding the distinction in metadata would keep the rule implicit, make validator failures less actionable, and preserve the declaration-only gap this spec is fixing. |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: The repository can already prove that declarations exist, but it cannot yet prove that the declared inspect model and overflow behavior actually match the constitution. That leaves real clickable-row and explicit-inspect surfaces vulnerable to silent behavioral drift.
|
||||
- **Existing structure is insufficient because**: `ActionSurfaceProfile` only describes slot requirements. It is too coarse to distinguish queue and audit surfaces that require explicit inspect from CRUD and registry surfaces that require one-click open. Primary discovery also still excludes the enrolled system-panel list pages.
|
||||
- **Narrowest correct implementation**: Add one first-class `surfaceType` enum to `ActionSurfaceDeclaration`, extend discovery narrowly to the already-enrolled system table pages, and strengthen the validator plus representative rendered tests. Do not add a second registry, persistence layer, or UI framework.
|
||||
- **Ownership cost created**: This adds one enum, one declaration field, more explicit declaration work on the enrolled reference surfaces, stronger validator logic, and a small set of focused guard tests that reviewers must maintain as the contract evolves.
|
||||
- **Alternative intentionally rejected**: Documentation-only guidance and declaration-only slot validation were rejected because they cannot fail when rendered behavior drifts. Replacing `ActionSurfaceProfile` entirely was rejected because slot requirements and constitution surface semantics are separate concerns.
|
||||
- **Release truth**: Current-release truth. The repository already contains both correct clickable-row and correct explicit-inspect patterns, and the missing work is durable enforcement.
|
||||
74
specs/169-action-surface-v11/quickstart.md
Normal file
74
specs/169-action-surface-v11/quickstart.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Quickstart: Action Surface Contract v1.1
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that the action-surface contract now governs behavior, not just declaration presence: every enrolled reference surface declares an explicit constitution-aligned `surfaceType`, the primary validator discovers the enrolled system-panel list pages, clickable-row and explicit-inspect rules are enforced, and representative `More` menus keep helpers first, workflow actions next, and destructive actions last.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start Sail.
|
||||
2. Ensure the database and factories are available for the current test suite.
|
||||
3. Keep the current baseline exemptions intact for deferred choosers, dashboards, widgets, onboarding flows, and non-enrolled system pages.
|
||||
4. Ensure representative tenant-plane and system-plane test helpers continue to work:
|
||||
- tenant helper for standard resources and monitoring pages
|
||||
- platform helper for system-panel pages
|
||||
|
||||
## Implementation Validation Order
|
||||
|
||||
### 1. Run low-level validator coverage
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceValidatorTest.php
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- Missing `surfaceType` fails.
|
||||
- Invalid `surfaceType` and inspect-affordance combinations fail with actionable messages.
|
||||
- `PrimaryLinkColumn` requires an explicit reason when used.
|
||||
|
||||
### 2. Run representative rendered action-surface guards
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- Clickable-row references still render `recordUrl()` and do not expose redundant lone `View` actions.
|
||||
- Explicit-inspect history and queue references preserve context and do not regress to row click.
|
||||
- Reporting and evidence registers remain scan-first clickable-row registries rather than being misclassified as audit surfaces.
|
||||
- System-panel reference pages are discovered by the primary validator without stale baseline exemptions.
|
||||
- Representative `More` menus keep helpers first, workflow actions next, destructive actions last, and do not render empty placeholder groups.
|
||||
|
||||
### 3. Run RBAC-aware tenant reference coverage
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- Tenant resource row-click and overflow behavior remains aligned with disabled-vs-forbidden capability semantics.
|
||||
- Existing `More`-menu placement still cooperates with RBAC visibility rules.
|
||||
|
||||
### 4. Format touched implementation files
|
||||
|
||||
```bash
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
Expected outcome:
|
||||
- Touched PHP files follow the repo’s Pint rules.
|
||||
|
||||
## Manual Smoke Check
|
||||
|
||||
1. Open `/admin/operations` and confirm the list still opens records through row click without a redundant row-level `View` action.
|
||||
2. Open `/admin/audit-log` and confirm inspection stays explicit and context-preserving rather than row-click navigation.
|
||||
3. Open `/admin/finding-exceptions/queue` and confirm inspection remains explicit while decision actions stay tied to the selected record context.
|
||||
4. Open `/system/ops/runs` and `/system/directory/tenants` as a platform user and confirm those pages still behave as read-only registries while now also belonging to the main validator discovery pass.
|
||||
5. Confirm deferred surfaces such as chooser pages, dashboards, widgets, and runbooks remain out of scope.
|
||||
|
||||
## Non-Goals For This Slice
|
||||
|
||||
- No new database migration or persisted artifact.
|
||||
- No new asset or `filament:assets` deployment change.
|
||||
- No new policy or capability family.
|
||||
- No new public HTTP API; the contract artifact is internal planning documentation only.
|
||||
57
specs/169-action-surface-v11/research.md
Normal file
57
specs/169-action-surface-v11/research.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Phase 0 Research: Action Surface Contract v1.1
|
||||
|
||||
## Decision: Add a first-class `ActionSurfaceType` enum to `ActionSurfaceDeclaration` while keeping `ActionSurfaceProfile`
|
||||
|
||||
**Rationale**: `ActionSurfaceProfile` currently governs which slots are required, but it does not distinguish constitution-level interaction semantics. The repo needs to tell the difference between clickable-row CRUD or registry surfaces and legitimate explicit-inspect queue or audit surfaces. A first-class `surfaceType` field makes that distinction explicit without forcing slot requirements and behavioral rules into the same enum.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Replace `ActionSurfaceProfile` entirely: rejected because slot requirements and constitution surface semantics are different concerns and existing declarations already rely on the profile model.
|
||||
- Derive surface type from metadata or inferred heuristics: rejected because the clarified spec requires a first-class declaration-level field and because inference would keep validator failures ambiguous.
|
||||
|
||||
## Decision: Keep inspect-model compatibility rules close to the existing contract stack
|
||||
|
||||
**Rationale**: The narrowest implementation is one new enum plus validator logic and small helper methods where needed. That keeps the feature inside the current `ActionSurfaceDeclaration` / `ActionSurfaceValidator` architecture and avoids introducing a second action-governance registry or policy layer.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create a new `ActionSurfaceTypeDefinition` registry or service: rejected because the slice does not justify another framework layer.
|
||||
- Enforce the new rules in tests only: rejected because the main validator must become the primary contract gate, not just the rendered tests.
|
||||
|
||||
## Decision: Extend primary discovery to declared system-panel table pages only
|
||||
|
||||
**Rationale**: The six enrolled system list pages already have declarations and targeted tests, but the main discovery pass still excludes them. The safest extension is to scan `app/Filament/System/Pages/**` narrowly for declared, table-backed pages so the main validator covers them without accidentally enrolling auth, dashboard, widget, or other deferred system surfaces.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hardcode an allowlist of six system classes: rejected because it would preserve a parallel discovery model and create more manual maintenance.
|
||||
- Broadly discover every page under `app/Filament/System/Pages`: rejected because it would sweep in deferred surfaces such as auth, runbooks, and break-glass tooling.
|
||||
|
||||
## Decision: Keep current panel-scope metadata unchanged for this slice
|
||||
|
||||
**Rationale**: The feature needs system-panel discovery coverage, not a new panel taxonomy. The current codebase does not have an active consumer that requires a `System` panel scope enum, so adding one now would widen the slice without solving the immediate operator problem.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add `ActionSurfacePanelScope::System`: rejected because no current validation or runtime rule depends on it, and the spec does not require it.
|
||||
- Infer system scope through panel providers and extend all scope tests: rejected as unnecessary expansion beyond the current feature goal.
|
||||
|
||||
## Decision: Split enforcement between fast validator tests and representative Livewire render tests
|
||||
|
||||
**Rationale**: Validator tests are the right seam for declaration-time rules such as required `surfaceType`, allowed affordance combinations, and required exception reasons. Livewire render tests are the right seam for business-visible behavior such as row click actually existing, explicit inspect actually preserving context, and `More` groups actually rendering helpers first, workflow actions next, and destructive actions last.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Declaration-only guard coverage: rejected because the constitution explicitly rejects declaration-only conformance when rendered behavior drifts.
|
||||
- Browser-only coverage: rejected because it would be slower, broader, and less precise than the current Livewire table guard pattern already used in the repo.
|
||||
|
||||
## Decision: Treat `PrimaryLinkColumn` as an exception path that requires an explicit reason
|
||||
|
||||
**Rationale**: The spec needs stronger control over linked-column inspect affordances, but the repo does not need a dedicated exception object or secondary taxonomy for that. Requiring an explicit reason on the declaration is enough to keep the exception visible and reviewable.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Allow `PrimaryLinkColumn` anywhere the enum is present: rejected because the constitution requires a concrete reason when row click is not the correct primary inspect model.
|
||||
- Add a new exception class hierarchy: rejected because it would add structure without a separate current-release problem to solve.
|
||||
|
||||
## Decision: Keep the two reference docs in lockstep with validator behavior
|
||||
|
||||
**Rationale**: The repo already has two developer-facing references for this domain: `docs/ui/action-surface-contract.md` and `docs/product/standards/filament-actions-ux.md`. The feature should update both together so implementation guidance, spec language, and guard behavior stay aligned.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Update only the constitution: rejected because day-to-day implementation guidance lives in the shorter reference docs.
|
||||
- Update only one doc and let the other lag: rejected because that would recreate the ambiguity the feature is meant to remove.
|
||||
221
specs/169-action-surface-v11/spec.md
Normal file
221
specs/169-action-surface-v11/spec.md
Normal file
@ -0,0 +1,221 @@
|
||||
# Feature Specification: Action Surface Contract v1.1: Inspect Decision Rules, Menu Ordering, and Behavior Guard Coverage
|
||||
|
||||
**Feature Branch**: `169-action-surface-v11`
|
||||
**Created**: 2026-03-30
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 169 — Action Surface Contract v1.1: Inspect Rules, Menu Ordering, and System Guard Coverage"
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-03-30
|
||||
|
||||
- Q: How should Spec 169 require the contract to encode surface type for enforcement? → A: Add a first-class declaration field / enum for constitution surface types and drive inspect-rule enforcement from it.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace + tenant + canonical-view + platform
|
||||
- **Primary Routes**:
|
||||
- `/admin/operations` and `/admin/operations/{run}` for the canonical operations list and run detail flow that now represent the clickable-row reference implementation
|
||||
- `/admin/audit-log` for the history / audit surface that intentionally uses explicit inspect rather than row navigation
|
||||
- `/admin/finding-exceptions/queue` for the queue / review surface that intentionally uses explicit inspect and selected-record detail actions
|
||||
- `/system/ops/runs`, `/system/ops/failures`, and `/system/ops/stuck` for system-panel operational list surfaces that must be discovered by the primary contract guard
|
||||
- `/system/directory/tenants`, `/system/directory/workspaces`, and `/system/security/access-logs` for system-panel registry and audit list surfaces that must be discovered by the primary contract guard
|
||||
- existing CRUD / List-first resource surfaces that already declare the contract, especially the Tenants and Policies lists, as representative ordering-rule references rather than new rollout targets
|
||||
- **Data Ownership**:
|
||||
- No tenant-owned or workspace-owned business entity changes are introduced
|
||||
- The only structural surface owned by this feature is the in-code action-surface declaration and validator contract, plus the supporting standards documentation and guard tests that govern operator-facing list behavior
|
||||
- Existing tenant-owned, workspace-owned, and platform-owned records remain unchanged; this feature only governs how their list surfaces declare and enforce interaction semantics
|
||||
- **RBAC**:
|
||||
- Existing tenant/admin and platform authorization planes remain unchanged
|
||||
- Existing workspace membership, tenant entitlement, and platform capability checks remain the enforcement source for covered pages
|
||||
- Non-members or cross-plane actors remain deny-as-not-found; in-scope members without capability remain forbidden
|
||||
- This feature must not weaken or bypass existing server-side authorization and must not treat UI contract enforcement as a security boundary
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Canonical and workspace-scoped list pages keep their current tenant-prefilter behavior when entered from tenant context. This feature does not broaden filter scope; it only governs inspect/open behavior and action ordering on those surfaces.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing tenant and workspace entitlement checks remain authoritative. Adding system-panel pages to the primary discovery and validation pass must not alter route visibility, record visibility, or link generation rules. Contract discovery must never imply broader access.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Monitoring Operations | Read-only Registry / Report | Full-row click to canonical run detail | required | none on the list; related actions live on the detail header | none on the list; any dangerous follow-up remains on the detail header | `/admin/operations` | `/admin/operations/{run}` | Workspace scope plus optional tenant filter | Operations / Run | run status, outcome, age, initiator | none |
|
||||
| Monitoring Audit Log | History / Audit | Explicit Inspect action with same-page selected-event detail | forbidden | page header and selected-event header actions | none on the list; no destructive audit-row action | `/admin/audit-log` | `/admin/audit-log?event={event}` | Workspace scope plus tenant filter | Audit log / Audit event | outcome, actor, target, recorded time | none |
|
||||
| Finding Exceptions Queue | Queue / Review | Explicit Inspect action with same-page selected-record review detail | forbidden | page header and selected-record header actions | selected-record header only; list rows stay decision-light | `/admin/finding-exceptions/queue` | `/admin/finding-exceptions/queue?exception={record}` | Workspace scope plus tenant prefilter when entered from tenant context | Finding exceptions / Exception | approval state, tenant, requested governance action, expiry urgency | none |
|
||||
| Reporting and evidence registers (`Review Register`, `Evidence Overview`) | Read-only Registry / Report | Full-row click to the existing review or evidence detail destination | required | filter-reset or scope-reset helpers remain in the page header; no row-level secondary actions | none on the list | `/admin/reviews` and the existing evidence overview route | existing tenant-scoped review and evidence detail destinations | Workspace scope plus optional tenant filter or prefilter context | Reviews / Review and Evidence / Snapshot | artifact truth, completeness or freshness, generated or captured time | none |
|
||||
| System operations lists (`Runs`, `Failures`, `Stuck`) | Read-only Registry / Report | Full-row click to system run detail | required | none on the list unless a system page has a justified secondary action | none on the list | `/system/ops/runs`, `/system/ops/failures`, `/system/ops/stuck` | `/system/ops/{run}` | Platform scope only | Operations / Run | run status, outcome, recency, failure/stuck signal | Cross-panel Canonical Route Exception |
|
||||
| System directory lists (`Tenants`, `Workspaces`) | Read-only Registry / Report | Full-row click to system entity detail | required | none on the list unless a page-specific safe shortcut is justified | none on the list | `/system/directory/tenants`, `/system/directory/workspaces` | existing system detail pages for tenant/workspace inspection | Platform scope only | Tenants / Workspaces | identity, state, summary metadata needed for selection | none |
|
||||
| System access logs | History / Audit | Explicit Inspect action or equivalent context-preserving detail open | forbidden by default | page header and selected-record context actions | none on the list | `/system/security/access-logs` | same-page selected detail or existing system detail route | Platform scope only | Access logs / Access event | actor, capability, outcome, recorded time | none |
|
||||
| Representative read-only registry resources (`Operation runs`, `Alert deliveries`, `Baseline snapshots`, `Evidence snapshots`, `Review packs`, `Tenant reviews`) | Read-only Registry / Report | One-click open only, normally via row click, with at most one justified non-destructive inline shortcut | required unless a documented exception applies | no row secondary actions by default; any justified safe shortcut remains singular | detail header only, or `More` only where an existing lifecycle action already exists | existing tenant or workspace collection routes | existing view routes | existing tenant or workspace scope chips remain truthful | resource-specific canonical noun remains stable per resource | outcome, freshness, completeness, publication readiness, or artifact truth required for triage | none |
|
||||
| Representative CRUD list-first resources (`Tenants`, `Policies`, `Baseline profiles`, `Backup schedules`, `Workspaces`) | CRUD / List-first Resource | One-click open only, with at most one inline safe shortcut when justified | required unless a documented exception applies | one inline safe shortcut plus `More` | `More` and detail header only | existing tenant/admin collection routes | existing view or edit routes | Workspace or tenant context chips already in use | resource-specific canonical nouns such as Tenants / Tenant, Policies / Policy, Baselines / Baseline, Backup schedules / Backup schedule, and Workspaces / Workspace | lifecycle, operability, status needed for action | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Monitoring Operations | Tenant operator or workspace operator | Read-only Registry / Report | Which runs should I open, and what happened most recently? | status, outcome, operation type, initiator, start time, duration | raw context JSON, normalized counts, internal IDs, related diagnostic payloads | execution outcome, lifecycle recency | Read-only list; existing detail actions unchanged | Open run | Existing detail-level retry or resume actions only where already allowed |
|
||||
| Monitoring Audit Log | Senior operator or auditor | History / Audit | Which event should I inspect without losing chronology? | outcome, actor, action, target, recorded time | full metadata payload, deeper trace context, internal identifiers | audit outcome, chronology | Read-only | Inspect event, open related record | none |
|
||||
| Finding Exceptions Queue | Workspace approver or governance reviewer | Queue / Review | Which exception needs review now, and what is the right decision? | approval state, tenant, target finding, expiry or urgency, requester context | full request details, governance history, supporting raw metadata | approval lifecycle, urgency | Existing approve/reject workflow only | Inspect exception, approve, reject, open related finding | Approve/reject remain selected-detail actions with existing confirmation and authorization |
|
||||
| Reporting and evidence registers | Workspace operator, reviewer, or auditor | Read-only Registry / Report | Which review or evidence record should I open next without losing scanability? | artifact truth, completeness or freshness, generated or captured time, tenant context when applicable | raw JSON, detailed supporting payloads, internal identifiers | artifact freshness, completeness, publication readiness where applicable | Read-only | Open review or evidence detail | none |
|
||||
| System operations lists | Platform operator | Read-only Registry / Report | Which platform operation or failure needs investigation? | run type, status, outcome, recency, high-signal failure context | raw run payloads, internal run metadata, technical traces | execution outcome, chronology, stuck or failed state | Read-only list; existing run follow-up unchanged | Open run | Existing run follow-up actions only where already defined |
|
||||
| System directory lists | Platform operator | Read-only Registry / Report | Which platform record do I need to inspect next? | name, identity, high-signal summary metadata | deeper system metadata and internal diagnostic fields | lifecycle or availability where relevant | Read-only | Open detail | none |
|
||||
| Representative read-only registry resources | Tenant or workspace operator | Read-only Registry / Report | Which immutable or read-mostly record should I open next? | artifact truth, freshness, completeness, outcome, or publication readiness needed for triage | raw JSON, related payloads, deep diagnostics, internal identifiers | resource-specific truth dimensions remain separate | Read-only or tightly scoped lifecycle follow-up only | Open detail, with at most one justified safe shortcut | Existing lifecycle cleanup remains detail-header first, or `More` only where already present |
|
||||
| Representative CRUD resources | Tenant or workspace operator | CRUD / List-first Resource | Which record should I open or mutate next? | identifier, health, operability, lifecycle or status needed for triage | raw IDs, low-level diagnostics, provider payload details | lifecycle, operability, state | Existing resource mutations only | Open record, one justified inline safe shortcut | Existing destructive actions remain under `More` or detail header |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: No
|
||||
- **New persisted entity/table/artifact?**: No
|
||||
- **New abstraction?**: Yes
|
||||
- **New enum/state/reason family?**: Yes. Spec 169 introduces a first-class declaration enum for constitution-aligned surface types so inspect-rule enforcement can be driven by explicit contract data instead of inference.
|
||||
- **New cross-domain UI framework/taxonomy?**: No. This feature mirrors the already-approved constitution surface taxonomy instead of inventing a new product taxonomy
|
||||
- **Current operator problem**: The repository can currently verify that declarations exist, but it cannot reliably verify that the declared inspect model and action-group behavior actually match the constitution. This leaves room for regressions that look compliant on paper while drifting in rendered UI behavior.
|
||||
- **Existing structure is insufficient because**: `ActionSurfaceProfile` alone is too coarse to distinguish list-first clickable-row surfaces from legitimate explicit-inspect queue and audit surfaces. The main validator also does not bring system-panel list pages into the primary discovery pass.
|
||||
- **Narrowest correct implementation**: Extend the existing declaration and validator contract with one first-class `surface_type` field / enum aligned to the constitution surface taxonomy, then drive inspect-model and ordering enforcement from that field. Do not build a second registry or a broad UI meta-framework.
|
||||
- **Ownership cost**: This adds a small amount of declaration metadata, stronger validator logic, and representative runtime guard tests. It also raises the review bar for future list surfaces because contributors will need to declare why a surface is clickable-row, explicit-inspect, or exempt.
|
||||
- **Alternative intentionally rejected**: Another manual cleanup slice or another declaration-only rollout was rejected because the repo already completed most of that work. Documentation-only guidance without guard coverage was also rejected because it would not prevent regressions.
|
||||
- **Release truth**: Current-release truth. The repo already contains both correct clickable-row patterns and correct explicit-inspect patterns, and the missing work is turning those conventions into durable enforcement.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Enforce the Correct Inspect Model (Priority: P1)
|
||||
|
||||
As a developer or reviewer, I need the action-surface guard to distinguish when a surface must use clickable row behavior and when it must use explicit Inspect behavior, so that new list pages cannot drift into redundant or misleading interaction models.
|
||||
|
||||
**Why this priority**: The constitution’s highest-value list rule is “one primary inspect/open model per list.” If the repo cannot enforce that, later rollout work becomes fragile.
|
||||
|
||||
**Independent Test**: Can be fully tested by validating one clickable-row reference surface and one explicit-inspect reference surface, then proving the guard fails when their inspect models are swapped or duplicated.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a CRUD / List-first or Read-only Registry / Report surface that should open through row click, **When** it declares or renders a redundant lone `View` action, **Then** the guard fails with an actionable message.
|
||||
2. **Given** a Queue / Review or History / Audit surface that should preserve context through explicit Inspect, **When** it declares row-click navigation as its primary inspect model without a documented exception, **Then** the guard fails.
|
||||
3. **Given** a legitimate explicit-inspect surface such as Audit Log or Finding Exceptions Queue, **When** the guard evaluates its declaration and rendered behavior, **Then** it passes without being forced into a clickable-row model.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Enforce Stable More-Menu Ordering (Priority: P1)
|
||||
|
||||
As an operator, I need secondary and destructive actions to appear in a stable order across governed list surfaces, so that I can scan `More` menus without re-learning where dangerous actions are hidden.
|
||||
|
||||
**Why this priority**: Wave 1 normalized where actions live; the next missing layer is ensuring consistent ordering so the contract governs behavior, not just group existence.
|
||||
|
||||
**Independent Test**: Can be fully tested by asserting the ordered shape of representative `More` menus on existing governed CRUD resources and ensuring inspection or navigation helpers appear first, non-destructive workflow actions appear next, destructive actions sort last, and empty groups fail.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a governed `More` menu with navigation, workflow, and destructive actions, **When** the menu is validated, **Then** inspection or navigation helpers appear first, non-destructive workflow actions appear next, and destructive actions appear last.
|
||||
2. **Given** a governed surface defines an `ActionGroup` or `BulkActionGroup` placeholder with no effective actions, **When** the guard runs, **Then** the guard fails.
|
||||
3. **Given** a governed surface has a justified inline safe shortcut plus a `More` menu, **When** the guard evaluates it, **Then** the shortcut remains allowed while overflow actions still follow the standard helper-first, workflow-next, destructive-last ordering rules.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Bring System Lists Under Primary Guard Coverage (Priority: P2)
|
||||
|
||||
As a maintainer, I need system-panel list pages to be discovered by the same primary contract validator as tenant/admin surfaces, so that the system panel is governed by the same interaction contract instead of a parallel ad hoc check.
|
||||
|
||||
**Why this priority**: The repo already enrolled these system pages. The remaining risk is that the main validator still ignores them, leaving a split governance model.
|
||||
|
||||
**Independent Test**: Can be fully tested by proving that the primary validator discovers the enrolled system list pages and that no stale baseline exemption is required for those pages to pass.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an enrolled system-panel table page under `app/Filament/System/Pages`, **When** the primary discovery and validator pass run, **Then** the page is discovered and validated without relying on a special targeted assertion only.
|
||||
2. **Given** a system auth page, widget, or other out-of-scope surface, **When** the primary discovery pass runs, **Then** it remains out of scope unless a later spec explicitly enrolls it.
|
||||
3. **Given** an enrolled system page already has a declaration, **When** the primary validator runs after this feature, **Then** no new baseline exemption is needed to keep the repo green.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A surface uses same-page selected-record inspect rather than full-page detail navigation; the contract must treat this as a legitimate explicit-inspect pattern, not as a clickable-row violation.
|
||||
- A surface uses a primary linked column rather than full-row click because the row contains another dominant interaction model; the contract must allow this only when the declaration explains why row click is inappropriate.
|
||||
- A run-log or audit surface intentionally has no bulk actions because the records are immutable; the validator must continue to allow this only through explicit exemption, not silent omission.
|
||||
- System auth pages, dashboards, widgets, choosers, and deferred workspace-entry pages must not be accidentally swept into the new discovery scope.
|
||||
- Existing deliberate exemptions for `ChooseTenant`, `ChooseWorkspace`, `ManagedTenantsLanding`, `TenantDashboard`, and the onboarding wizard must remain explicit and must not be “fixed” by broad enforcement.
|
||||
- A surface may satisfy ordering rules declaratively but render contradictory runtime behavior; representative rendered-surface tests must catch this mismatch.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no domain writes, and no new long-running or scheduled work. It is a UI governance and guard-coverage feature only. Existing list, detail, queue, audit, and system pages remain the runtime surfaces; this feature only changes how their interaction contract is documented, declared, discovered, and verified.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one narrow contract extension and no new business persistence. The extension is a first-class code-level `surface_type` enum in `ActionSurfaceDeclaration`, aligned to the constitution surface taxonomy, because the current `ActionSurfaceProfile` cannot safely distinguish constitution-governed inspect models across CRUD, queue, audit, and registry surfaces. The implementation must remain the narrowest viable change to the existing contract stack and must not create a parallel registry, persistence layer, or new product-semantic framework.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Not applicable. No `OperationRun` lifecycle, summary-counts contract, notification behavior, or service-owned transition rule changes are introduced.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature does not change any authorization policy, capability matrix, or 404 vs 403 semantics. It must preserve current tenant/admin and platform-plane authorization behavior on all representative test surfaces and must not convert UI contract checks into a security mechanism. Positive and negative authorization behavior on representative clickable-row and explicit-inspect surfaces remains part of regression coverage.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||
|
||||
**Constitution alignment (BADGE-001):** This feature does not introduce or change any badge vocabulary. Existing badge semantics on governed surfaces remain centralized and unchanged.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** This feature continues to use Filament-native table actions, `recordUrl()`, `ActionGroup`, and `BulkActionGroup` as the governing primitives. It must not introduce page-local replacement UI or local status language. Any behavior-aware runtime guard must inspect or test existing Filament action structures rather than replacing them.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** This feature stabilizes interaction nouns rather than introducing new product nouns. The contract must preserve the distinction between `View` and `Inspect`, keep `More` as the standard secondary-action group label, and avoid inventing alternative labels such as `Actions` or `Open` for the same contract role unless a later naming spec explicitly changes the vocabulary.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** This feature is a direct enforcement slice for the operator-surface constitution. It must codify the inspect decision tree for the existing surface types, document where row click is required, allowed, or forbidden, define where secondary and destructive actions live, and preserve catalogued exceptions for deferred surface families. The resulting validator and guard tests must fail when a declaration claims conformance but the rendered behavior violates the constitution.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** This feature does not change the operator-first information hierarchy of covered pages, but it does enforce the interaction layer beneath that hierarchy. Queue and audit surfaces must preserve context through explicit inspect. List-first and registry surfaces must preserve one obvious open path. Dangerous actions must remain secondary and never compete with the primary inspect model.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature must not add a second semantic layer above the existing declaration stack. It extends the existing declaration and validator model rather than layering a new presenter-like framework on top. Tests must focus on business-visible behavior rules such as “one primary inspect model,” “explicit inspect on queue/audit,” “helper-first then workflow then destructive ordering,” and “system pages actually discovered,” not on implementation indirection for its own sake.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature materially changes the Action Surface Contract itself. The contract remains satisfied because no affected surface may expose more than one primary inspect model, redundant `View` actions remain forbidden where row click is canonical, empty `ActionGroup` and `BulkActionGroup` placeholders remain forbidden, and destructive actions remain secondary. Existing documented exemptions for deferred pages remain explicit. No new exemption type is introduced.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** No create, edit, or detail page layout is redesigned. Existing view and list layouts remain in place. This feature changes interaction contract rules and guard coverage only.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-169-001**: The system MUST codify a behavior-aware inspect decision tree for constitution surface types using the existing action-surface contract documentation and standards files.
|
||||
- **FR-169-002**: `ActionSurfaceDeclaration` MUST carry a first-class `surface_type` field backed by a dedicated enum aligned to the constitution surface taxonomy.
|
||||
- **FR-169-003**: The `surface_type` enum MUST explicitly distinguish at minimum these interaction classes for enforcement purposes: CRUD / List-first Resource, Read-only Registry / Report, Queue / Review, History / Audit, and Config-lite.
|
||||
- **FR-169-004**: Standard CRUD / List-first and Read-only Registry / Report surfaces MUST default to one-click open behavior, normally via clickable row, unless a documented exception justifies a primary linked column.
|
||||
- **FR-169-005**: Queue / Review and History / Audit surfaces MUST default to explicit Inspect or equivalent same-page selected-detail behavior and MUST NOT use row click as their primary inspect model unless a documented exception is approved.
|
||||
- **FR-169-006**: Config-lite remains the only governed surface class where edit-as-inspect is allowed by default.
|
||||
- **FR-169-007**: The validator and guard suite MUST fail when a surface that should be clickable-row renders a redundant lone `View` action or otherwise exposes more than one primary inspect model.
|
||||
- **FR-169-008**: The validator and guard suite MUST allow legitimate explicit-inspect surfaces that preserve context, including Audit Log and Finding Exceptions Queue, without forcing them into clickable-row behavior.
|
||||
- **FR-169-009**: The contract MUST define when `PrimaryLinkColumn` is allowed and MUST treat it as an exception path that requires a concrete reason row click is not the correct primary inspect model.
|
||||
- **FR-169-010**: The contract MUST define ordering rules for `ActionGroup` and `BulkActionGroup` content: navigation or inspection helpers first, non-destructive lifecycle or workflow actions next, destructive actions last.
|
||||
- **FR-169-011**: The guard suite MUST fail when governed `ActionGroup` or `BulkActionGroup` placeholders are empty.
|
||||
- **FR-169-012**: The primary discovery pass MUST include in-scope system-panel list and table pages located under `app/Filament/System/Pages` rather than relying solely on targeted test assertions.
|
||||
- **FR-169-013**: System auth pages, widgets, dashboards, choosers, and other intentionally deferred surface families MUST remain out of scope unless separately enrolled by a future spec.
|
||||
- **FR-169-014**: The implementation MUST preserve the existing no-stale-exemption rule for already enrolled system pages, relation managers, monitoring pages, canonical detail pages, and singleton diagnostic pages.
|
||||
- **FR-169-015**: The implementation MUST not add new baseline exemptions merely to satisfy the upgraded contract for already enrolled surface families.
|
||||
- **FR-169-016**: The contract MUST keep `More` as the canonical secondary-action group label across governed surfaces unless a later naming spec changes it globally.
|
||||
- **FR-169-017**: The implementation MUST update the primary developer-facing reference documents together so the behavior rules in documentation match the validator and the guard tests.
|
||||
- **FR-169-018**: The guard suite MUST include representative rendered-surface tests proving that a declaration cannot claim conformance while the actual Filament table behavior violates the declared inspect model.
|
||||
- **FR-169-019**: Representative rendered-surface coverage MUST include at least one clickable-row run-log surface, one explicit-inspect history/audit surface, one explicit-inspect queue/review surface, and one system-panel list surface discovered through the primary validator.
|
||||
- **FR-169-020**: Representative rendered-surface coverage MUST include at least one governed `More` menu whose ordering proves helper-first, workflow-next, and destructive-last behavior.
|
||||
- **FR-169-021**: The feature MUST not introduce any new domain capability, policy, migration, persisted artifact, or cross-request cache.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Monitoring Operations | `app/Filament/Pages/Monitoring/Operations.php` and `app/Filament/Resources/OperationRunResource.php` | Existing scope and return actions such as `Scope`, `Back`, and `Show all tenants` remain | `recordUrl()` full-row click to canonical run detail | None on the list | None by explicit exemption | Existing empty-state guidance to adjust filters remains | Existing run-detail header actions such as back, refresh, related links, and resumable actions remain | Not applicable | No new audit event | This is the clickable-row reference surface for the upgraded contract |
|
||||
| Monitoring Audit Log | `app/Filament/Pages/Monitoring/AuditLog.php` | Existing scope and return actions remain | Explicit `Inspect` action with same-page selected-event detail | `Inspect` only | None by explicit exemption | Existing clear-filters guidance remains | Existing selected-event related-navigation actions remain | Not applicable | No new audit event | This is the History / Audit reference surface for explicit inspect |
|
||||
| Finding Exceptions Queue | `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` | Existing scope and return actions remain | Explicit `Inspect` action with same-page selected-record detail | `Inspect` only | None by explicit exemption | Existing empty-state guidance and tenant-return navigation remain | Existing `Approve exception`, `Reject exception`, and related record navigation remain | Not applicable | Yes, existing decision audit remains unchanged | This is the Queue / Review reference surface for explicit inspect |
|
||||
| Reporting and evidence registers | `app/Filament/Pages/Reviews/ReviewRegister.php` and `app/Filament/Pages/Monitoring/EvidenceOverview.php` | Existing filter-reset or scope-reset helpers remain | Existing clickable-row open to review or evidence detail remains | None on the list | None by explicit exemption | Existing page-specific clear-filters guidance remains | Existing review or evidence detail actions remain on the destination surface | Not applicable | No new audit event | Representative registry surfaces enrolled by this spec so non-audit scan-first lists are covered |
|
||||
| System operations lists | `app/Filament/System/Pages/Ops/Runs.php`, `Failures.php`, `Stuck.php` | Existing system-page header actions remain | Existing clickable-row open to system run detail remains | None on the list unless already justified by the page | Existing immutable-list bulk behavior or explicit exemption remains | Existing page-specific empty-state behavior remains | Existing run-detail actions remain | Not applicable | No new audit event | Read-only registry reference surfaces using the Cross-panel Canonical Route Exception while preserving the canonical Operations noun |
|
||||
| System directory lists | `app/Filament/System/Pages/Directory/Tenants.php` and `Workspaces.php` | Existing system-page header actions remain | Existing clickable-row open to system detail remains | None on the list unless already justified by the page | Existing bulk behavior or explicit exemption remains | Existing page-specific empty-state behavior remains | Existing detail-page actions remain | Not applicable | No new audit event | Read-only registry reference surfaces for cross-panel guard coverage |
|
||||
| System access logs | `app/Filament/System/Pages/Security/AccessLogs.php` | Existing system-page header actions remain | Existing explicit inspect or context-preserving detail open remains | Existing inspect action only | Existing immutable-list bulk behavior or explicit exemption remains | Existing page-specific empty-state behavior remains | Existing detail actions remain | Not applicable | No new audit event | History / Audit reference surface in the platform plane |
|
||||
| Representative read-only registry resources | `app/Filament/Resources/AlertDeliveryResource.php`, `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/ReviewPackResource.php`, and `app/Filament/Resources/TenantReviewResource.php` | Existing resource header actions remain unchanged | Existing row-click open remains canonical; no redundant row-level `View` action is allowed | At most one justified inline safe shortcut on resources that already expose one; otherwise none | Existing detail-header lifecycle actions remain preferred; row-level destructive actions remain secondary only where already justified | Existing page-specific empty-state behavior remains | Existing view-header actions remain | Not applicable | No new audit event | Representative read-only registry resource family for v1.1 declaration rollout |
|
||||
| Representative CRUD list-first resources (`Tenants`, `Policies`, `Baseline profiles`, `Backup schedules`, `Workspaces`) | `app/Filament/Resources/TenantResource.php`, `app/Filament/Resources/PolicyResource.php`, `app/Filament/Resources/BaselineProfileResource.php`, `app/Filament/Resources/BackupScheduleResource.php`, and `app/Filament/Resources/Workspaces/WorkspaceResource.php` | Existing scope and sync or lifecycle header actions remain | Existing row-click open remains canonical | One justified inline safe shortcut plus `More` | Existing bulk actions remain grouped under `More`; destructive actions stay under `More` or the detail header | Existing empty-state CTA behavior remains | Existing view or edit header actions remain | Not applicable | No new audit event | This is the representative CRUD family for overflow ordering and inline-budget enforcement |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **ActionSurfaceDeclaration v1.1**: The existing declaration object extended with the minimum rule data needed to enforce inspect-model and ordering behavior.
|
||||
- **Surface Type Enum**: A first-class enum on `ActionSurfaceDeclaration` aligned to the constitution surface taxonomy and used as the enforcement source for inspect-model rules.
|
||||
- **Governed Surface Type**: The constitution-aligned interaction class used to decide whether a surface should be clickable-row, explicit-inspect, or edit-as-inspect.
|
||||
- **Inspect Decision Rule**: The rule set that maps governed surface type to the allowed primary inspect model.
|
||||
- **Action Ordering Rule**: The rule set that governs the order and legality of entries inside `ActionGroup` and `BulkActionGroup` on covered surfaces.
|
||||
- **Primary Discovery Scope**: The set of Filament Resources, Pages, RelationManagers, and system-panel pages included in the repository-wide action-surface validation pass.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-169-001**: 100% of the enrolled system-panel list pages covered by existing targeted tests are discovered by the primary validator after this feature ships.
|
||||
- **SC-169-002**: Representative guard coverage exists for all four critical rule families: clickable-row enforcement, explicit-inspect enforcement, ordered overflow behavior, and out-of-scope exemption preservation.
|
||||
- **SC-169-003**: A deliberately broken inspect-model or ordering implementation fails with an actionable message naming the violating class and the violated rule.
|
||||
- **SC-169-004**: Existing reference surfaces continue to pass under the upgraded rules: clickable-row `OperationRunResource` / Monitoring Operations, explicit-inspect Audit Log, explicit-inspect Finding Exceptions Queue, and at least one system-panel list surface.
|
||||
- **SC-169-005**: No new baseline exemption is added for already enrolled system pages or relation managers in order to make the upgraded contract pass.
|
||||
- **SC-169-006**: The feature ships without introducing any new business persistence, any new capability family, or any product-facing route or workflow change.
|
||||
198
specs/169-action-surface-v11/tasks.md
Normal file
198
specs/169-action-surface-v11/tasks.md
Normal file
@ -0,0 +1,198 @@
|
||||
# Tasks: Action Surface Contract v1.1
|
||||
|
||||
**Input**: Design documents from `/specs/169-action-surface-v11/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/action-surface-governance.logical.openapi.yaml, quickstart.md
|
||||
|
||||
**Tests**: Required. This feature changes runtime behavior and repository guards, so Pest and Livewire coverage must be added and run.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently once the blocking foundation work is complete.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Add the minimum shared contract scaffolding that every later story depends on.
|
||||
|
||||
- [X] T001 Create the first-class `ActionSurfaceType` enum in `app/Support/Ui/ActionSurface/Enums/ActionSurfaceType.php`
|
||||
- [X] T002 Extend `app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php` to require `surfaceType` and store `PrimaryLinkColumn` reason metadata alongside the existing profile, slots, exemptions, and defaults
|
||||
- [X] T003 [P] Update shared action-surface helper seams in `app/Support/Ui/ActionSurface/Enums/ActionSurfaceInspectAffordance.php` and `app/Support/Ui/ActionSurface/ActionSurfaceProfileDefinition.php` so the new surface-type contract can be referenced consistently
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Roll the new declaration field across the enrolled reference surfaces before strict validator enforcement begins.
|
||||
|
||||
**⚠️ CRITICAL**: No story-specific enforcement work should begin until every enrolled reference surface can compile with the new `surfaceType` contract.
|
||||
|
||||
- [X] T004 [P] Add explicit `surfaceType` declarations to page-backed monitoring and reporting references in `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Pages/Monitoring/AuditLog.php`, `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `app/Filament/Pages/Monitoring/EvidenceOverview.php`, and `app/Filament/Pages/Reviews/ReviewRegister.php`
|
||||
- [X] T005 [P] Add explicit `surfaceType` declarations to representative CRUD resources in `app/Filament/Resources/BackupScheduleResource.php`, `app/Filament/Resources/BaselineProfileResource.php`, `app/Filament/Resources/PolicyResource.php`, `app/Filament/Resources/TenantResource.php`, and `app/Filament/Resources/Workspaces/WorkspaceResource.php`
|
||||
- [X] T006 [P] Add explicit `surfaceType` declarations to representative read-only registry resources in `app/Filament/Resources/OperationRunResource.php`, `app/Filament/Resources/AlertDeliveryResource.php`, `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/ReviewPackResource.php`, and `app/Filament/Resources/TenantReviewResource.php`
|
||||
- [X] T007 [P] Add explicit `surfaceType` declarations to the enrolled system list pages in `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, `app/Filament/System/Pages/Directory/Tenants.php`, `app/Filament/System/Pages/Directory/Workspaces.php`, and `app/Filament/System/Pages/Security/AccessLogs.php`
|
||||
|
||||
**Checkpoint**: The enrolled reference pack is migrated to the v1.1 contract and story-specific guard work can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Enforce the Correct Inspect Model (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make the validator and representative guard surfaces fail when clickable-row and explicit-inspect semantics drift from the constitution.
|
||||
|
||||
**Independent Test**: Prove one clickable-row reference surface and one explicit-inspect reference surface pass, then prove the guard fails when their inspect models are swapped or duplicated.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T008 [P] [US1] Extend `tests/Feature/Guards/ActionSurfaceValidatorTest.php` with failing cases for missing `surfaceType`, incompatible inspect-affordance pairings, and missing `PrimaryLinkColumn` reason text
|
||||
- [X] T009 [US1] Extend `tests/Feature/Guards/ActionSurfaceContractTest.php` with failing rendered-behavior checks for clickable-row references and explicit-inspect references using `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Resources/OperationRunResource.php`, `app/Filament/Pages/Monitoring/AuditLog.php`, `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, and `app/Filament/Pages/Monitoring/EvidenceOverview.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T010 [US1] Implement surface-type inspect compatibility and actionable validation messages in `app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||
- [X] T011 [US1] Align the inspect-model reference declarations in `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/Resources/OperationRunResource.php`, `app/Filament/Pages/Monitoring/AuditLog.php`, `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, `app/Filament/Pages/Reviews/ReviewRegister.php`, and `app/Filament/Pages/Monitoring/EvidenceOverview.php` with the constitution decision tree and explicit exception metadata
|
||||
- [X] T012 [US1] Update inspect-model guidance in `docs/ui/action-surface-contract.md` and `docs/product/standards/filament-actions-ux.md` to codify clickable-row defaults, explicit-inspect requirements, reporting-registry coverage, and `PrimaryLinkColumn` exception rules
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when inspect-model drift fails in both validator stubs and representative rendered guards while the enrolled reference surfaces continue to pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Enforce Stable More-Menu Ordering (Priority: P1)
|
||||
|
||||
**Goal**: Make representative governed lists prove helper-first, workflow-next, destructive-last ordering and prevent empty overflow groups from surviving as placeholders.
|
||||
|
||||
**Independent Test**: Assert the ordered `More` and `BulkActionGroup` shape on representative CRUD surfaces and fail the guard when helpers do not lead, workflow actions trail destructive ones, or groups become empty placeholders.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T013 [US2] Extend `tests/Feature/Guards/ActionSurfaceContractTest.php` with failing helper-first, workflow-next, destructive-last, and empty-group assertions for representative `More` and `BulkActionGroup` surfaces
|
||||
- [X] T014 [P] [US2] Extend `tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php` with RBAC-aware overflow ordering assertions for tenant list surfaces
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T015 [P] [US2] Reorder secondary and destructive actions in `app/Filament/Resources/BackupScheduleResource.php` and `app/Filament/Resources/BaselineProfileResource.php` so inspection helpers lead, workflow actions follow, destructive actions stay last, and placeholder groups cannot render
|
||||
- [X] T016 [P] [US2] Align inline safe shortcut budgets and `More` menu placement in `app/Filament/Resources/TenantResource.php`, `app/Filament/Resources/PolicyResource.php`, and `app/Filament/Resources/Workspaces/WorkspaceResource.php`
|
||||
- [X] T017 [US2] Update helper-first, workflow-next, destructive-last, and placeholder-group guidance in `docs/product/standards/filament-actions-ux.md` and `docs/ui/action-surface-contract.md`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when representative CRUD and RBAC-aware list surfaces render stable overflow ordering with helpers first, workflow actions next, destructive actions last, and no empty groups.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Bring System Lists Under Primary Guard Coverage (Priority: P2)
|
||||
|
||||
**Goal**: Move the enrolled system-panel list pages from targeted-only assertions into the main repository-wide discovery and validator pass.
|
||||
|
||||
**Independent Test**: Prove the primary validator discovers the six enrolled system list pages and still excludes auth, dashboard, widget, chooser, and deferred system tooling surfaces.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T018 [US3] Extend `tests/Feature/Guards/ActionSurfaceContractTest.php` with failing discovery assertions for the six enrolled system list pages and explicit exclusion assertions for `app/Filament/System/Pages/Ops/Runbooks.php` and `app/Filament/System/Pages/RepairWorkspaceOwners.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T019 [US3] Implement narrow system table-page discovery in `app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php` for declared `app/Filament/System/Pages/**` table pages only
|
||||
- [X] T020 [US3] Update baseline exemption handling in `app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php` so enrolled system pages are no longer treated like deferred exemptions and deferred families remain explicit
|
||||
- [X] T021 [P] [US3] Tune the enrolled system reference declarations in `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, `app/Filament/System/Pages/Directory/Tenants.php`, `app/Filament/System/Pages/Directory/Workspaces.php`, and `app/Filament/System/Pages/Security/AccessLogs.php` for the new discovery path, the repaired `ReadOnlyRegistryReport` classification for system ops lists, and canonical `Operations / Run` naming
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when the primary validator discovers the enrolled system list pages without stale baseline exemptions and still excludes deferred system surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Run the focused verification and formatting steps that close the implementation loop.
|
||||
|
||||
- [X] T022 Run `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [X] T023 Run the focused verification pack from `specs/169-action-surface-v11/quickstart.md` against `tests/Feature/Guards/ActionSurfaceValidatorTest.php`, `tests/Feature/Guards/ActionSurfaceContractTest.php`, and `tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: Starts immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories.
|
||||
- **User Story 1 (Phase 3)**: Starts after Phase 2.
|
||||
- **User Story 2 (Phase 4)**: Starts after Phase 2 and can proceed independently of US1 at the feature level, though both stories touch shared docs and guard files.
|
||||
- **User Story 3 (Phase 5)**: Starts after Phase 2 and can proceed independently of US1 and US2 at the feature level, though it shares `tests/Feature/Guards/ActionSurfaceContractTest.php`.
|
||||
- **Polish (Phase 6)**: Starts after all desired user stories are complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Depends on Setup and Foundational only.
|
||||
- **US2 (P1)**: Depends on Setup and Foundational only.
|
||||
- **US3 (P2)**: Depends on Setup and Foundational only.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Story tests are written or extended before story implementation tasks.
|
||||
- Shared validator or discovery code changes come before story-level declaration tuning.
|
||||
- Reference surfaces are aligned before the focused verification pack runs.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T003 can run in parallel with T001 or T002 once the enum shape is settled.
|
||||
- T004 through T007 can run in parallel because they touch different declaration families.
|
||||
- In US1, T008 can run in parallel with declaration tuning preparation because it targets a separate test file.
|
||||
- In US2, T014, T015, and T016 can run in parallel because they target different files.
|
||||
- In US3, T021 can run in parallel with T019 or T020 once the discovery rule is agreed.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch the validator stub work and rendered guard extension separately:
|
||||
Task: "Extend tests/Feature/Guards/ActionSurfaceValidatorTest.php with failing cases for missing surfaceType and invalid inspect-affordance pairings"
|
||||
Task: "Extend tests/Feature/Guards/ActionSurfaceContractTest.php with failing rendered-behavior checks for Monitoring Operations, OperationRunResource, AuditLog, FindingExceptionsQueue, ReviewRegister, and EvidenceOverview"
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Split ordering work across resource families:
|
||||
Task: "Reorder secondary and destructive actions in app/Filament/Resources/BackupScheduleResource.php and app/Filament/Resources/BaselineProfileResource.php so helpers lead, workflow actions follow, and destructive actions stay last"
|
||||
Task: "Align inline safe shortcut budgets and More menu placement in app/Filament/Resources/TenantResource.php, app/Filament/Resources/PolicyResource.php, and app/Filament/Resources/Workspaces/WorkspaceResource.php"
|
||||
Task: "Extend tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php with RBAC-aware overflow ordering assertions"
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Let discovery and system declaration tuning proceed side by side:
|
||||
Task: "Implement narrow system table-page discovery in app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php"
|
||||
Task: "Tune the enrolled system reference declarations in app/Filament/System/Pages/Ops/Runs.php, app/Filament/System/Pages/Ops/Failures.php, app/Filament/System/Pages/Ops/Stuck.php, app/Filament/System/Pages/Directory/Tenants.php, app/Filament/System/Pages/Directory/Workspaces.php, and app/Filament/System/Pages/Security/AccessLogs.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. Validate the focused inspect-model guard behavior before starting additional stories.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Finish Setup + Foundational to put the enrolled reference pack on the v1.1 declaration contract.
|
||||
2. Deliver US1 to make inspect-model drift fail decisively.
|
||||
3. Deliver US2 to stabilize overflow ordering across representative CRUD surfaces.
|
||||
4. Deliver US3 to bring system lists into the main validator scope.
|
||||
5. Run the focused quickstart verification and then decide whether to run the full suite.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor handles Phase 1 and the shared declaration contract updates.
|
||||
2. After Phase 2, separate contributors can take:
|
||||
- US1 validator and monitoring reference surfaces
|
||||
- US2 CRUD ordering surfaces and RBAC-aware overflow tests
|
||||
- US3 system discovery and system reference surfaces
|
||||
3. Rejoin for Phase 6 formatting and focused verification.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks touch separate files and can be executed in parallel after their dependencies are satisfied.
|
||||
- The main shared hot spots are `tests/Feature/Guards/ActionSurfaceContractTest.php`, `docs/ui/action-surface-contract.md`, and `docs/product/standards/filament-actions-ux.md`; avoid parallel edits there.
|
||||
- This feature does not add `OperationRun`, assets, routes, persistence, or capability work, so no extra Ops-UX or deployment tasks are required.
|
||||
52
tests/Browser/TenantMembershipsPageTest.php
Normal file
52
tests/Browser/TenantMembershipsPageTest.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
pest()->browser()->timeout(15_000);
|
||||
|
||||
it('renders tenant memberships only on the dedicated memberships page after scroll hydration', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$member = User::factory()->create([
|
||||
'email' => 'browser-tenant-member@example.test',
|
||||
]);
|
||||
$member->tenants()->syncWithoutDetaching([
|
||||
$tenant->getKey() => ['role' => 'readonly'],
|
||||
]);
|
||||
|
||||
$this->actingAs($owner)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$viewPage = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));
|
||||
|
||||
$viewPage
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee((string) $tenant->name)
|
||||
->assertScript("document.body.innerText.includes('Add member')", false)
|
||||
->assertScript("document.body.innerText.includes('browser-tenant-member@example.test')", false);
|
||||
|
||||
$membershipsPage = visit(TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin'));
|
||||
|
||||
$membershipsPage
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee('Tenant memberships');
|
||||
|
||||
$membershipsPage->script(<<<'JS'
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
JS);
|
||||
|
||||
$membershipsPage
|
||||
->waitForText('Add member')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee('Memberships')
|
||||
->assertSee('Add member')
|
||||
->assertSee('browser-tenant-member@example.test')
|
||||
->assertSee('Change role')
|
||||
->assertSee('Remove');
|
||||
});
|
||||
@ -74,6 +74,47 @@ function getBackupScheduleEmptyStateAction(Testable $component, string $name): ?
|
||||
})->toThrow(AuthorizationException::class);
|
||||
});
|
||||
|
||||
it('requires confirmation for destructive backup schedule lifecycle actions', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$activeSchedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Lifecycle confirmation active',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$archivedSchedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Lifecycle confirmation archived',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '02:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
$archivedSchedule->delete();
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->assertTableActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired(), $activeSchedule);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->filterTable(TrashedFilter::class, false)
|
||||
->assertTableActionExists('forceDelete', fn (Action $action): bool => $action->isConfirmationRequired(), BackupSchedule::withTrashed()->findOrFail($archivedSchedule->id));
|
||||
});
|
||||
|
||||
it('disables backup schedule create in the empty state for members without manage capability', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
|
||||
@ -76,6 +76,8 @@ function makeBackupScheduleForTenant(\App\Models\Tenant $tenant, string $name):
|
||||
'pageClass' => EditBackupSchedule::class,
|
||||
]);
|
||||
|
||||
expect(fn () => $component->instance()->mountTableAction('view', (string) $foreignRun->getKey()))
|
||||
$table = $component->instance()->getTable();
|
||||
|
||||
expect(fn () => $table->getRecordUrl($foreignRun))
|
||||
->toThrow(NotFoundHttpException::class);
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
|
||||
@ -335,3 +336,119 @@
|
||||
expect($stats->findingsCount)->toBe(1)
|
||||
->and($stats->severityCounts['high'])->toBe(1);
|
||||
});
|
||||
|
||||
it('returns governance attention counts from current findings truth', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'baseline_compare',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => [
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Finding::factory()->triaged()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$expiringFinding = Finding::factory()->riskAccepted()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $expiringFinding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'approved_by_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_EXPIRING,
|
||||
'current_validity_state' => FindingException::VALIDITY_EXPIRING,
|
||||
'request_reason' => 'Expiring governance coverage',
|
||||
'approval_reason' => 'Approved for coverage',
|
||||
'requested_at' => now()->subDays(2),
|
||||
'approved_at' => now()->subDay(),
|
||||
'effective_from' => now()->subDay(),
|
||||
'expires_at' => now()->addDays(2),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$lapsedFinding = Finding::factory()->riskAccepted()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $lapsedFinding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'approved_by_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_EXPIRED,
|
||||
'current_validity_state' => FindingException::VALIDITY_EXPIRED,
|
||||
'request_reason' => 'Expired governance coverage',
|
||||
'approval_reason' => 'Approved for coverage',
|
||||
'requested_at' => now()->subDays(3),
|
||||
'approved_at' => now()->subDays(2),
|
||||
'effective_from' => now()->subDays(2),
|
||||
'expires_at' => now()->subDay(),
|
||||
'review_due_at' => now()->subDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
Finding::factory()->inProgress()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$stats = BaselineCompareStats::forTenant($tenant);
|
||||
|
||||
expect($stats->overdueOpenFindingsCount)->toBe(1)
|
||||
->and($stats->expiringGovernanceCount)->toBe(1)
|
||||
->and($stats->lapsedGovernanceCount)->toBe(1)
|
||||
->and($stats->activeNonNewFindingsCount)->toBe(2)
|
||||
->and($stats->highSeverityActiveFindingsCount)->toBe(1);
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
@ -246,3 +247,84 @@ function createAssignedBaselineTenant(): array
|
||||
->and($assessment->headline)->toContain('Accepted-risk governance has lapsed')
|
||||
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS);
|
||||
});
|
||||
|
||||
it('maps unavailable compare prerequisites to baseline prerequisite guidance', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'active_snapshot_id' => null,
|
||||
]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
|
||||
|
||||
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_UNAVAILABLE)
|
||||
->and($assessment->headline)->toBe('The current baseline snapshot is not available for compare.')
|
||||
->and($assessment->nextActionLabel())->toBe('Review baseline prerequisites')
|
||||
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING);
|
||||
});
|
||||
|
||||
it('treats expiring governance as action required even with zero compare findings', function (): void {
|
||||
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
|
||||
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => [
|
||||
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
]);
|
||||
|
||||
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(),
|
||||
'approved_by_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_EXPIRING,
|
||||
'current_validity_state' => FindingException::VALIDITY_EXPIRING,
|
||||
'request_reason' => 'Expiring governance coverage',
|
||||
'approval_reason' => 'Approved for coverage',
|
||||
'requested_at' => now()->subDays(2),
|
||||
'approved_at' => now()->subDay(),
|
||||
'effective_from' => now()->subDay(),
|
||||
'expires_at' => now()->addDays(2),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
|
||||
|
||||
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||
->and($assessment->expiringGovernanceCount)->toBe(1)
|
||||
->and($assessment->headline)->toContain('Accepted-risk governance is nearing expiry')
|
||||
->and($assessment->nextActionLabel())->toBe('Open findings');
|
||||
});
|
||||
|
||||
@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineCompareSummaryAssessment;
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
function createTenantGovernanceAggregateTenant(): array
|
||||
{
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Aggregate Baseline',
|
||||
]);
|
||||
|
||||
$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(),
|
||||
]);
|
||||
|
||||
return [$user, $tenant, $profile, $snapshot];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @param array<string, mixed> $compareContext
|
||||
*/
|
||||
function seedTenantGovernanceAggregateRun(Tenant $tenant, BaselineProfile $profile, BaselineSnapshot $snapshot, array $attributes = [], array $compareContext = []): OperationRun
|
||||
{
|
||||
return OperationRun::factory()->create(array_replace_recursive([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => array_replace_recursive([
|
||||
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
], $compareContext),
|
||||
],
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
function createTenantGovernanceException(Tenant $tenant, Finding $finding, User $user, string $status, string $validityState, ?\Carbon\CarbonInterface $expiresAt = null): void
|
||||
{
|
||||
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(),
|
||||
'approved_by_user_id' => (int) $user->getKey(),
|
||||
'status' => $status,
|
||||
'current_validity_state' => $validityState,
|
||||
'request_reason' => 'Exception created for tenant governance aggregate coverage',
|
||||
'approval_reason' => 'Approved for test coverage',
|
||||
'requested_at' => now()->subDays(2),
|
||||
'approved_at' => now()->subDay(),
|
||||
'effective_from' => now()->subDay(),
|
||||
'expires_at' => $expiresAt,
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
}
|
||||
|
||||
it('resolves an unavailable governance aggregate when the assigned baseline has no snapshot', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'active_snapshot_id' => null,
|
||||
]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant);
|
||||
|
||||
expect($aggregate)->not->toBeNull()
|
||||
->and($aggregate?->compareState)->toBe('no_snapshot')
|
||||
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_UNAVAILABLE)
|
||||
->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING)
|
||||
->and($aggregate?->headline)->toBe('The current baseline snapshot is not available for compare.');
|
||||
});
|
||||
|
||||
it('resolves an in-progress governance aggregate for queued compare runs', function (): void {
|
||||
[, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant();
|
||||
|
||||
seedTenantGovernanceAggregateRun(
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
snapshot: $snapshot,
|
||||
attributes: [
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'completed_at' => null,
|
||||
],
|
||||
);
|
||||
|
||||
$aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant);
|
||||
|
||||
expect($aggregate)->not->toBeNull()
|
||||
->and($aggregate?->compareState)->toBe('comparing')
|
||||
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_IN_PROGRESS)
|
||||
->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN)
|
||||
->and($aggregate?->headline)->toBe('Baseline compare is in progress.');
|
||||
});
|
||||
|
||||
it('resolves a failed governance aggregate with run follow-up', function (): void {
|
||||
[, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant();
|
||||
|
||||
seedTenantGovernanceAggregateRun(
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
snapshot: $snapshot,
|
||||
attributes: [
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'failure_summary' => ['message' => 'Graph API timeout'],
|
||||
],
|
||||
);
|
||||
|
||||
$aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant);
|
||||
|
||||
expect($aggregate)->not->toBeNull()
|
||||
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||
->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN)
|
||||
->and($aggregate?->nextActionLabel)->toBe('Review the failed run')
|
||||
->and($aggregate?->headline)->toBe('The latest baseline compare failed before it produced a usable result.');
|
||||
});
|
||||
|
||||
it('resolves an action-required aggregate when open drift findings remain', function (): void {
|
||||
[, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant();
|
||||
|
||||
seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot);
|
||||
|
||||
Finding::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'source' => 'baseline.compare',
|
||||
'scope_key' => 'baseline_profile:'.$profile->getKey(),
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant);
|
||||
|
||||
expect($aggregate)->not->toBeNull()
|
||||
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||
->and($aggregate?->visibleDriftFindingsCount)->toBe(1)
|
||||
->and($aggregate?->highSeverityActiveFindingsCount)->toBe(1)
|
||||
->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS);
|
||||
});
|
||||
|
||||
it('resolves overdue workflow pressure as action required even with zero visible drift', function (): void {
|
||||
[$user, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant();
|
||||
|
||||
seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot);
|
||||
|
||||
Finding::factory()->triaged()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant);
|
||||
|
||||
expect($aggregate)->not->toBeNull()
|
||||
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||
->and($aggregate?->overdueOpenFindingsCount)->toBe(1)
|
||||
->and($aggregate?->visibleDriftFindingsCount)->toBe(0)
|
||||
->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS)
|
||||
->and($aggregate?->headline)->toContain('overdue finding');
|
||||
});
|
||||
|
||||
it('resolves lapsed governance as action required even with zero visible drift', function (): void {
|
||||
[$user, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant();
|
||||
|
||||
seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot);
|
||||
|
||||
$finding = Finding::factory()->riskAccepted()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
createTenantGovernanceException(
|
||||
tenant: $tenant,
|
||||
finding: $finding,
|
||||
user: $user,
|
||||
status: FindingException::STATUS_EXPIRED,
|
||||
validityState: FindingException::VALIDITY_EXPIRED,
|
||||
expiresAt: now()->subDay(),
|
||||
);
|
||||
|
||||
$aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant);
|
||||
|
||||
expect($aggregate)->not->toBeNull()
|
||||
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||
->and($aggregate?->lapsedGovernanceCount)->toBe(1)
|
||||
->and($aggregate?->visibleDriftFindingsCount)->toBe(0)
|
||||
->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS)
|
||||
->and($aggregate?->headline)->toContain('Accepted-risk governance has lapsed');
|
||||
});
|
||||
|
||||
it('resolves expiring governance into the shared action-required contract', function (): void {
|
||||
[$user, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant();
|
||||
|
||||
seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot);
|
||||
|
||||
$finding = Finding::factory()->riskAccepted()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
createTenantGovernanceException(
|
||||
tenant: $tenant,
|
||||
finding: $finding,
|
||||
user: $user,
|
||||
status: FindingException::STATUS_EXPIRING,
|
||||
validityState: FindingException::VALIDITY_EXPIRING,
|
||||
expiresAt: now()->addDays(2),
|
||||
);
|
||||
|
||||
$aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant);
|
||||
|
||||
expect($aggregate)->not->toBeNull()
|
||||
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||
->and($aggregate?->expiringGovernanceCount)->toBe(1)
|
||||
->and($aggregate?->nextActionLabel)->toBe('Open findings');
|
||||
});
|
||||
|
||||
it('resolves limited-confidence zero findings into a caution aggregate', function (): void {
|
||||
[, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant();
|
||||
|
||||
seedTenantGovernanceAggregateRun(
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
snapshot: $snapshot,
|
||||
attributes: [
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
],
|
||||
compareContext: [
|
||||
'reason_code' => BaselineCompareReasonCode::CoverageUnproven->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration', 'deviceCompliancePolicy'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => ['deviceCompliancePolicy'],
|
||||
'proof' => false,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant);
|
||||
|
||||
expect($aggregate)->not->toBeNull()
|
||||
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_CAUTION)
|
||||
->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_RUN)
|
||||
->and($aggregate?->headline)->toBe('The last compare finished, but normal result output was suppressed.');
|
||||
});
|
||||
|
||||
it('resolves stale no-drift compare results into a stale aggregate', function (): void {
|
||||
[, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant();
|
||||
|
||||
seedTenantGovernanceAggregateRun(
|
||||
tenant: $tenant,
|
||||
profile: $profile,
|
||||
snapshot: $snapshot,
|
||||
attributes: [
|
||||
'completed_at' => now()->subDays(10),
|
||||
],
|
||||
);
|
||||
|
||||
$aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant);
|
||||
|
||||
expect($aggregate)->not->toBeNull()
|
||||
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_STALE)
|
||||
->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_LANDING)
|
||||
->and($aggregate?->headline)->toBe('The latest baseline compare result is stale.');
|
||||
});
|
||||
|
||||
it('resolves trustworthy no-drift results into a positive all-clear aggregate', function (): void {
|
||||
[, $tenant, $profile, $snapshot] = createTenantGovernanceAggregateTenant();
|
||||
|
||||
seedTenantGovernanceAggregateRun($tenant, $profile, $snapshot);
|
||||
|
||||
$aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant($tenant);
|
||||
|
||||
expect($aggregate)->not->toBeNull()
|
||||
->and($aggregate?->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_POSITIVE)
|
||||
->and($aggregate?->positiveClaimAllowed)->toBeTrue()
|
||||
->and($aggregate?->nextActionTarget)->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_NONE)
|
||||
->and($aggregate?->headline)->toBe('No confirmed drift in the latest baseline compare.');
|
||||
});
|
||||
@ -364,7 +364,17 @@ function seedEvidenceDomain(Tenant $tenant): void
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($primaryRowActionNames)->toBe(['view_snapshot', 'expire'])
|
||||
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
||||
$moreActionNames = collect($moreGroup?->getActions())
|
||||
->map(static fn ($action): ?string => $action->getName())
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($primaryRowActionNames)->toBe([])
|
||||
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
||||
->and($moreGroup?->getLabel())->toBe('More')
|
||||
->and($moreActionNames)->toEqualCanonicalizing(['expire'])
|
||||
->and($table->getBulkActions())->toBeEmpty()
|
||||
->and($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot]));
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -128,3 +129,43 @@ function createCoverageBannerTenant(): array
|
||||
->assertDontSee('No confirmed drift in the latest baseline compare.')
|
||||
->assertDontSee('Review compare detail');
|
||||
});
|
||||
|
||||
it('shows an action banner when overdue findings remain without new drift', function (): void {
|
||||
[$user, $tenant, $profile, $snapshot] = createCoverageBannerTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => [
|
||||
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Finding::factory()->triaged()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(BaselineCompareCoverageBanner::class)
|
||||
->assertSee('overdue finding')
|
||||
->assertSee('Open findings');
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -16,6 +17,30 @@
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function createBaselineCompareSummaryConsistencyTenant(): array
|
||||
{
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
return [$user, $tenant, $profile, $snapshot];
|
||||
}
|
||||
|
||||
it('keeps widget, landing, and banner equally cautious for the same limited-confidence compare result', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
@ -77,3 +102,87 @@
|
||||
->assertSee('The last compare finished, but normal result output was suppressed.')
|
||||
->assertSee('Review compare detail');
|
||||
});
|
||||
|
||||
it('keeps widget, landing, and banner aligned when overdue workflow remains without new drift', function (): void {
|
||||
[$user, $tenant, $profile, $snapshot] = createBaselineCompareSummaryConsistencyTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => [
|
||||
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Finding::factory()->triaged()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'due_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(BaselineCompareNow::class)
|
||||
->assertSee('Action required')
|
||||
->assertSee('overdue finding')
|
||||
->assertSee('Open findings');
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee('Action required')
|
||||
->assertSee('overdue finding')
|
||||
->assertSee('Open findings');
|
||||
|
||||
Livewire::test(BaselineCompareCoverageBanner::class)
|
||||
->assertSee('overdue finding')
|
||||
->assertSee('Open findings');
|
||||
});
|
||||
|
||||
it('keeps widget, landing, and banner aligned while compare is still running', function (): void {
|
||||
[$user, $tenant, $profile, $snapshot] = createBaselineCompareSummaryConsistencyTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(BaselineCompareNow::class)
|
||||
->assertSee('In progress')
|
||||
->assertSee('Baseline compare is in progress.')
|
||||
->assertSee('View run');
|
||||
|
||||
Livewire::test(BaselineCompareLanding::class)
|
||||
->assertSee('In progress')
|
||||
->assertSee('Baseline compare is in progress.')
|
||||
->assertSee('View run');
|
||||
|
||||
Livewire::test(BaselineCompareCoverageBanner::class)
|
||||
->assertSee('Baseline compare is in progress.')
|
||||
->assertSee('View run');
|
||||
});
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -193,3 +194,64 @@ function createNeedsAttentionTenant(): array
|
||||
->assertSee('Lapsed accepted-risk governance')
|
||||
->assertDontSee('Current dashboard signals look trustworthy.');
|
||||
});
|
||||
|
||||
it('surfaces expiring governance from the shared aggregate without adding navigation links', function (): void {
|
||||
[$user, $tenant, $profile, $snapshot] = createNeedsAttentionTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => [
|
||||
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->riskAccepted()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
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(),
|
||||
'approved_by_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_EXPIRING,
|
||||
'current_validity_state' => FindingException::VALIDITY_EXPIRING,
|
||||
'request_reason' => 'Expiring governance coverage',
|
||||
'approval_reason' => 'Approved for coverage',
|
||||
'requested_at' => now()->subDays(2),
|
||||
'approved_at' => now()->subDay(),
|
||||
'effective_from' => now()->subDay(),
|
||||
'expires_at' => now()->addDays(2),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(NeedsAttention::class)
|
||||
->assertSee('Expiring accepted-risk governance')
|
||||
->assertSee('Open findings')
|
||||
->assertDontSee('Current dashboard signals look trustworthy.');
|
||||
|
||||
expect($component->html())->not->toContain('href=');
|
||||
});
|
||||
|
||||
@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
|
||||
use App\Filament\Widgets\Dashboard\NeedsAttention;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\TenantGovernanceAggregateResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function createTenantGovernanceMemoizationTenant(): array
|
||||
{
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||
|
||||
BaselineTenantAssignment::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
]);
|
||||
|
||||
\App\Models\OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => OperationRunType::BaselineCompare->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now(),
|
||||
'context' => [
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'baseline_compare' => [
|
||||
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
|
||||
'coverage' => [
|
||||
'effective_types' => ['deviceConfiguration'],
|
||||
'covered_types' => ['deviceConfiguration'],
|
||||
'uncovered_types' => [],
|
||||
'proof' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
return [$user, $tenant];
|
||||
}
|
||||
|
||||
it('reuses one tenant-governance aggregate across the tenant dashboard summary widgets', function (): void {
|
||||
[$user, $tenant] = createTenantGovernanceMemoizationTenant();
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)->test(NeedsAttention::class);
|
||||
Livewire::actingAs($user)->test(BaselineCompareNow::class);
|
||||
|
||||
expect(app(RequestScopedDerivedStateStore::class)->countStored(
|
||||
DerivedStateFamily::TenantGovernanceAggregate,
|
||||
Tenant::class,
|
||||
(string) $tenant->getKey(),
|
||||
TenantGovernanceAggregateResolver::VARIANT_TENANT_GOVERNANCE_SUMMARY,
|
||||
))->toBe(1);
|
||||
});
|
||||
|
||||
it('keeps tenant switches from reusing another tenant aggregate in the same request scope', function (): void {
|
||||
[, $tenantA] = createTenantGovernanceMemoizationTenant();
|
||||
[, $tenantB] = createTenantGovernanceMemoizationTenant();
|
||||
|
||||
$resolver = app(TenantGovernanceAggregateResolver::class);
|
||||
|
||||
$aggregateA = $resolver->forTenant($tenantA);
|
||||
$aggregateB = $resolver->forTenant($tenantB);
|
||||
|
||||
$store = app(RequestScopedDerivedStateStore::class);
|
||||
|
||||
expect($aggregateA)->not->toBeNull()
|
||||
->and($aggregateB)->not->toBeNull()
|
||||
->and($aggregateA?->tenantId)->toBe((int) $tenantA->getKey())
|
||||
->and($aggregateB?->tenantId)->toBe((int) $tenantB->getKey())
|
||||
->and($aggregateA?->tenantId)->not->toBe($aggregateB?->tenantId)
|
||||
->and($store->countStored(
|
||||
DerivedStateFamily::TenantGovernanceAggregate,
|
||||
Tenant::class,
|
||||
(string) $tenantA->getKey(),
|
||||
TenantGovernanceAggregateResolver::VARIANT_TENANT_GOVERNANCE_SUMMARY,
|
||||
))->toBe(1)
|
||||
->and($store->countStored(
|
||||
DerivedStateFamily::TenantGovernanceAggregate,
|
||||
Tenant::class,
|
||||
(string) $tenantB->getKey(),
|
||||
TenantGovernanceAggregateResolver::VARIANT_TENANT_GOVERNANCE_SUMMARY,
|
||||
))->toBe(1);
|
||||
});
|
||||
|
||||
it('returns no aggregate and stores nothing when no tenant context exists', function (): void {
|
||||
$aggregate = app(TenantGovernanceAggregateResolver::class)->forTenant(null);
|
||||
|
||||
expect($aggregate)->toBeNull()
|
||||
->and(app(RequestScopedDerivedStateStore::class)->countStored(
|
||||
DerivedStateFamily::TenantGovernanceAggregate,
|
||||
))->toBe(0);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -13,14 +13,15 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
|
||||
final class ActionSurfaceValidatorCompleteStub
|
||||
{
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
||||
@ -32,9 +33,9 @@ final class ActionSurfaceValidatorMissingSlotStub
|
||||
{
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value);
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value);
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,13 +43,13 @@ final class ActionSurfaceValidatorRunLogNoExportStub
|
||||
{
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->withDefaults(new \App\Support\Ui\ActionSurface\ActionSurfaceDefaults(
|
||||
moreGroupLabel: 'More',
|
||||
exportIsDefaultBulkActionForReadOnly: false,
|
||||
))
|
||||
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader);
|
||||
@ -59,8 +60,36 @@ final class ActionSurfaceValidatorExemptSlotWithoutReasonStub
|
||||
{
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||
->setSlot(ActionSurfaceSlot::ListHeader, ActionSurfaceSlotRequirement::exempt())
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader);
|
||||
}
|
||||
}
|
||||
|
||||
final class ActionSurfaceValidatorMissingSurfaceTypeStub
|
||||
{
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, version: 2)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader);
|
||||
}
|
||||
}
|
||||
|
||||
final class ActionSurfaceValidatorIncompatibleInspectAffordanceStub
|
||||
{
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||
@ -69,6 +98,19 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
}
|
||||
}
|
||||
|
||||
final class ActionSurfaceValidatorPrimaryLinkColumnWithoutReasonStub
|
||||
{
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::PrimaryLinkColumn->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState);
|
||||
}
|
||||
}
|
||||
|
||||
final class ActionSurfaceValidatorNoDeclarationStub {}
|
||||
|
||||
function actionSurfaceComponent(string $className): ActionSurfaceDiscoveredComponent
|
||||
@ -115,6 +157,42 @@ className: $className,
|
||||
expect($result->formatForAssertion())->toContain('Missing action-surface declaration');
|
||||
});
|
||||
|
||||
it('fails behavior-aware declarations when surface type is missing', function (): void {
|
||||
$validator = new ActionSurfaceValidator(
|
||||
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||
exemptions: new ActionSurfaceExemptions([]),
|
||||
);
|
||||
|
||||
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorMissingSurfaceTypeStub::class)]);
|
||||
|
||||
expect($result->hasIssues())->toBeTrue();
|
||||
expect($result->formatForAssertion())->toContain('Behavior-aware declarations must define a surface type');
|
||||
});
|
||||
|
||||
it('fails behavior-aware declarations when surface type and inspect affordance are incompatible', function (): void {
|
||||
$validator = new ActionSurfaceValidator(
|
||||
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||
exemptions: new ActionSurfaceExemptions([]),
|
||||
);
|
||||
|
||||
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorIncompatibleInspectAffordanceStub::class)]);
|
||||
|
||||
expect($result->hasIssues())->toBeTrue();
|
||||
expect($result->formatForAssertion())->toContain('Inspect affordance "view_action" is incompatible with surface type "crud_list_first_resource"');
|
||||
});
|
||||
|
||||
it('fails primary-link-column inspect affordances without an explicit reason', function (): void {
|
||||
$validator = new ActionSurfaceValidator(
|
||||
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||
exemptions: new ActionSurfaceExemptions([]),
|
||||
);
|
||||
|
||||
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorPrimaryLinkColumnWithoutReasonStub::class)]);
|
||||
|
||||
expect($result->hasIssues())->toBeTrue();
|
||||
expect($result->formatForAssertion())->toContain('Primary link column inspect affordance requires a non-empty reason');
|
||||
});
|
||||
|
||||
it('accepts missing declarations when explicit baseline exemption exists', function (): void {
|
||||
$validator = new ActionSurfaceValidator(
|
||||
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||
|
||||
@ -22,8 +22,9 @@
|
||||
'related_navigation_primary',
|
||||
'related_navigation_detail',
|
||||
'related_navigation_header',
|
||||
'tenant_governance_aggregate',
|
||||
];
|
||||
$allowedAccessPatterns = ['row_safe', 'page_safe', 'direct_once'];
|
||||
$allowedAccessPatterns = ['row_safe', 'page_safe', 'direct_once', 'widget_safe'];
|
||||
$allowedFreshnessPolicies = ['request_stable', 'invalidate_after_mutation', 'no_reuse'];
|
||||
$cachePattern = '/static\s+array\s+\$[A-Za-z0-9_]*cache\b/i';
|
||||
$violations = [];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user