Compare commits

..

2 Commits

Author SHA1 Message Date
Ahmed Darrazi
80bd9e3087 feat(onboarding): align verify-step hierarchy; contextual-help dark-mode callout
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m46s
2026-04-27 02:05:41 +02:00
Ahmed Darrazi
9f5d3293c5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-26 22:53:42 +02:00
129 changed files with 3874 additions and 14299 deletions

View File

@ -1,34 +1,30 @@
<!--
Sync Impact Report
- Version change: 2.10.0 -> 2.11.0
- Version change: 2.9.0 -> 2.10.0
- Modified principles:
- Expanded decision-first and operator-surface rules so operational,
governance, evidence, onboarding, review, and support-facing
detail/status surfaces separate decision content, operator
diagnostics, and support/raw evidence
- Expanded review and enforcement expectations so specs, plans,
tasks, and checklists must make audience modes, raw/support
gating, one dominant next action, and duplicate-truth prevention
explicit
- Expanded Operations / Run Observability Standard so OperationRun
start UX is shared-contract-owned instead of surface-owned
- Expanded Governance review expectations for OperationRun-starting
features, explicit queued-notification policy, and bounded
exceptions
- Added sections:
- Audience-Aware Decision Surfaces & Disclosure Ladder
(DECIDE-AUD-001): requires customer-readable default paths,
operator diagnostics as progressive disclosure, support/raw
evidence gating, one dominant next action, and no duplicate truth
across equal-priority cards
- OperationRun Start UX Contract (OPS-UX-START-001): centralizes
queued toast/link/event/message semantics, run/artifact deep links,
queued DB-notification policy, and tenant/workspace-safe operation
URL resolution behind one shared OperationRun UX layer
- Removed sections: None
- Templates requiring updates:
- .specify/templates/spec-template.md: add audience-aware disclosure
section + constitution prompts ✅
- .specify/templates/plan-template.md: add audience/disclosure
planning prompts + constitution checks ✅
- .specify/templates/tasks-template.md: add decision/disclosure
implementation + test tasks ✅
- .specify/templates/checklist-template.md: add disclosure, raw-gating,
one-primary-action, and duplicate-truth review checks ✅
- .specify/templates/spec-template.md: add OperationRun UX Impact
section + start-contract prompts ✅
- .specify/templates/plan-template.md: add OperationRun UX Impact
planning section + constitution checks ✅
- .specify/templates/tasks-template.md: add central start-UX reuse,
queued-notification policy, and exception tasks ✅
- .specify/templates/checklist-template.md: add OperationRun start
UX review checks ✅
- docs/product/standards/README.md: refresh constitution index for
the new audience-aware disclosure contract ✅
the new ops-UX contract ✅
- Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present
- Follow-up TODOs: None
@ -598,109 +594,6 @@ ##### Review gate
8. Does this reduce search, review, or click work?
9. Does this make the product calmer and clearer instead of louder?
#### Audience-Aware Decision Surfaces & Disclosure Ladder (DECIDE-AUD-001)
Goal: every operational, governance, evidence, onboarding, review, and
support-facing detail or status surface MUST keep customer-readable
decision content, operator diagnostics, and support/raw evidence
intentionally separated while preserving full depth through progressive
disclosure.
##### Audience ladder is explicit
- In-scope detail and status surfaces MUST define their content using
this three-tier hierarchy when applicable:
- decision content
- operator diagnostics
- support / raw evidence
- Surfaces that are reachable by more than one audience class MUST
define their default-visible content for at least these layers when
applicable:
- customer / read-only default
- operator / MSP diagnostics
- platform / support raw evidence
- The surface contract MUST state which capabilities unlock each deeper
layer.
- Support/raw evidence MUST NOT become the default first-read
experience on customer-readable or ordinary operator-facing
surfaces.
##### Customer-readable default path
- The default reading path for customer/read-only users MUST optimize
for status, reason, impact, one dominant next action, and a short
result or artifact summary.
- Internal lifecycle wording, debug semantics, implementation field
names, raw payload fragments, and support-oriented context MUST NOT
appear in the default customer-readable path unless they are the only
way to understand the first decision.
- Default-visible customer/read-only content is responsible for status,
reason, impact, the dominant next action, and a concise supporting
summary only.
##### Diagnostics are secondary by default
- Diagnostics such as lifecycle, timings, verification detail, drift
detail, permission detail, provider summaries, or related-operation
context MUST be lower-priority than the decision surface and MUST be
collapsed, tabbed, grouped, or otherwise progressively disclosed when
the first decision does not require them.
- Authorized operators MAY expand diagnostics, but diagnostics MUST NOT
visually compete with the primary decision block.
- Where no support/raw tier is exposed, diagnostics still remain below
the decision tier and MUST NOT restate the same decision summary at
equal weight.
##### Raw/support evidence is gated
- Raw/support evidence such as JSON, raw context payloads,
fingerprints, internal reason ownership, platform reason families,
monitoring detail, viewer context, or copy/show-raw actions MUST NOT
appear in the default decision path.
- These details MUST live behind explicit reveal affordances and MUST
be capability-gated wherever the audience model distinguishes support
or platform users from ordinary operators.
- Capability-gated support/raw disclosure MUST fail closed when the
actor lacks the required scope or capability.
##### One dominant next action
- A decision surface MUST expose exactly one dominant next action in
the default-visible region.
- Optional secondary actions MAY exist, but they MUST NOT compete with
the primary remediation or decision action in prominence.
- Contextual navigation such as opening a related run, tenant, report,
or technical detail remains secondary.
##### No duplicate truth across equal-priority cards
- The same blocker, reason, or next action MUST NOT be repeated across
multiple equal-priority cards, sections, or summary blocks on the
same default-visible surface.
- Supporting evidence MAY restate the underlying proof, but the
dominant decision message appears once and diagnostics elaborate
beneath it.
##### Required tests
- New or materially changed customer/operator-facing detail surfaces
MUST include focused tests proving:
- default-visible content shows status, reason, impact, and next
action,
- exactly one dominant next action is primary,
- diagnostics are secondary or collapsed,
- raw/support evidence is not default-visible,
- support/raw sections are capability-gated where applicable,
- and duplicate visible decision summaries are absent.
##### Stored evidence wins over fallback diagnostics
- When a stored verification or report artifact exists, fallback
technical diagnostics SHOULD demote behind supporting evidence or
technical details instead of remaining peer-level default content.
- Fallback diagnostics MAY become temporarily prominent only when the
higher-level artifact does not yet exist or is unavailable.
#### Surface Taxonomy (UI-SURF-001)
Every new admin surface MUST be assigned exactly one broad action-surface
@ -1424,22 +1317,11 @@ #### Operator Surface Principles (OPSURF-001)
- 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.
- Detail/status surfaces MUST satisfy DECIDE-AUD-001: decision content
first, operator diagnostics second, support/raw evidence third.
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.
Dominant next action and duplicate-truth control
- Default-visible decision content MUST include status, reason,
impact, and one dominant next action where those concepts exist.
- Secondary navigation or debug helpers MUST remain lower-priority
than the dominant decision action.
- The same blocker, reason, impact, or next action MUST NOT be
repeated across multiple default-visible cards, sections, tabs, or
summaries.
Explicit mutation scope
- 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.
@ -1460,13 +1342,6 @@ #### Operator Surface Principles (OPSURF-001)
Page contract requirement
- 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.
- Where multiple audience classes share the page, the contract MUST
explicitly define the customer/read-only default path, operator
diagnostics path, support/raw-evidence path, and the capabilities
that unlock each layer.
- The page contract MUST also make the dominant next action,
duplicate-truth prevention, and raw/support gating explicit for
changed detail/status surfaces.
#### Spec Scope Fields (SCOPE-002)
@ -1491,11 +1366,8 @@ #### Enforcement Model (UI-REVIEW-001)
native, custom, or a shared detail family, what shared core vs host
variation exists if relevant, which layer owns the relevant shell,
page, and detail truth, which requested/active/draft/inspect/
restorable roles exist, which audience ladder and disclosure
boundaries exist, what the dominant next action is, how raw/support
evidence is gated, how duplicate truth is prevented, whether any
fake-native or host-drift risk is present, and whether an exception
type is used.
restorable roles exist, whether any fake-native or host-drift risk is
present, and whether an exception type is used.
- Missing any of those answers makes the spec incomplete.
PR review requirements
@ -1510,12 +1382,7 @@ #### Enforcement Model (UI-REVIEW-001)
promoted into primary navigation without justification, one case
fragmented across multiple equal-rank pages, new automation that adds
attention surfaces without reducing operator work, noisy default
surfaces with no action/watch/reference hierarchy, duplicate visible
blocker/reason/next-action summaries, customer/operator default paths
that expose raw JSON, fingerprints, reason ownership, platform reason
families, or monitoring detail, helper actions such as `Open
operation`, `Technical details`, or `Show JSON` competing with the
dominant decision action, `Filament Costume`,
surfaces with no action/watch/reference hierarchy, `Filament Costume`,
`Blade Request UI`, `Hand-Rolled Simple Overview`, `Hidden Exception`,
`Host Drift`, `State Layer Collapse`, `Parallel Inspect Worlds`, or
undocumented exceptions without dedicated tests.
@ -1527,15 +1394,11 @@ #### Enforcement Model (UI-REVIEW-001)
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, presence of a single dominant
next action where surface metadata exposes one, absence of duplicate
visible decision summaries, explicit raw/support gating or secondary
placement where the surface serves multiple audience classes,
absence of fake-native primary controls where metadata says the
surface is native, bounded shared family contracts where metadata
says a family is reused, explicit state ownership where specs or
metadata expose it, and dedicated tests for every approved
exception.
stable canonical nouns across shells, absence of fake-native primary
controls where metadata says the surface is native, bounded shared
family contracts where metadata says a family is reused, explicit
state ownership where specs or metadata expose it, and dedicated
tests for every approved exception.
#### Immediate Retrofit Priorities
@ -1602,10 +1465,6 @@ #### Appendix A - One-page Condensed Constitution
- Scope chips must be truthful.
- Domain nouns are canonical and stable.
- Critical operational truth is default-visible.
- Multi-audience detail/status surfaces keep customer-readable decision
content above operator diagnostics and support/raw evidence.
- One dominant next action stays visually primary.
- Duplicate visible decision truth is forbidden.
- Semantic truth dimensions are not collapsed into a generic status.
- Standard lists stay scanable.
- Exceptions are catalogued, justified, and tested.
@ -1618,8 +1477,6 @@ #### Appendix B - Feature Review Checklist
- The human-in-the-loop moment is explicit.
- Immediate-visible decision information is explicit.
- On-demand evidence / diagnostics boundaries are explicit.
- Audience-aware default visibility and raw-evidence boundaries are
explicit where the page serves more than one audience class.
- Any new primary surface is justified against an existing decision
context.
- Navigation reflects a workflow rather than storage structure.
@ -1629,8 +1486,6 @@ #### Appendix B - Feature Review Checklist
- Broad action-surface class is declared.
- Detailed surface type is declared.
- The one most likely next operator action is explicit.
- One dominant next action stays primary.
- Duplicate visible decision truth is absent.
- The surface is classified correctly as native, custom, or shared
family.
- Primary inspect/open model is defined.
@ -1712,10 +1567,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
- Local Blade/Tailwind cards are allowed only when they preserve dark
mode correctness, spacing consistency, badge semantics, action
hierarchy, progressive disclosure, accessibility, and overall
Filament visual language.
Native-by-default classification
- `Native Surface` means the primary interaction contract is built from
@ -1747,8 +1598,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
more than one host, it becomes a `Shared Detail Micro-UI` and MUST
define shared core vs host variation before another host reassembles
it locally.
- Local one-off markup MUST NOT recreate decision/diagnostics/raw
layering when an existing shared detail family is sufficient.
Upgrade-safe preference
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
@ -1762,9 +1611,7 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- and the deviation is justified briefly in code and in the governing spec or PR.
- Approved exceptions MUST stay layout-neutral, use the minimum local
classes necessary, MUST NOT invent a new page-local status language,
MUST preserve dark mode correctness, spacing consistency,
badge semantics, action hierarchy, progressive disclosure,
accessibility, and MUST say what remains standardized.
and MUST say what remains standardized.
- `Hidden Exception` is forbidden. Historical accident or local
implementation convenience is not a valid substitute for UI-EX-001.
@ -1773,8 +1620,6 @@ ### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
- which native Filament element or shared primitive was used,
- why an existing component was insufficient if an exception was taken,
- whether the surface is native, custom, or a shared detail family,
- whether any local Blade/Tailwind card still preserves Filament
visual language and disclosure semantics,
- and whether any ad-hoc status, emphasis styling, or fake-native
contract was introduced.
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
@ -1813,11 +1658,6 @@ ### Scope, Compliance, and Review Expectations
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
and migration decision.
- Specs and PRs that change detail or status surfaces MUST explicitly
document how they satisfy customer-readable decision-first content,
diagnostics-secondary disclosure, support/raw-evidence gating, one
dominant next action, duplicate-truth prevention, and shared-pattern
reuse.
- Specs and PRs that change operator-facing surfaces MUST classify each
affected surface under DECIDE-001 and justify any new Primary
Decision Surface or workflow-first navigation change.
@ -1835,4 +1675,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-27
**Version**: 2.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-24

View File

@ -51,14 +51,6 @@ ## Signals, Exceptions, And Test Depth
- [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
- [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
## Audience-Aware Disclosure And Decision Hierarchy
- [ ] CHK023 Default-visible content is decision-first and clearly separated from operator diagnostics and support/raw evidence.
- [ ] CHK024 Customer/read-only default paths do not expose raw JSON, copied context payloads, fingerprints, internal reason ownership, platform reason families, monitoring detail, or other debug semantics by default.
- [ ] CHK025 Exactly one dominant next action is primary; navigation or debug helpers such as `Open operation`, `Technical details`, or `Show JSON` do not compete at equal weight.
- [ ] CHK026 Duplicate visible status, blocker, reason, impact, or next-action summaries are removed or explicitly justified as non-duplicative evidence.
- [ ] CHK027 Support/raw sections are collapsed, lower-priority, or capability-gated where applicable, and any local Blade/Tailwind surface still preserves Filament visual language, dark mode correctness, progressive disclosure, and accessibility.
## Review Outcome
- [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.

View File

@ -36,10 +36,6 @@ ## UI / Surface Guardrail Plan
- **Native vs custom classification summary**: [native / custom / mixed / N/A]
- **Shared-family relevance**: [none / list affected shared families]
- **State layers in scope**: [shell / page / detail / URL-query / none]
- **Audience modes in scope**: [customer/read-only / operator-MSP / support-platform / N/A]
- **Decision/diagnostic/raw hierarchy plan**: [decision-first / diagnostics-second / support-raw-third / N/A]
- **Raw/support gating plan**: [collapsed / capability-gated / role-gated / N/A]
- **One-primary-action / duplicate-truth control**: [how one dominant next action is preserved and repeated blockers are removed]
- **Handling modes by drift class or surface**: [hard-stop-candidate / review-mandatory / exception-required / report-only / N/A]
- **Repository-signal treatment**: [report-only / review-mandatory / exception-required / future hard-stop candidate / N/A]
- **Special surface test profiles**: [standard-native-filament / shared-detail-family / monitoring-state-page / global-context-shell / exception-coded-surface / N/A]
@ -115,10 +111,6 @@ ## 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
- Filament-native UI (UI-FIL-001): if local Blade/Tailwind cards are
still necessary, they preserve dark mode correctness, spacing
consistency, badge semantics, action hierarchy, progressive
disclosure, accessibility, and Filament visual language
- UI/UX surface taxonomy (UI-CONST-001 / UI-SURF-001): every changed operator-facing surface is classified as exactly one allowed surface type; ad-hoc interaction models are forbidden
- Decision-first operating model (DECIDE-001): each changed
operator-facing surface is classified as Primary Decision,
@ -128,13 +120,6 @@ ## Constitution Check
disclosed, one governance case stays decidable in one context where
practical, navigation follows workflows not storage structures, and
automation / alerts reduce attention load instead of adding noise
- Audience-aware disclosure (DECIDE-AUD-001 / OPSURF-001): detail or
status surfaces separate customer-readable decision content,
operator diagnostics, and support/raw evidence; customer-readable
default paths hide raw JSON, copied context, fingerprints, internal
reason ownership, platform reason families, and debug semantics;
one dominant next action is explicit; duplicate visible truth is
removed
- 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

View File

@ -89,17 +89,6 @@ ## Decision-First Surface Role *(mandatory when operator-facing surfaces are cha
|---|---|---|---|---|---|---|---|
| e.g. Review inbox | Primary Decision Surface | Review and release queued governance work | Case summary, severity, recommendation, required action | Full evidence, raw payloads, audit trail, provider diagnostics | Primary because it is the queue where operators decide and clear work | Follows pending-decisions workflow, not storage objects | Removes search across runs, findings, and audit pages |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
If this feature adds or materially changes a detail or status surface,
fill out one row per affected surface. Reuse the same surface names
used above and make the disclosure hierarchy explicit instead of
assuming it.
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| e.g. Review inbox | customer-read-only, operator-MSP, support-platform | Current status, why it matters, impact, recommendation, next action | Review history, lifecycle, related evidence, related runs | Raw payloads, fingerprints, reason ownership, platform reason family | `Review evidence` | Raw/support detail hidden or capability-gated outside support mode | The top summary states the blocker once; later sections add evidence rather than restating it |
## 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,
@ -265,13 +254,6 @@ ## Requirements *(mandatory)*
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** If this feature changes a detail or status surface, the spec MUST describe:
- how the surface separates customer-readable decision content, operator diagnostics, and support/raw evidence,
- which audience modes are in scope (`customer/read-only`, `operator/MSP`, `support/platform`),
- which content is hidden, collapsed, or capability-gated by default,
- how one dominant next action is preserved,
- and how duplicate visible truth is prevented.
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
- classify each touched seam as provider-owned or platform-core,
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
@ -328,7 +310,6 @@ ## Requirements *(mandatory)*
- which native Filament components or shared UI primitives are used,
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
- how any required local Blade/Tailwind cards still preserve dark mode correctness, spacing consistency, badge semantics, action hierarchy, progressive disclosure, accessibility, and Filament visual language,
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
@ -386,7 +367,6 @@ ## Requirements *(mandatory)*
**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,
- how the dominant next action stays primary and how duplicate visible truth is avoided,
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),

View File

@ -78,21 +78,9 @@ # Tasks: [FEATURE NAME]
- filling the specs Operator Surface Contract for every affected page,
- keeping default-visible content limited to first-decision needs and
moving proof, payloads, and diagnostics into progressive disclosure,
- implementing the three-tier disclosure hierarchy where applicable:
customer-readable decision content first, operator diagnostics
second, support/raw evidence third,
- 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,
- ensuring customer/read-only default paths do not expose raw JSON,
copied context payloads, fingerprints, internal reason ownership,
platform reason families, or debug semantics,
- keeping each governance case decidable in one focused context where
practical instead of forcing cross-page reconstruction,
- keeping exactly one dominant next action primary and demoting
navigation/debug helpers such as `Open operation`, `Technical
details`, or `Show JSON`,
- removing duplicate visible status, blocker, reason, impact, or
next-action summaries so later sections add evidence instead of
restating the same decision truth,
- 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,
@ -140,12 +128,6 @@ # Tasks: [FEATURE NAME]
- 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.
- For any new or modified customer/operator-facing detail surface,
tests MUST prove default-visible status/reason/impact/next-action
content, exactly one dominant next action, diagnostics-secondary
ordering, hidden raw/support detail by default, capability-gated
support/raw sections where applicable, and the absence of duplicate
visible decision summaries.
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
- ensuring all form fields are inside Sections/Cards (no naked inputs at root schema level),

View File

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Entitlements;
final class WorkspaceEntitlementBlockedException extends \RuntimeException
{
/**
* @param array<string, mixed> $decision
*/
public function __construct(private readonly array $decision)
{
parent::__construct((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks this action.'));
}
/**
* @return array<string, mixed>
*/
public function decision(): array
{
return $this->decision;
}
}

View File

@ -6,7 +6,6 @@
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
@ -31,7 +30,6 @@
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
@ -42,10 +40,6 @@
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
@ -147,6 +141,10 @@ protected function getHeaderActions(): array
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
: OperationRunLinks::index());
if (isset($this->run)) {
$actions[] = $this->openSupportDiagnosticsAction();
}
if (! isset($this->run)) {
return $actions;
}
@ -169,14 +167,6 @@ protected function getHeaderActions(): array
->color('gray');
}
$actions[] = ActionGroup::make([
$this->openSupportDiagnosticsAction(),
$this->requestSupportAction(),
])
->label('More')
->icon('heroicon-o-ellipsis-horizontal')
->color('gray');
$actions[] = $this->resumeCaptureAction();
return $actions;
@ -238,6 +228,8 @@ private function openSupportDiagnosticsAction(): Action
$action = Action::make('openSupportDiagnostics')
->label('Open support diagnostics')
->icon('heroicon-o-lifebuoy')
->iconButton()
->tooltip('Open support diagnostics')
->color('gray')
->record($this->run)
->modal()
@ -259,85 +251,39 @@ private function openSupportDiagnosticsAction(): Action
->apply();
}
public function authorizeOperationRunSupportRequest(): void
{
$this->resolveRunTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
}
private function requestSupportAction(): Action
{
$action = Action::make('requestSupport')
->label('Request support')
->icon('heroicon-o-paper-airplane')
->record($this->run)
->slideOver()
->stickyModalHeader()
->modalHeading('Request support')
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from the current run.')
->modalSubmitActionLabel('Submit support request')
->form([
Placeholder::make('primary_context')
->label('Primary context')
->content(fn (): string => OperationRunLinks::identifier($this->run))
->columnSpanFull(),
Placeholder::make('included_context')
->label('Included context')
->content(fn (): string => $this->operationSupportRequestAttachmentSummary())
->columnSpanFull(),
Select::make('severity')
->label('Severity')
->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL)
->required()
->native(false),
TextInput::make('summary')
->label('Summary')
->required()
->columnSpanFull(),
Textarea::make('reproduction_notes')
->label('Reproduction notes')
->rows(4)
->columnSpanFull(),
TextInput::make('contact_name')
->label('Contact name')
->default(fn (): ?string => $this->resolveViewerActor()->name),
TextInput::make('contact_email')
->label('Contact email')
->email()
->default(fn (): ?string => $this->resolveViewerActor()->email),
])
->action(function (array $data): void {
$actor = $this->resolveViewerActor();
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
Notification::make()
->title('Support request submitted')
->body('Reference '.$supportRequest->internal_reference)
->success()
->send();
});
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
->apply();
}
/**
* @return array<string, mixed>
*/
public function operationRunSupportDiagnosticBundle(): array
{
$user = $this->resolveViewerActor();
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
$user = auth()->user();
$tenant = $this->supportDiagnosticsTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
abort(403);
}
return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user);
}
private function auditOperationSupportDiagnosticsOpen(): void
{
$user = $this->resolveViewerActor();
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
$user = auth()->user();
$tenant = $this->supportDiagnosticsTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$this->recordSupportDiagnosticsOpened(
tenant: $tenant,
@ -361,59 +307,6 @@ private function supportDiagnosticsTenant(): ?Tenant
return $this->run->loadMissing('tenant')->tenant;
}
private function resolveViewerActor(): User
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
return $user;
}
private function resolveRunTenantForCapability(string $capability): Tenant
{
$tenant = $this->supportDiagnosticsTenant();
$user = $this->resolveViewerActor();
if (! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, $capability)) {
abort(403);
}
return $tenant;
}
private function operationSupportRequestAttachmentSummary(): string
{
$tenant = $this->supportDiagnosticsTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return 'Only canonical redacted run context will be attached.';
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return 'Only canonical redacted run context will be attached.';
}
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
? 'A redacted diagnostic snapshot and the canonical run context will be attached.'
: 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.';
}
/**
* @param array<string, mixed> $bundle
*/

View File

@ -9,7 +9,6 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
@ -177,10 +176,6 @@ public function table(Table $table): Table
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
&& in_array($record->status, ['ready', 'published'], true))
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
->tooltip(fn (TenantReview $record): ?string => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)
? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '')
: null)
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
])
->bulkActions([])

View File

@ -7,11 +7,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Support\Ai\AiPolicyMode;
use App\Support\Ai\AiUseCaseCatalog;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities;
@ -24,9 +20,7 @@
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
@ -57,7 +51,6 @@ class WorkspaceSettings extends Page
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
*/
private const SETTING_FIELDS = [
'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'],
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
@ -65,23 +58,10 @@ class WorkspaceSettings extends Page
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
'entitlements_plan_profile' => ['domain' => 'entitlements', 'key' => 'plan_profile', 'type' => 'string'],
'entitlements_managed_tenant_limit_override_value' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_value', 'type' => 'int'],
'entitlements_managed_tenant_limit_override_reason' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_reason', 'type' => 'string'],
'entitlements_review_pack_generation_override_value' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_value', 'type' => 'bool'],
'entitlements_review_pack_generation_override_reason' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_reason', 'type' => 'string'],
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
];
/**
* @var array<string, string>
*/
private const ENTITLEMENT_OVERRIDE_REASON_FIELDS = [
'entitlements_managed_tenant_limit_override_value' => 'entitlements_managed_tenant_limit_override_reason',
'entitlements_review_pack_generation_override_value' => 'entitlements_review_pack_generation_override_reason',
];
/**
* Fields rendered as Filament KeyValue components (array state, not JSON string).
*
@ -131,14 +111,6 @@ class WorkspaceSettings extends Page
*/
public array $resolvedSettings = [];
/**
* @var array{
* plan_profile?: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
* decisions?: array<string, array<string, mixed>>
* }
*/
public array $entitlementSummary = [];
/**
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
*
@ -208,71 +180,6 @@ public function content(Schema $schema): Schema
return $schema
->statePath('data')
->schema([
Section::make('Workspace entitlements')
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
->columns(2)
->schema([
Select::make('entitlements_plan_profile')
->label('Plan profile')
->options(app(WorkspacePlanProfileCatalog::class)->optionLabels())
->placeholder(sprintf('Use default profile (%s)', app(WorkspacePlanProfileCatalog::class)->default()['label']))
->native(false)
->columnSpanFull()
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->planProfileFieldHelperText()),
TextInput::make('entitlements_managed_tenant_limit_override_value')
->label('Managed tenant activation limit override')
->placeholder('Unset (uses plan profile default)')
->suffix('tenants')
->hint('0 or greater')
->numeric()
->integer()
->minValue(0)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->managedTenantLimitHelperText())
->hintAction($this->makeResetAction('entitlements_managed_tenant_limit_override_value')),
Textarea::make('entitlements_managed_tenant_limit_override_reason')
->label('Managed tenant activation override reason')
->rows(3)
->maxLength(500)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->managedTenantLimitReasonHelperText()),
Select::make('entitlements_review_pack_generation_override_value')
->label('Review pack generation override')
->options(self::booleanOptions())
->placeholder('Unset (uses plan profile default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->reviewPackGenerationHelperText())
->hintAction($this->makeResetAction('entitlements_review_pack_generation_override_value')),
Textarea::make('entitlements_review_pack_generation_override_reason')
->label('Review pack generation override reason')
->rows(3)
->maxLength(500)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->reviewPackGenerationReasonHelperText()),
]),
Section::make('Workspace AI policy')
->description($this->sectionDescription('ai', 'Control whether the workspace disables AI entirely or allows approved internal-only drafts on private-only infrastructure.'))
->schema([
Select::make('ai_policy_mode')
->label('AI posture')
->options(AiPolicyMode::optionLabels())
->placeholder('Unset (uses default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->aiPolicyModeHelperText())
->hintAction($this->makeResetAction('ai_policy_mode')),
Placeholder::make('ai_approved_use_cases')
->label('Approved use cases')
->content(fn (): string => $this->aiApprovedUseCasesText()),
Placeholder::make('ai_allowed_provider_classes')
->label('Allowed provider classes')
->content(fn (): string => $this->aiAllowedProviderClassesText()),
Placeholder::make('ai_blocked_data_classifications')
->label('Blocked data classifications')
->content(fn (): string => $this->aiBlockedDataClassificationsText()),
]),
Section::make('Backup settings')
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
->schema([
@ -548,56 +455,6 @@ public function resetSetting(string $field): void
->send();
}
private function resetEntitlementOverridePair(string $field): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceManage($user);
if (! $this->hasEntitlementOverridePair($field)) {
Notification::make()
->title('Entitlement already uses plan profile default')
->success()
->send();
return;
}
$writer = app(SettingsWriter::class);
$valueSetting = $this->settingForField($field);
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
$reasonSetting = $this->settingForField($reasonField);
if ($this->workspaceOverrideForField($field) !== null) {
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $valueSetting['domain'],
key: $valueSetting['key'],
);
}
if ($this->workspaceOverrideForField($reasonField) !== null) {
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $reasonSetting['domain'],
key: $reasonSetting['key'],
);
}
$this->loadFormState();
Notification::make()
->title('Workspace entitlement override reset')
->success()
->send();
}
private function loadFormState(): void
{
$resolver = app(SettingsResolver::class);
@ -633,7 +490,6 @@ private function loadFormState(): void
$this->data = $data;
$this->workspaceOverrides = $workspaceOverrides;
$this->resolvedSettings = $resolvedSettings;
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
$this->loadDomainLastModified();
}
@ -707,25 +563,15 @@ private function makeResetAction(string $field): Action
->color('danger')
->requiresConfirmation()
->action(function () use ($field): void {
if ($this->isEntitlementOverrideValueField($field)) {
$this->resetEntitlementOverridePair($field);
return;
}
$this->resetSetting($field);
})
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
->tooltip(function () use ($field): ?string {
if (! $this->currentUserCanManage()) {
return 'You do not have permission to manage workspace settings.';
}
if (! $this->canResetField($field)) {
if ($this->isEntitlementOverrideValueField($field)) {
return 'No workspace override to reset.';
}
if (! $this->hasWorkspaceOverride($field)) {
return 'No workspace override to reset.';
}
@ -733,200 +579,6 @@ private function makeResetAction(string $field): Action
});
}
private function canResetField(string $field): bool
{
if ($this->isEntitlementOverrideValueField($field)) {
return $this->hasEntitlementOverridePair($field);
}
return $this->hasWorkspaceOverride($field);
}
private function isEntitlementOverrideValueField(string $field): bool
{
return array_key_exists($field, self::ENTITLEMENT_OVERRIDE_REASON_FIELDS);
}
private function hasEntitlementOverridePair(string $field): bool
{
if (! $this->isEntitlementOverrideValueField($field)) {
return false;
}
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
return $this->workspaceOverrideForField($field) !== null
|| $this->workspaceOverrideForField($reasonField) !== null;
}
private function planProfileFieldHelperText(): string
{
$profile = $this->resolvedPlanProfile();
$selectedProfile = $this->workspaceOverrideForField('entitlements_plan_profile');
if (! is_string($selectedProfile) || $selectedProfile === '') {
return sprintf('Default profile: %s. %s', $profile['label'], $profile['description']);
}
return sprintf('Effective profile: %s. %s', $profile['label'], $profile['description']);
}
private function managedTenantLimitHelperText(): string
{
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
$capacityText = $remainingCapacity < 0
? sprintf('Over limit by %d.', abs($remainingCapacity))
: sprintf('%d remaining.', $remainingCapacity);
return sprintf(
'Effective limit: %d active managed tenants. Current usage: %d. %s Source: %s.',
$effectiveValue,
$currentUsage,
$capacityText,
$this->entitlementSourceLabel($decision),
);
}
private function managedTenantLimitReasonHelperText(): string
{
return $this->entitlementReasonHelperText(
valueField: 'entitlements_managed_tenant_limit_override_value',
key: WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
}
private function reviewPackGenerationHelperText(): string
{
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
return sprintf(
'Effective state: %s. Source: %s.',
(bool) ($decision['effective_value'] ?? false) ? 'enabled' : 'disabled',
$this->entitlementSourceLabel($decision),
);
}
private function reviewPackGenerationReasonHelperText(): string
{
return $this->entitlementReasonHelperText(
valueField: 'entitlements_review_pack_generation_override_value',
key: WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
}
private function aiPolicyModeHelperText(): string
{
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
if (! is_array($resolved)) {
return '';
}
$mode = AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
?? AiPolicyMode::Disabled;
$prefix = ! $this->hasWorkspaceOverride('ai_policy_mode')
? sprintf('Effective posture: %s. Source: %s.', $mode->label(), $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')))
: sprintf('Effective posture: %s.', $mode->label());
return sprintf('%s %s', $prefix, $mode->summary());
}
private function aiApprovedUseCasesText(): string
{
return implode('; ', app(AiUseCaseCatalog::class)->labels()).'.';
}
private function aiAllowedProviderClassesText(): string
{
$labels = app(AiUseCaseCatalog::class)->allowedProviderClassLabelsForMode($this->effectiveAiPolicyMode());
if ($labels === []) {
return 'No provider classes are allowed while AI is disabled.';
}
return implode(', ', $labels).'.';
}
private function aiBlockedDataClassificationsText(): string
{
return implode(', ', app(AiUseCaseCatalog::class)->blockedDataClassificationLabels()).'.';
}
private function effectiveAiPolicyMode(): AiPolicyMode
{
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
if (! is_array($resolved)) {
return AiPolicyMode::Disabled;
}
return AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
?? AiPolicyMode::Disabled;
}
private function entitlementReasonHelperText(string $valueField, string $key): string
{
$decision = $this->entitlementDecision($key);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
if ($this->workspaceOverrideForField($valueField) === null) {
return 'Required when an explicit override value is set.';
}
if ($rationale === null || $rationale === '') {
return 'Required when an explicit override value is set.';
}
return sprintf('Current rationale: %s', $rationale);
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
private function resolvedPlanProfile(): array
{
$profile = $this->entitlementSummary['plan_profile'] ?? null;
if (is_array($profile)) {
return $profile;
}
return app(WorkspacePlanProfileCatalog::class)->default();
}
/**
* @return array<string, mixed>
*/
private function entitlementDecision(string $key): array
{
$decision = $this->entitlementSummary['decisions'][$key] ?? null;
return is_array($decision) ? $decision : [];
}
/**
* @param array<string, mixed> $decision
*/
private function entitlementSourceLabel(array $decision): string
{
if (($decision['source'] ?? null) === 'workspace_override') {
return 'workspace override';
}
$planProfileLabel = $decision['plan_profile_label'] ?? null;
if (is_string($planProfileLabel) && $planProfileLabel !== '') {
return sprintf('%s plan profile', $planProfileLabel);
}
return 'plan profile default';
}
private function helperTextFor(string $field): string
{
$resolved = $this->resolvedSettings[$field] ?? null;
@ -1069,27 +721,6 @@ private function normalizedInputValues(): array
}
}
foreach (self::ENTITLEMENT_OVERRIDE_REASON_FIELDS as $valueField => $reasonField) {
if (($normalizedValues[$valueField] ?? null) === null) {
$normalizedValues[$reasonField] = null;
continue;
}
if (($normalizedValues[$reasonField] ?? null) !== null) {
continue;
}
$message = match ($valueField) {
'entitlements_managed_tenant_limit_override_value' => 'Override reason is required when a managed tenant activation limit override is set.',
'entitlements_review_pack_generation_override_value' => 'Override reason is required when a review pack generation override is set.',
default => 'Override reason is required when an explicit override is set.',
};
$validationErrors['data.'.$reasonField] ??= [];
$validationErrors['data.'.$reasonField][] = $message;
}
return [$normalizedValues, $validationErrors];
}

View File

@ -11,7 +11,6 @@
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
use App\Filament\Widgets\Dashboard\RecentOperations;
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
@ -21,14 +20,8 @@
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Dashboard;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
@ -77,72 +70,10 @@ public function getColumns(): int|array
protected function getHeaderActions(): array
{
return [
$this->requestSupportAction(),
$this->openSupportDiagnosticsAction(),
];
}
public function authorizeTenantSupportRequest(): void
{
$this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
}
private function requestSupportAction(): Action
{
$action = Action::make('requestSupport')
->label('Request support')
->icon('heroicon-o-paper-airplane')
->color('gray')
->slideOver()
->stickyModalHeader()
->modalHeading('Request support')
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from existing records.')
->modalSubmitActionLabel('Submit request')
->form([
Placeholder::make('included_context')
->label('Included context')
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
->columnSpanFull(),
Select::make('severity')
->label('Severity')
->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL)
->required()
->native(false),
TextInput::make('summary')
->label('Summary')
->required()
->columnSpanFull(),
Textarea::make('reproduction_notes')
->label('Reproduction notes')
->rows(4)
->columnSpanFull(),
TextInput::make('contact_name')
->label('Contact name')
->default(fn (): ?string => $this->resolveDashboardActor()->name),
TextInput::make('contact_email')
->label('Contact email')
->email()
->default(fn (): ?string => $this->resolveDashboardActor()->email),
])
->action(function (array $data): void {
$actor = $this->resolveDashboardActor();
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
Notification::make()
->title('Support request submitted')
->body('Reference '.$supportRequest->internal_reference)
->success()
->send();
});
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
->apply();
}
private function openSupportDiagnosticsAction(): Action
{
$action = Action::make('openSupportDiagnostics')
@ -173,16 +104,34 @@ private function openSupportDiagnosticsAction(): Action
*/
public function tenantSupportDiagnosticBundle(): array
{
$user = $this->resolveDashboardActor();
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
$user = auth()->user();
$tenant = Filament::getTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
abort(403);
}
return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user);
}
private function auditTenantSupportDiagnosticsOpen(): void
{
$user = $this->resolveDashboardActor();
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
$user = auth()->user();
$tenant = Filament::getTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$this->recordSupportDiagnosticsOpened(
tenant: $tenant,
@ -223,57 +172,4 @@ private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, U
$this->supportDiagnosticsAuditKeys[] = $auditKey;
}
private function resolveDashboardActor(): User
{
$user = auth()->user();
if (! $user instanceof User) {
abort(404);
}
return $user;
}
private function resolveCurrentTenantForCapability(string $capability): Tenant
{
$user = $this->resolveDashboardActor();
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, $capability)) {
abort(403);
}
return $tenant;
}
private function tenantSupportRequestAttachmentSummary(): string
{
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return 'Only canonical redacted tenant context will be attached.';
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return 'Only canonical redacted tenant context will be attached.';
}
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.'
: 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.';
}
}

View File

@ -30,7 +30,6 @@
use App\Services\Onboarding\OnboardingDraftResolver;
use App\Services\Onboarding\OnboardingDraftStageResolver;
use App\Services\Onboarding\OnboardingLifecycleService;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionMutationService;
use App\Services\Providers\ProviderOperationRegistry;
@ -663,16 +662,7 @@ public function content(Schema $schema): Schema
Text::make(fn (): string => $this->completionSummaryBootstrapSummary())
->badge()
->color(fn (): string => $this->completionSummaryBootstrapColor()),
Text::make('Activation entitlement')
->color('gray'),
Text::make(fn (): string => $this->completionSummaryEntitlementSummary())
->badge()
->color(fn (): string => $this->completionSummaryEntitlementColor()),
]),
Callout::make('Activation entitlement')
->description(fn (): string => $this->completionSummaryEntitlementDetail())
->warning()
->visible(fn (): bool => $this->completionSummaryEntitlementBlocked()),
Callout::make('Bootstrap needs attention')
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
->warning()
@ -710,7 +700,9 @@ public function content(Schema $schema): Schema
->modalSubmitActionLabel('Yes, complete onboarding')
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
->tooltip(fn (): ?string => $this->completionActionTooltip())
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
? null
: 'Owner required to complete onboarding.')
->action(fn () => $this->completeOnboarding()),
]),
]),
@ -4506,10 +4498,6 @@ private function canCompleteOnboarding(): bool
return false;
}
if ($this->completionSummaryEntitlementBlocked()) {
return false;
}
$user = $this->currentUser();
if (! app(TenantOperabilityService::class)->outcomeFor(
@ -4542,111 +4530,6 @@ private function canCompleteOnboarding(): bool
return trim((string) ($this->data['override_reason'] ?? '')) !== '';
}
/**
* @return array<string, mixed>
*/
private function completionSummaryEntitlementDecision(): array
{
if (! isset($this->workspace) || ! $this->workspace instanceof Workspace) {
return [];
}
return app(WorkspaceEntitlementResolver::class)->resolve(
$this->workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
}
private function completionSummaryEntitlementBlocked(): bool
{
return (bool) ($this->completionSummaryEntitlementDecision()['is_blocked'] ?? false);
}
private function completionSummaryEntitlementSummary(): string
{
$decision = $this->completionSummaryEntitlementDecision();
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
return sprintf(
'%s - %d active of %d allowed (%s)',
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
$currentUsage,
$effectiveValue,
$sourceLabel,
);
}
private function completionSummaryEntitlementDetail(): string
{
$decision = $this->completionSummaryEntitlementDecision();
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
$message = sprintf(
'Current usage is %d active managed tenant%s out of %d allowed. Source: %s.',
$currentUsage,
$currentUsage === 1 ? '' : 's',
$effectiveValue,
$sourceLabel,
);
if ($remainingCapacity >= 0) {
$message .= sprintf(' Remaining capacity: %d.', $remainingCapacity);
}
if ($this->completionSummaryEntitlementBlocked()) {
$blockReason = is_string($decision['block_reason'] ?? null) ? $decision['block_reason'] : null;
if ($blockReason !== null && $blockReason !== '') {
$message = $blockReason;
}
}
if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === 'workspace_override') {
$message .= ' Rationale: '.$rationale;
}
return $message;
}
private function completionSummaryEntitlementColor(): string
{
return $this->completionSummaryEntitlementBlocked() ? 'warning' : 'success';
}
/**
* @param array<string, mixed> $decision
*/
private function completionSummaryEntitlementSourceLabel(array $decision): string
{
if (($decision['source'] ?? null) === 'workspace_override') {
return 'workspace override';
}
$label = $decision['plan_profile_label'] ?? null;
return is_string($label) && $label !== ''
? sprintf('%s plan profile', $label)
: 'plan profile default';
}
private function completionActionTooltip(): ?string
{
if (! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) {
return 'Owner required to complete onboarding.';
}
if ($this->completionSummaryEntitlementBlocked()) {
return $this->completionSummaryEntitlementDetail();
}
return null;
}
private function completionSummaryTenantLine(): string
{
$tenant = $this->currentManagedTenantRecord();
@ -4980,16 +4863,6 @@ public function completeOnboarding(): void
return;
}
if ($this->completionSummaryEntitlementBlocked()) {
Notification::make()
->title('Activation limit reached')
->body($this->completionSummaryEntitlementDetail())
->warning()
->send();
return;
}
$run = $this->verificationRun();
$verificationSucceeded = $this->verificationHasSucceeded();
$verificationCanProceed = $this->verificationCanProceed();

View File

@ -2,8 +2,6 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource\Pages;
@ -12,7 +10,6 @@
use App\Models\User;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -48,8 +45,6 @@
class ReviewPackResource extends Resource
{
use ResolvesPanelTenantContext;
protected static ?string $model = ReviewPack::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -107,9 +102,9 @@ public static function canView(Model $record): bool
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action appears in the list header once review packs exist.')
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
@ -355,37 +350,14 @@ public static function table(Table $table): Table
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
->emptyStateIcon('heroicon-o-document-arrow-down')
->emptyStateActions([
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
]);
}
public static function generatePackAction(string $name = 'generate_pack', string $label = 'Generate Pack'): Actions\Action
{
$action = UiEnforcement::forAction(
Actions\Action::make($name)
->label($label)
UiEnforcement::forAction(
Actions\Action::make('generate_first')
->label('Generate first pack')
->icon('heroicon-o-plus')
->disabled(fn (): bool => static::reviewPackGenerationBlocked())
->action(function (array $data): void {
static::executeGeneration($data);
})
->form(static::reviewPackGenerationFormSchema())
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->preserveDisabled()
->apply();
$action->tooltip(fn (): ?string => static::reviewPackGenerationActionTooltip());
return $action;
}
/**
* @return array<int, Section>
*/
public static function reviewPackGenerationFormSchema(): array
{
return [
->form([
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
@ -397,20 +369,22 @@ public static function reviewPackGenerationFormSchema(): array
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
];
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
]);
}
public static function getEloquentQuery(): Builder
{
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
return parent::getEloquentQuery()
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
->where('tenant_id', (int) $tenant->getKey());
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
}
public static function getPages(): array
@ -484,14 +458,6 @@ public static function executeGeneration(array $data): void
try {
$reviewPack = $service->generate($tenant, $user, $options);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()
->warning()
->title('Review pack generation unavailable')
->body($exception->getMessage())
->send();
return;
} catch (ReviewPackEvidenceResolutionException $exception) {
$reasons = $exception->result->reasons;
@ -527,55 +493,4 @@ public static function executeGeneration(array $data): void
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
}
/**
* @return array<string, mixed>
*/
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
{
$tenant ??= Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [];
}
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
}
public static function currentTenantContext(): ?Tenant
{
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
return $tenant instanceof Tenant ? $tenant : null;
}
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
{
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
}
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return null;
}
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{
$tenant ??= static::currentTenantContext();
$user = auth()->user();
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant);
}
}

View File

@ -3,7 +3,12 @@
namespace App\Filament\Resources\ReviewPackResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Section;
class ListReviewPacks extends ListRecords
{
@ -12,13 +17,29 @@ class ListReviewPacks extends ListRecords
protected function getHeaderActions(): array
{
return [
ReviewPackResource::generatePackAction()
->visible(fn (): bool => $this->tableHasRecords()),
UiEnforcement::forAction(
Actions\Action::make('generate_pack')
->label('Generate Pack')
->icon('heroicon-o-plus')
->action(function (array $data): void {
ReviewPackResource::executeGeneration($data);
})
->form([
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
];
}
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;
}
}

View File

@ -19,12 +19,20 @@ class ViewReviewPack extends ViewRecord
protected function getHeaderActions(): array
{
$regenerateAction = UiEnforcement::forAction(
return [
Actions\Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
->openUrlInNewTab(),
UiEnforcement::forAction(
Actions\Action::make('regenerate')
->label('Regenerate')
->icon('heroicon-o-arrow-path')
->color('primary')
->disabled(fn (): bool => ReviewPackResource::reviewPackGenerationBlocked($this->record->tenant))
->requiresConfirmation()
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
->action(function (array $data): void {
@ -59,21 +67,7 @@ protected function getHeaderActions(): array
})
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->preserveDisabled()
->apply();
$regenerateAction->tooltip(fn (): ?string => ReviewPackResource::reviewPackGenerationActionTooltip($this->record->tenant));
return [
Actions\Action::make('download')
->label('Download')
->icon('heroicon-o-arrow-down-tray')
->color('success')
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
->openUrlInNewTab(),
$regenerateAction,
->apply(),
];
}
}

View File

@ -7,7 +7,6 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\TenantReviewResource\Pages;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
use App\Models\TenantReview;
@ -16,7 +15,6 @@
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -243,25 +241,6 @@ public static function infolist(Schema $schema): Schema
public static function table(Table $table): Table
{
$exportExecutivePackAction = UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->disabled(fn (TenantReview $record): bool => static::reviewPackGenerationBlocked($record->tenant))
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
fn (TenantReview $record): TenantReview => $record,
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->preserveDisabled()
->apply();
$exportExecutivePackAction->tooltip(fn (TenantReview $record): ?string => static::reviewPackGenerationActionTooltip($record->tenant));
return $table
->defaultSort('generated_at', 'desc')
->persistFiltersInSession()
@ -308,7 +287,20 @@ public static function table(Table $table): Table
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
])
->actions([
$exportExecutivePackAction,
UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
fn (TenantReview $record): TenantReview => $record,
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
])
->bulkActions([])
->emptyStateHeading('No tenant reviews yet')
@ -431,50 +423,6 @@ public static function executeCreateReview(array $data): void
$toast->send();
}
/**
* @return array<string, mixed>
*/
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
{
$tenant ??= Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [];
}
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
}
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
{
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
}
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return null;
}
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{
$tenant ??= static::panelTenantContext();
$user = auth()->user();
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant);
}
public static function executeExport(TenantReview $review): void
{
$review->loadMissing(['tenant', 'currentExportReviewPack']);
@ -509,10 +457,6 @@ public static function executeExport(TenantReview $review): void
'include_pii' => true,
'include_operations' => true,
]);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send();
return;
} catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();

View File

@ -232,7 +232,7 @@ private function publishReviewAction(): Actions\Action
private function exportExecutivePackAction(): Actions\Action
{
$action = UiEnforcement::forAction(
return UiEnforcement::forAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
@ -241,17 +241,11 @@ private function exportExecutivePackAction(): Actions\Action
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->disabled(fn (): bool => TenantReviewResource::reviewPackGenerationBlocked($this->record->tenant))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->preserveDisabled()
->apply();
$action->tooltip(fn (): ?string => TenantReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
return $action;
}
private function createNextReviewAction(): Actions\Action

View File

@ -6,8 +6,6 @@
use App\Filament\System\Widgets\ControlTowerHealthIndicator;
use App\Filament\System\Widgets\ControlTowerKpis;
use App\Filament\System\Widgets\CustomerHealthKpis;
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
use App\Filament\System\Widgets\ProductTelemetryKpis;
use App\Filament\System\Widgets\ControlTowerRecentFailures;
use App\Filament\System\Widgets\ControlTowerTopOffenders;
@ -64,12 +62,6 @@ public function getWidgets(): array
{
return [
ControlTowerHealthIndicator::class,
new WidgetConfiguration(CustomerHealthKpis::class, [
'window' => $this->window,
]),
new WidgetConfiguration(CustomerHealthTopWorkspaces::class, [
'window' => $this->window,
]),
new WidgetConfiguration(ControlTowerKpis::class, [
'window' => $this->window,
]),

View File

@ -1,144 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Directory\Concerns;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
use App\Support\SystemConsole\SystemConsoleWindow;
trait BuildsCustomerHealthDecisionData
{
/**
* @param array{
* overall_level: string,
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
* dominant_dimension_keys: list<string>
* } $summary
* @return array{
* overall: array{label: string, color: string, icon: string|null},
* reason: string,
* impact: string,
* recommended_action: string,
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
* window_label: string
* }
*/
protected function buildCustomerHealthDecision(array $summary, SystemConsoleWindow $window, string $surface): array
{
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
$dominantDimensions = collect($summary['dominant_dimension_keys'])
->map(function (string $dimensionKey) use ($summary): ?array {
$dimension = $summary['dimensions'][$dimensionKey] ?? null;
if (! is_array($dimension)) {
return null;
}
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
return [
'label' => $dimension['label'],
'color' => $badge->color,
'icon' => $badge->icon,
];
})
->filter()
->take(2)
->values()
->all();
$dominantLabels = array_map(static fn (array $dimension): string => $dimension['label'], $dominantDimensions);
$primaryDimension = $summary['dominant_dimension_keys'][0] ?? null;
return [
'overall' => [
'label' => $overallBadge->label,
'color' => $overallBadge->color,
'icon' => $overallBadge->icon,
],
'reason' => $this->customerHealthReason($dominantLabels),
'impact' => $this->customerHealthImpact($summary['overall_level'], $primaryDimension),
'recommended_action' => $this->customerHealthRecommendedAction($summary['overall_level'], $primaryDimension, $surface),
'dominant_dimensions' => $dominantDimensions,
'window_label' => SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours',
];
}
/**
* @param list<string> $dominantLabels
*/
protected function customerHealthReason(array $dominantLabels): string
{
if ($dominantLabels === []) {
return 'No active health drivers are pressuring this workspace right now.';
}
$labelPrefix = count($dominantLabels) === 1 ? 'Top driver' : 'Top drivers';
return $labelPrefix.': '.implode(', ', $dominantLabels);
}
protected function customerHealthImpact(string $overallLevel, ?string $primaryDimension): string
{
if ($overallLevel === 'ok') {
return 'Tracked onboarding, provider, operational, governance, review-pack, and engagement signals are currently stable.';
}
if ($overallLevel === 'unknown') {
return 'Some required health truth is missing or stale, so this workspace cannot be treated as healthy yet.';
}
return match ($primaryDimension) {
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $overallLevel === 'critical'
? 'Onboarding readiness is blocked, so this workspace cannot be treated as operationally ready.'
: 'Onboarding readiness still needs follow-up before this workspace can be treated as fully stable.',
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $overallLevel === 'critical'
? 'Default provider consent or verification is blocking reliable tenant management.'
: 'Provider connectivity has degraded and may impact reliable tenant management.',
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => $overallLevel === 'critical'
? 'Failed or stuck operations are actively putting delivery at risk.'
: 'Recent operational noise is starting to erode delivery confidence.',
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $overallLevel === 'critical'
? 'High-severity or expired governance pressure needs immediate review.'
: 'Governance pressure is active and should be reviewed before it escalates.',
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => $overallLevel === 'critical'
? 'Recent review-pack work is unusable or expired, so review readiness is blocked.'
: 'Review-pack readiness is incomplete, so recent review evidence may not be usable yet.',
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $overallLevel === 'critical'
? 'Recent product activity is missing, which suggests active usage may be deteriorating.'
: 'Recent product activity is thinning out and may indicate adoption drift.',
default => $overallLevel === 'critical'
? 'This workspace needs immediate operator follow-up.'
: 'This workspace needs follow-up soon to prevent further drift.',
};
}
protected function customerHealthRecommendedAction(string $overallLevel, ?string $primaryDimension, string $surface): string
{
if ($overallLevel === 'ok') {
return 'Continue normal monitoring from the system dashboard.';
}
return match ($primaryDimension) {
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $surface === 'tenant'
? 'Confirm the tenant onboarding state with the responsible tenant admin and clear the blocking step.'
: 'Open the affected tenant below and confirm which onboarding step is blocked.',
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $surface === 'tenant'
? 'Review connectivity signals below and confirm the default provider consent and verification state.'
: 'Open the affected tenant below and review the default provider connection state.',
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => 'Review recent operations below and triage failed or stuck runs first.',
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $surface === 'tenant'
? 'Review governance findings or exception pressure for this tenant before proceeding.'
: 'Open the affected tenant below and review governance findings or exceptions.',
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => 'Check recent review-pack activity and confirm that a usable pack exists for the current window.',
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $surface === 'tenant'
? 'Confirm whether missing recent product activity is expected for this tenant.'
: 'Confirm whether missing recent product activity is expected across this workspace.',
default => 'Review the diagnostics below to confirm which source truth needs operator follow-up.',
};
}
}

View File

@ -4,25 +4,20 @@
namespace App\Filament\System\Pages\Directory;
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Support\Auth\PlatformCapabilities;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\OperationCatalog;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Pages\Page;
use Illuminate\Support\Collection;
class ViewTenant extends Page
{
use BuildsCustomerHealthDecisionData;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'directory/tenants/{tenant}';
@ -107,26 +102,4 @@ public function runsUrl(): string
{
return SystemOperationRunLinks::index();
}
/**
* @return array{
* overall: array{label: string, color: string, icon: string|null},
* reason: string,
* impact: string,
* recommended_action: string,
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
* window_label: string
* }|null
*/
public function customerHealthDecision(): ?array
{
$window = SystemConsoleWindow::fromNullable(request()->query('window'));
$summary = app(WorkspaceHealthSummaryQuery::class)->summaryForWorkspace((int) $this->tenant->workspace_id, $window);
if (! is_array($summary)) {
return null;
}
return $this->buildCustomerHealthDecision($summary, $window, 'tenant');
}
}

View File

@ -4,25 +4,19 @@
namespace App\Filament\System\Pages\Directory;
use App\Filament\System\Pages\Directory\Concerns\BuildsCustomerHealthDecisionData;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Support\Auth\PlatformCapabilities;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\OperationCatalog;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Pages\Page;
use Illuminate\Support\Collection;
class ViewWorkspace extends Page
{
use BuildsCustomerHealthDecisionData;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'directory/workspaces/{workspace}';
@ -85,34 +79,4 @@ public function runsUrl(): string
{
return SystemOperationRunLinks::index();
}
/**
* @return array<string, mixed>
*/
public function workspaceEntitlementSummary(): array
{
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
}
/**
* @return array{
* overall: array{label: string, color: string, icon: string|null},
* reason: string,
* impact: string,
* recommended_action: string,
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
* window_label: string
* }|null
*/
public function customerHealthDecision(): ?array
{
$window = SystemConsoleWindow::fromNullable(request()->query('window'));
$summary = app(WorkspaceHealthSummaryQuery::class)->summaryForWorkspace($this->workspace, $window);
if (! is_array($summary)) {
return null;
}
return $this->buildCustomerHealthDecision($summary, $window, 'workspace');
}
}

View File

@ -80,9 +80,6 @@ protected function getHeaderActions(): array
$this->pauseRestoreExecuteAction(),
$this->resumeRestoreExecuteAction(),
$this->viewHistoryRestoreExecuteAction(),
$this->pauseAiExecutionAction(),
$this->resumeAiExecutionAction(),
$this->viewHistoryAiExecutionAction(),
];
}
@ -202,21 +199,6 @@ public function viewHistoryRestoreExecuteAction(): Action
return $this->historyActionFor('restore.execute');
}
public function pauseAiExecutionAction(): Action
{
return $this->pauseActionFor('ai.execution');
}
public function resumeAiExecutionAction(): Action
{
return $this->resumeActionFor('ai.execution');
}
public function viewHistoryAiExecutionAction(): Action
{
return $this->historyActionFor('ai.execution');
}
private function pauseActionFor(string $controlKey): Action
{
$label = app(OperationalControlCatalog::class)->label($controlKey);
@ -231,7 +213,7 @@ private function pauseActionFor(string $controlKey): Action
->form($this->pauseFormSchema($controlKey))
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
$actor = $this->controlsActor();
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($controlKey, $data);
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($data);
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
@ -291,7 +273,7 @@ private function resumeActionFor(string $controlKey): Action
->form($this->resumeFormSchema($controlKey))
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
$actor = $this->controlsActor();
[$scopeType, $workspace] = $this->normalizeResumeInput($controlKey, $data);
[$scopeType, $workspace] = $this->normalizeResumeInput($data);
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
->notExpired()
@ -349,8 +331,11 @@ private function pauseFormSchema(string $controlKey): array
return [
Radio::make('scope_type')
->label('Scope')
->options($this->scopeOptions($controlKey))
->default($this->defaultScopeFor($controlKey))
->options([
'global' => 'Global',
'workspace' => 'One workspace',
])
->default('global')
->live()
->required(),
@ -410,8 +395,11 @@ private function resumeFormSchema(string $controlKey): array
return [
Radio::make('scope_type')
->label('Scope')
->options($this->scopeOptions($controlKey))
->default($this->defaultScopeFor($controlKey))
->options([
'global' => 'Global',
'workspace' => 'One workspace',
])
->default('global')
->live()
->required(),
@ -468,9 +456,9 @@ private function controlsActor(): PlatformUser
/**
* @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface}
*/
private function normalizePauseInput(string $controlKey, array $data): array
private function normalizePauseInput(array $data): array
{
[$scopeType, $workspace] = $this->resolveScopeInput($controlKey, $data);
[$scopeType, $workspace] = $this->resolveScopeInput($data);
$reasonText = trim((string) ($data['reason_text'] ?? ''));
if ($reasonText === '') {
@ -497,20 +485,19 @@ private function normalizePauseInput(string $controlKey, array $data): array
/**
* @return array{0: string, 1: ?Workspace}
*/
private function normalizeResumeInput(string $controlKey, array $data): array
private function normalizeResumeInput(array $data): array
{
return $this->resolveScopeInput($controlKey, $data);
return $this->resolveScopeInput($data);
}
/**
* @return array{0: string, 1: ?Workspace}
*/
private function resolveScopeInput(string $controlKey, array $data): array
private function resolveScopeInput(array $data): array
{
$scopeType = (string) ($data['scope_type'] ?? 'global');
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'] ?? ['global'];
if (! in_array($scopeType, $supportedScopes, true)) {
if (! in_array($scopeType, ['global', 'workspace'], true)) {
throw ValidationException::withMessages([
'scope_type' => 'Invalid scope selected.',
]);
@ -539,26 +526,6 @@ private function resolveScopeInput(string $controlKey, array $data): array
return [$scopeType, $workspace];
}
/**
* @return array<string, string>
*/
private function scopeOptions(string $controlKey): array
{
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
return Arr::only([
'global' => 'Global',
'workspace' => 'One workspace',
], $supportedScopes);
}
private function defaultScopeFor(string $controlKey): string
{
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
return $supportedScopes[0] ?? 'global';
}
private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder
{
$query = OperationalControlActivation::query()

View File

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class CustomerHealthKpis extends StatsOverviewWidget
{
protected static bool $isLazy = false;
protected ?string $heading = 'Customer health';
protected int|string|array $columnSpan = 'full';
public ?string $window = null;
/**
* @return array<Stat>
*/
protected function getStats(): array
{
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
$counts = app(WorkspaceHealthSummaryQuery::class)->healthCounts($window);
return [
Stat::make('Healthy', $counts['ok'])
->description(sprintf('Operational stability, review-pack readiness, and engagement freshness honor %s.', $windowLabel))
->color($counts['ok'] > 0 ? 'success' : 'gray'),
Stat::make('Warning', $counts['warn'])
->description('Onboarding readiness, provider health, and governance pressure stay point-in-time.')
->color($counts['warn'] > 0 ? 'warning' : 'gray'),
Stat::make('Critical', $counts['critical'])
->description('Overall workspace health is derived from existing system truth only.')
->color($counts['critical'] > 0 ? 'danger' : 'gray'),
Stat::make('Unknown', $counts['unknown'])
->description('Missing or stale inputs stay explicit instead of silently reading healthy.')
->color('gray'),
];
}
}

View File

@ -1,137 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\SystemConsole\SystemConsoleWindow;
use App\Support\System\SystemOperationRunLinks;
use Filament\Widgets\Widget;
class CustomerHealthTopWorkspaces extends Widget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
protected string $view = 'filament.system.widgets.customer-health-top-workspaces';
public ?string $window = null;
public static function canView(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
return true;
}
if (! static::canOpenRuns($user)) {
return false;
}
$window = SystemConsoleWindow::fromNullable((string) request()->query('window'));
return app(WorkspaceHealthSummaryQuery::class)
->attentionNeeded($window, 10)
->contains(fn (array $summary): bool => static::canAccessNextLink($summary['next_link'], $user));
}
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
$user = auth('platform')->user();
return [
'windowLabel' => $windowLabel,
'rows' => app(WorkspaceHealthSummaryQuery::class)
->attentionNeeded($window, 10)
->filter(fn (array $summary): bool => $user instanceof PlatformUser && static::canAccessNextLink($summary['next_link'], $user))
->map(fn (array $summary): array => $this->presentSummary($summary)),
];
}
/**
* @param array{label: string, url: string} $nextLink
*/
private static function canAccessNextLink(array $nextLink, PlatformUser $user): bool
{
if ($user->hasCapability(PlatformCapabilities::DIRECTORY_VIEW)) {
return true;
}
return static::canOpenRuns($user)
&& $nextLink['url'] === SystemOperationRunLinks::index();
}
private static function canOpenRuns(PlatformUser $user): bool
{
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
}
/**
* @param array{
* workspace_id: int,
* workspace_name: string,
* overall_level: string,
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
* dominant_dimension_keys: list<string>,
* non_ok_dimension_count: int,
* next_link: array{label: string, url: string}
* } $summary
* @return array{
* workspace_id: int,
* workspace_label: string,
* overall: array{label: string, color: string, icon: ?string},
* dominant_copy: string,
* dominant_dimensions: list<array{label: string, color: string, icon: ?string}>,
* next_link: array{label: string, url: string}
* }
*/
private function presentSummary(array $summary): array
{
$dominantDimensions = collect($summary['dominant_dimension_keys'])
->take(2)
->map(function (string $dimensionKey) use ($summary): array {
$dimension = $summary['dimensions'][$dimensionKey];
$badge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $dimension['level']);
return [
'label' => $dimension['label'],
'color' => $badge->color,
'icon' => $badge->icon,
];
})
->values()
->all();
$overallBadge = BadgeRenderer::spec(BadgeDomain::SystemHealth, $summary['overall_level']);
return [
'workspace_id' => $summary['workspace_id'],
'workspace_label' => $summary['workspace_name'],
'overall' => [
'label' => $overallBadge->label,
'color' => $overallBadge->color,
'icon' => $overallBadge->icon,
],
'dominant_copy' => implode(', ', array_column($dominantDimensions, 'label')),
'dominant_dimensions' => $dominantDimensions,
'next_link' => $summary['next_link'],
];
}
}

View File

@ -4,7 +4,6 @@
namespace App\Filament\Widgets\Tenant;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\Tenant;
@ -19,7 +18,6 @@
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
class TenantReviewPackCard extends Widget
@ -68,18 +66,6 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
/** @var ReviewPackService $service */
$service = app(ReviewPackService::class);
$decision = $service->reviewPackGenerationDecisionForTenant($tenant);
if ((bool) ($decision['is_blocked'] ?? false)) {
Notification::make()
->title('Review pack generation unavailable')
->body((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'))
->warning()
->send();
return;
}
$activeRun = $service->checkActiveRun($tenant)
? OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
@ -104,20 +90,10 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
return;
}
try {
$reviewPack = $service->generate($tenant, $user, [
'include_pii' => $includePii,
'include_operations' => $includeOperations,
]);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()
->title('Review pack generation unavailable')
->body($exception->getMessage())
->warning()
->send();
return;
}
$runUrl = $reviewPack->operationRun
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
@ -154,14 +130,6 @@ protected function getViewData(): array
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
$service = app(ReviewPackService::class);
$generationEntitlement = $canManage
? $service->reviewPackGenerationDecisionForTenant($tenant)
: null;
$generationBlocked = (bool) ($generationEntitlement['is_blocked'] ?? false);
$generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null)
? $generationEntitlement['block_reason']
: null;
$latestPack = ReviewPack::query()
->with(['tenantReview', 'operationRun'])
@ -178,8 +146,6 @@ protected function getViewData(): array
'pollingInterval' => null,
'canView' => $canView,
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'downloadUrl' => null,
'failedReason' => null,
'reviewUrl' => null,
@ -228,8 +194,6 @@ protected function getViewData(): array
'pollingInterval' => self::resolvePollingInterval($latestPack),
'canView' => $canView,
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason,
'failedReasonDetail' => $failedReasonDetail,
@ -260,8 +224,6 @@ private function emptyState(): array
'pollingInterval' => null,
'canView' => false,
'canManage' => false,
'generationBlocked' => false,
'generationBlockReason' => null,
'downloadUrl' => null,
'failedReason' => null,
'failedReasonDetail' => null,

View File

@ -1,121 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SupportRequest extends Model
{
use DerivesWorkspaceIdFromTenant;
/** @use HasFactory<\Database\Factories\SupportRequestFactory> */
use HasFactory;
public const string PRIMARY_CONTEXT_TENANT = 'tenant';
public const string PRIMARY_CONTEXT_OPERATION_RUN = 'operation_run';
public const string ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED = 'diagnostic_snapshot_attached';
public const string ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY = 'canonical_context_only';
public const string SEVERITY_LOW = 'low';
public const string SEVERITY_NORMAL = 'normal';
public const string SEVERITY_HIGH = 'high';
public const string SEVERITY_BLOCKING = 'blocking';
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'context_envelope' => 'array',
];
}
/**
* @return array<string, string>
*/
public static function severityOptions(): array
{
return [
self::SEVERITY_LOW => 'Low',
self::SEVERITY_NORMAL => 'Normal',
self::SEVERITY_HIGH => 'High',
self::SEVERITY_BLOCKING => 'Blocking',
];
}
/**
* @return list<string>
*/
public static function severityValues(): array
{
return array_keys(self::severityOptions());
}
/**
* @return list<string>
*/
public static function primaryContextTypes(): array
{
return [
self::PRIMARY_CONTEXT_TENANT,
self::PRIMARY_CONTEXT_OPERATION_RUN,
];
}
/**
* @return list<string>
*/
public static function attachmentModes(): array
{
return [
self::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
self::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
];
}
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<OperationRun, $this>
*/
public function operationRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function initiator(): BelongsTo
{
return $this->belongsTo(User::class, 'initiated_by_user_id');
}
}

View File

@ -4,10 +4,9 @@
namespace App\Services\Audit;
use App\Models\Tenant;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Audit\AuditActionId;
@ -15,7 +14,6 @@
use App\Support\Audit\AuditActorType;
use App\Support\Audit\AuditTargetSnapshot;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
class WorkspaceAuditLogger
{
@ -138,39 +136,4 @@ public function logSupportDiagnosticsOpened(
tenant: $tenant,
);
}
public function logSupportRequestCreated(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
$supportRequest->loadMissing(['tenant.workspace']);
$tenant = $supportRequest->tenant;
if (! $tenant instanceof Tenant) {
throw new InvalidArgumentException('Support requests must belong to a tenant.');
}
return $this->log(
workspace: $tenant->workspace,
action: AuditActionId::SupportRequestCreated,
context: [
'internal_reference' => $supportRequest->internal_reference,
'primary_context_type' => $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? (string) $supportRequest->operation_run_id
: (string) $tenant->getKey(),
'attachment_mode' => $supportRequest->attachment_mode,
'redaction_mode' => (string) data_get($supportRequest->context_envelope, 'redaction_mode', 'default_redacted'),
],
actor: $actor,
status: 'success',
resourceType: 'support_request',
resourceId: (string) $supportRequest->getKey(),
targetLabel: $supportRequest->internal_reference,
summary: 'Support request created for '.$supportRequest->internal_reference,
operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null,
tenant: $tenant,
);
}
}

View File

@ -20,7 +20,6 @@ class RoleCapabilityMap
Capabilities::TENANT_DELETE,
Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::SUPPORT_REQUESTS_CREATE,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE,
@ -66,7 +65,6 @@ class RoleCapabilityMap
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::SUPPORT_REQUESTS_CREATE,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE,
@ -108,7 +106,6 @@ class RoleCapabilityMap
Capabilities::TENANT_VIEW,
Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::SUPPORT_REQUESTS_CREATE,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE,

View File

@ -1,327 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Settings\SettingsResolver;
use Carbon\CarbonInterface;
final class WorkspaceEntitlementResolver
{
public const SETTING_DOMAIN = 'entitlements';
public const SETTING_PLAN_PROFILE = 'plan_profile';
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE = 'managed_tenant_limit_override_value';
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON = 'managed_tenant_limit_override_reason';
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE = 'review_pack_generation_override_value';
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON = 'review_pack_generation_override_reason';
public const KEY_MANAGED_TENANT_ACTIVATION_LIMIT = 'managed_tenant_activation_limit';
public const KEY_REVIEW_PACK_GENERATION_ENABLED = 'review_pack_generation_enabled';
public function __construct(
private SettingsResolver $settingsResolver,
private WorkspacePlanProfileCatalog $planProfileCatalog,
) {}
/**
* @return array{
* plan_profile: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
* decisions: array<string, array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int|bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int|null,
* remaining_capacity: int|null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }>
* }
*/
public function summary(Workspace $workspace): array
{
$planProfile = $this->resolvePlanProfile($workspace);
return [
'plan_profile' => $planProfile,
'decisions' => [
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolve($workspace, self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, $planProfile),
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolve($workspace, self::KEY_REVIEW_PACK_GENERATION_ENABLED, $planProfile),
],
];
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function resolvePlanProfile(Workspace $workspace): array
{
$planProfileId = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_PLAN_PROFILE,
);
return $this->planProfileCatalog->resolve(is_string($planProfileId) ? $planProfileId : null);
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null $planProfile
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int|bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int|null,
* remaining_capacity: int|null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
public function resolve(Workspace $workspace, string $key, ?array $planProfile = null): array
{
$planProfile ??= $this->resolvePlanProfile($workspace);
$lastChanged = $this->lastChangedMetadata($workspace);
return match ($key) {
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolveManagedTenantActivationLimitDecision($workspace, $planProfile, $lastChanged),
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolveReviewPackGenerationDecision($workspace, $planProfile, $lastChanged),
default => throw new \InvalidArgumentException(sprintf('Unknown workspace entitlement key: %s', $key)),
};
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int,
* remaining_capacity: int,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
private function resolveManagedTenantActivationLimitDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
{
$overrideValue = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
);
$overrideReason = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
);
$effectiveValue = is_int($overrideValue['value'])
? $overrideValue['value']
: (int) $planProfile['managed_tenant_limit_default'];
$source = $overrideValue['source'] === 'workspace_override'
? 'workspace_override'
: 'plan_profile_default';
$currentUsage = Tenant::activeQuery()
->where('workspace_id', (int) $workspace->getKey())
->count();
$remainingCapacity = $effectiveValue - $currentUsage;
$isBlocked = $currentUsage >= $effectiveValue;
$rationale = $source === 'workspace_override'
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
: (string) $planProfile['description'];
return [
'workspace_id' => (int) $workspace->getKey(),
'plan_profile_id' => (string) $planProfile['id'],
'plan_profile_label' => (string) $planProfile['label'],
'plan_profile_description' => (string) $planProfile['description'],
'key' => self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
'effective_value' => $effectiveValue,
'source' => $source,
'rationale' => $rationale,
'current_usage' => $currentUsage,
'remaining_capacity' => $remainingCapacity,
'is_blocked' => $isBlocked,
'block_reason' => $isBlocked
? $this->managedTenantLimitBlockReason($currentUsage, $effectiveValue, $source, $planProfile, $rationale)
: null,
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: null,
* remaining_capacity: null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
private function resolveReviewPackGenerationDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
{
$overrideValue = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
);
$overrideReason = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
);
$effectiveValue = is_bool($overrideValue['value'])
? $overrideValue['value']
: (bool) $planProfile['review_pack_generation_default'];
$source = $overrideValue['source'] === 'workspace_override'
? 'workspace_override'
: 'plan_profile_default';
$rationale = $source === 'workspace_override'
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
: (string) $planProfile['description'];
return [
'workspace_id' => (int) $workspace->getKey(),
'plan_profile_id' => (string) $planProfile['id'],
'plan_profile_label' => (string) $planProfile['label'],
'plan_profile_description' => (string) $planProfile['description'],
'key' => self::KEY_REVIEW_PACK_GENERATION_ENABLED,
'effective_value' => $effectiveValue,
'source' => $source,
'rationale' => $rationale,
'current_usage' => null,
'remaining_capacity' => null,
'is_blocked' => ! $effectiveValue,
'block_reason' => $effectiveValue
? null
: $this->reviewPackGenerationBlockReason($source, $planProfile, $rationale),
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
*/
private function lastChangedMetadata(Workspace $workspace): array
{
$record = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', self::SETTING_DOMAIN)
->whereIn('key', [
self::SETTING_PLAN_PROFILE,
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
])
->whereNotNull('updated_by_user_id')
->with('updatedByUser:id,name')
->latest('updated_at')
->latest('id')
->first();
if (! $record instanceof WorkspaceSetting) {
return [
'last_changed_at' => null,
'last_changed_by' => null,
];
}
return [
'last_changed_at' => $record->updated_at,
'last_changed_by' => $record->updatedByUser?->name,
];
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
*/
private function managedTenantLimitBlockReason(int $currentUsage, int $effectiveValue, string $source, array $planProfile, ?string $rationale): string
{
$prefix = $source === 'workspace_override'
? 'This workspace override currently allows'
: sprintf('The %s plan profile currently allows', $planProfile['label']);
$message = sprintf(
'%s %d active managed tenant%s, and this workspace already has %d active managed tenant%s.',
$prefix,
$effectiveValue,
$effectiveValue === 1 ? '' : 's',
$currentUsage,
$currentUsage === 1 ? '' : 's',
);
if ($source === 'workspace_override' && $rationale !== null) {
$message .= ' Reason: '.$rationale;
}
return $message;
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
*/
private function reviewPackGenerationBlockReason(string $source, array $planProfile, ?string $rationale): string
{
$message = $source === 'workspace_override'
? 'Review pack generation is disabled by workspace override.'
: sprintf('Review pack generation is disabled by the %s plan profile.', $planProfile['label']);
if ($source === 'workspace_override' && $rationale !== null) {
$message .= ' Reason: '.$rationale;
}
return $message;
}
}

View File

@ -1,104 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
final class WorkspacePlanProfileCatalog
{
/**
* @var array<string, array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
*/
private const PROFILES = [
'starter' => [
'id' => 'starter',
'label' => 'Starter',
'description' => 'Minimal allowance for early workspace access and low-volume operations.',
'managed_tenant_limit_default' => 1,
'review_pack_generation_default' => false,
'is_default' => false,
],
'standard' => [
'id' => 'standard',
'label' => 'Standard',
'description' => 'Balanced defaults for most managed workspaces.',
'managed_tenant_limit_default' => 25,
'review_pack_generation_default' => true,
'is_default' => true,
],
'scale' => [
'id' => 'scale',
'label' => 'Scale',
'description' => 'Higher managed-tenant capacity for larger workspace portfolios.',
'managed_tenant_limit_default' => 100,
'review_pack_generation_default' => true,
'is_default' => false,
],
];
/**
* @return list<array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
*/
public function all(): array
{
return array_values(self::PROFILES);
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function default(): array
{
return self::PROFILES[self::defaultProfileId()];
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null
*/
public function find(?string $id): ?array
{
if ($id === null) {
return null;
}
return self::PROFILES[$id] ?? null;
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function resolve(?string $id): array
{
return $this->find($id) ?? $this->default();
}
/**
* @return array<string, string>
*/
public function optionLabels(): array
{
return array_map(
static fn (array $profile): string => $profile['label'],
self::PROFILES,
);
}
/**
* @return list<string>
*/
public static function profileIds(): array
{
return array_keys(self::PROFILES);
}
public static function defaultProfileId(): string
{
foreach (self::PROFILES as $id => $profile) {
if ($profile['is_default']) {
return $id;
}
}
throw new \RuntimeException('Workspace plan profile catalog is missing a default profile.');
}
}

View File

@ -4,7 +4,6 @@
namespace App\Services;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Jobs\GenerateReviewPackJob;
use App\Models\EvidenceSnapshot;
@ -14,7 +13,6 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Evidence\EvidenceResolutionRequest;
use App\Services\Evidence\EvidenceSnapshotResolver;
use App\Support\Audit\AuditActionId;
@ -30,7 +28,6 @@ public function __construct(
private OperationRunService $operationRunService,
private EvidenceSnapshotResolver $snapshotResolver,
private WorkspaceAuditLogger $auditLogger,
private WorkspaceEntitlementResolver $workspaceEntitlementResolver,
private ProductTelemetryRecorder $productTelemetryRecorder,
) {}
@ -52,8 +49,6 @@ public function __construct(
*/
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
{
$this->assertReviewPackGenerationAllowed($tenant);
$options = $this->normalizeOptions($options);
$snapshot = $this->resolveSnapshot($tenant);
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
@ -143,8 +138,6 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.');
}
$this->assertReviewPackGenerationAllowed($tenant);
$options = $this->normalizeOptions($options);
$fingerprint = $this->computeFingerprintForReview($review, $options);
$existing = $this->findExistingPackForReview($review, $fingerprint);
@ -246,17 +239,6 @@ public function generateDownloadUrl(ReviewPack $pack): string
);
}
/**
* @return array<string, mixed>
*/
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
{
return $this->workspaceEntitlementResolver->resolve(
$tenant->workspace,
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
}
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void
{
$this->productTelemetryRecorder->record(
@ -332,17 +314,6 @@ private function normalizeOptions(array $options): array
];
}
private function assertReviewPackGenerationAllowed(Tenant $tenant): void
{
$decision = $this->reviewPackGenerationDecisionForTenant($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return;
}
throw new WorkspaceEntitlementBlockedException($decision);
}
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
{
$data = [

View File

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiDataClassification: string
{
case ProductKnowledge = 'product_knowledge';
case OperationalMetadata = 'operational_metadata';
case RedactedSupportSummary = 'redacted_support_summary';
case PersonalData = 'personal_data';
case CustomerConfidential = 'customer_confidential';
case RawProviderPayload = 'raw_provider_payload';
public function label(): string
{
return match ($this) {
self::ProductKnowledge => 'Product knowledge',
self::OperationalMetadata => 'Operational metadata',
self::RedactedSupportSummary => 'Redacted support summary',
self::PersonalData => 'Personal data',
self::CustomerConfidential => 'Customer confidential',
self::RawProviderPayload => 'Raw provider payload',
};
}
}

View File

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
final class AiDecisionAuditMetadataFactory
{
/**
* @return array<string, mixed>
*/
public function make(AiExecutionRequest $request, AiExecutionDecision $decision): array
{
return array_filter([
'use_case_key' => $decision->useCaseKey,
'decision_outcome' => $decision->outcome,
'decision_reason' => $decision->reasonCode->value,
'workspace_ai_policy_mode' => $decision->workspaceAiPolicyMode,
'requested_provider_class' => $decision->requestedProviderClass,
'data_classifications' => $decision->dataClassifications,
'source_family' => $decision->sourceFamily,
'workspace_id' => $request->workspace?->getKey(),
'tenant_id' => $request->tenant?->getKey(),
'context_fingerprint' => $this->normalizedFingerprint($request->contextFingerprint),
'matched_operational_control_scope' => $decision->matchedOperationalControlScope,
], static fn (mixed $value): bool => $value !== null);
}
private function normalizedFingerprint(?string $contextFingerprint): ?string
{
if (! is_string($contextFingerprint)) {
return null;
}
$normalized = trim($contextFingerprint);
return $normalized === '' ? null : $normalized;
}
}

View File

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiDecisionReasonCode: string
{
case Allowed = 'allowed';
case MissingWorkspaceContext = 'missing_workspace_context';
case TenantOutsideWorkspace = 'tenant_outside_workspace';
case OperationalControlPaused = 'operational_control_paused';
case WorkspacePolicyDisabled = 'workspace_policy_disabled';
case UnregisteredUseCase = 'unregistered_use_case';
case ProviderClassBlocked = 'provider_class_blocked';
case DataClassificationBlocked = 'data_classification_blocked';
case SourceFamilyMismatch = 'source_family_mismatch';
}

View File

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
use App\Support\Audit\AuditActionId;
final readonly class AiExecutionDecision
{
/**
* @param list<string> $dataClassifications
* @param array<string, mixed> $auditMetadata
*/
public function __construct(
public string $outcome,
public AiDecisionReasonCode $reasonCode,
public string $workspaceAiPolicyMode,
public ?string $matchedOperationalControlScope,
public string $useCaseKey,
public string $requestedProviderClass,
public array $dataClassifications,
public string $sourceFamily,
public AuditActionId $auditAction,
public array $auditMetadata,
) {}
public function isAllowed(): bool
{
return $this->outcome === 'allowed';
}
public function isBlocked(): bool
{
return $this->outcome === 'blocked';
}
}

View File

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
final readonly class AiExecutionRequest
{
/**
* @param list<string> $dataClassifications
*/
public function __construct(
public ?Workspace $workspace,
public ?Tenant $tenant,
public User|PlatformUser|null $actor,
public string $useCaseKey,
public string $requestedProviderClass,
public array $dataClassifications,
public string $sourceFamily,
public ?string $callerSurface = null,
public ?string $contextFingerprint = null,
) {}
}

View File

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiPolicyMode: string
{
case Disabled = 'disabled';
case PrivateOnly = 'private_only';
public function label(): string
{
return match ($this) {
self::Disabled => 'Disabled',
self::PrivateOnly => 'Private only',
};
}
public function summary(): string
{
return match ($this) {
self::Disabled => 'No AI execution is allowed for this workspace.',
self::PrivateOnly => 'Only approved internal drafts may use private-only AI for approved use cases.',
};
}
/**
* @return array<string, string>
*/
public static function optionLabels(): array
{
return array_reduce(
self::cases(),
static function (array $labels, self $mode): array {
$labels[$mode->value] = $mode->label();
return $labels;
},
[],
);
}
}

View File

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiProviderClass: string
{
case LocalPrivate = 'local_private';
case ExternalPublic = 'external_public';
public function label(): string
{
return match ($this) {
self::LocalPrivate => 'Local private',
self::ExternalPublic => 'External public',
};
}
}

View File

@ -1,126 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
final class AiUseCaseCatalog
{
/**
* @var array<string, array{
* key: string,
* label: string,
* future_consumer: string,
* visibility: string,
* allowed_provider_classes: list<string>,
* allowed_data_classifications: list<string>,
* source_family: string,
* tenant_context_permitted: bool
* }>
*/
private const USE_CASES = [
'product_knowledge.answer_draft' => [
'key' => 'product_knowledge.answer_draft',
'label' => 'Product knowledge answer draft',
'future_consumer' => 'ContextualHelpResolver',
'visibility' => 'internal_only_draft',
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
'allowed_data_classifications' => [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
'source_family' => 'product_knowledge',
'tenant_context_permitted' => false,
],
'support_diagnostics.summary_draft' => [
'key' => 'support_diagnostics.summary_draft',
'label' => 'Support diagnostics summary draft',
'future_consumer' => 'SupportDiagnosticBundleBuilder',
'visibility' => 'internal_only_draft',
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
'allowed_data_classifications' => [AiDataClassification::RedactedSupportSummary->value],
'source_family' => 'support_diagnostics',
'tenant_context_permitted' => true,
],
];
/**
* @return list<array{
* key: string,
* label: string,
* future_consumer: string,
* visibility: string,
* allowed_provider_classes: list<string>,
* allowed_data_classifications: list<string>,
* source_family: string,
* tenant_context_permitted: bool
* }>
*/
public function all(): array
{
return array_values(self::USE_CASES);
}
/**
* @return array{
* key: string,
* label: string,
* future_consumer: string,
* visibility: string,
* allowed_provider_classes: list<string>,
* allowed_data_classifications: list<string>,
* source_family: string,
* tenant_context_permitted: bool
* }|null
*/
public function find(string $key): ?array
{
return self::USE_CASES[$key] ?? null;
}
/**
* @return list<string>
*/
public function labels(): array
{
return array_map(
static fn (array $definition): string => $definition['label'],
$this->all(),
);
}
/**
* @return list<string>
*/
public function allowedProviderClassLabelsForMode(AiPolicyMode $mode): array
{
if ($mode === AiPolicyMode::Disabled) {
return [];
}
$labels = [];
foreach ($this->all() as $definition) {
foreach ($definition['allowed_provider_classes'] as $providerClass) {
$labels[$providerClass] = AiProviderClass::from($providerClass)->label();
}
}
return array_values($labels);
}
/**
* @return list<string>
*/
public function blockedDataClassificationLabels(): array
{
return array_map(
static fn (AiDataClassification $classification): string => $classification->label(),
[
AiDataClassification::PersonalData,
AiDataClassification::CustomerConfidential,
AiDataClassification::RawProviderPayload,
],
);
}
}

View File

@ -1,181 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Settings\SettingsResolver;
use App\Support\Audit\AuditActionId;
use App\Support\OperationalControls\OperationalControlEvaluator;
final class GovernedAiExecutionBoundary
{
public function __construct(
private readonly AiUseCaseCatalog $useCaseCatalog,
private readonly SettingsResolver $settingsResolver,
private readonly OperationalControlEvaluator $operationalControls,
private readonly AiDecisionAuditMetadataFactory $auditMetadataFactory,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {}
public function evaluate(AiExecutionRequest $request): AiExecutionDecision
{
$decision = $this->decisionFor($request);
$metadata = $this->auditMetadataFactory->make($request, $decision);
$decision = new AiExecutionDecision(
outcome: $decision->outcome,
reasonCode: $decision->reasonCode,
workspaceAiPolicyMode: $decision->workspaceAiPolicyMode,
matchedOperationalControlScope: $decision->matchedOperationalControlScope,
useCaseKey: $decision->useCaseKey,
requestedProviderClass: $decision->requestedProviderClass,
dataClassifications: $decision->dataClassifications,
sourceFamily: $decision->sourceFamily,
auditAction: $decision->auditAction,
auditMetadata: $metadata,
);
if ($request->workspace !== null) {
$definition = $this->useCaseCatalog->find($request->useCaseKey);
$this->workspaceAuditLogger->log(
workspace: $request->workspace,
action: $decision->auditAction,
context: ['metadata' => $decision->auditMetadata],
actor: $request->actor,
status: $decision->isAllowed() ? 'success' : 'blocked',
resourceType: 'ai_use_case',
resourceId: $request->useCaseKey,
targetLabel: $definition['label'] ?? $request->useCaseKey,
summary: 'AI execution decision evaluated',
tenant: $request->tenant,
);
}
return $decision;
}
private function decisionFor(AiExecutionRequest $request): AiExecutionDecision
{
if ($request->workspace === null) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::MissingWorkspaceContext,
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
);
}
if ($request->tenant !== null && (int) $request->tenant->workspace_id !== (int) $request->workspace->getKey()) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::TenantOutsideWorkspace,
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
);
}
$controlDecision = $this->operationalControls->evaluate('ai.execution', $request->workspace);
if ($controlDecision->isPaused()) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::OperationalControlPaused,
workspaceAiPolicyMode: $this->resolvedPolicyMode($request),
matchedOperationalControlScope: $controlDecision->matchedScopeType,
);
}
$policyMode = $this->resolvedPolicyMode($request);
if ($policyMode === AiPolicyMode::Disabled->value) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::WorkspacePolicyDisabled,
workspaceAiPolicyMode: $policyMode,
);
}
$definition = $this->useCaseCatalog->find($request->useCaseKey);
if ($definition === null) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::UnregisteredUseCase,
workspaceAiPolicyMode: $policyMode,
);
}
if ($definition['source_family'] !== $request->sourceFamily) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::SourceFamilyMismatch,
workspaceAiPolicyMode: $policyMode,
);
}
if (! in_array($request->requestedProviderClass, $definition['allowed_provider_classes'], true)) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::ProviderClassBlocked,
workspaceAiPolicyMode: $policyMode,
);
}
foreach ($request->dataClassifications as $classification) {
if (! in_array($classification, $definition['allowed_data_classifications'], true)) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::DataClassificationBlocked,
workspaceAiPolicyMode: $policyMode,
);
}
}
return new AiExecutionDecision(
outcome: 'allowed',
reasonCode: AiDecisionReasonCode::Allowed,
workspaceAiPolicyMode: $policyMode,
matchedOperationalControlScope: null,
useCaseKey: $request->useCaseKey,
requestedProviderClass: $request->requestedProviderClass,
dataClassifications: $request->dataClassifications,
sourceFamily: $request->sourceFamily,
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
auditMetadata: [],
);
}
private function resolvedPolicyMode(AiExecutionRequest $request): string
{
if ($request->workspace === null) {
return AiPolicyMode::Disabled->value;
}
$resolved = $this->settingsResolver->resolveValue($request->workspace, 'ai', 'policy_mode');
return is_string($resolved) && $resolved !== ''
? $resolved
: AiPolicyMode::Disabled->value;
}
private function blockedDecision(
AiExecutionRequest $request,
AiDecisionReasonCode $reasonCode,
string $workspaceAiPolicyMode,
?string $matchedOperationalControlScope = null,
): AiExecutionDecision {
return new AiExecutionDecision(
outcome: 'blocked',
reasonCode: $reasonCode,
workspaceAiPolicyMode: $workspaceAiPolicyMode,
matchedOperationalControlScope: $matchedOperationalControlScope,
useCaseKey: $request->useCaseKey,
requestedProviderClass: $request->requestedProviderClass,
dataClassifications: $request->dataClassifications,
sourceFamily: $request->sourceFamily,
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
auditMetadata: [],
);
}
}

View File

@ -100,8 +100,6 @@ enum AuditActionId: string
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
case SupportRequestCreated = 'support_request.created';
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
case OperationalControlPaused = 'operational_control.paused';
case OperationalControlUpdated = 'operational_control.updated';
case OperationalControlResumed = 'operational_control.resumed';
@ -243,8 +241,6 @@ private static function labels(): array
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',
self::OperationalControlResumed->value => 'Operational control resumed',
@ -331,8 +327,6 @@ private static function summaries(): array
self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',
self::OperationalControlResumed->value => 'Operational control resumed',

View File

@ -72,9 +72,6 @@ class Capabilities
// Support diagnostics
public const SUPPORT_DIAGNOSTICS_VIEW = 'support_diagnostics.view';
// Support requests
public const SUPPORT_REQUESTS_CREATE = 'support_requests.create';
// Inventory
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';

View File

@ -1,112 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\CustomerHealth;
use InvalidArgumentException;
final class CustomerHealthDimensionCatalog
{
public const string ONBOARDING_READINESS = 'onboarding_readiness';
public const string PROVIDER_CONNECTION_HEALTH = 'provider_connection_health';
public const string OPERATIONAL_STABILITY = 'operational_stability';
public const string GOVERNANCE_PRESSURE = 'governance_pressure';
public const string REVIEW_PACK_READINESS = 'review_pack_readiness';
public const string ENGAGEMENT_FRESHNESS = 'engagement_freshness';
/**
* @var array<string, array{label: string, windowed: bool}>
*/
private const DEFINITIONS = [
self::ONBOARDING_READINESS => [
'label' => 'Onboarding readiness',
'windowed' => false,
],
self::PROVIDER_CONNECTION_HEALTH => [
'label' => 'Provider connection health',
'windowed' => false,
],
self::OPERATIONAL_STABILITY => [
'label' => 'Operational stability',
'windowed' => true,
],
self::GOVERNANCE_PRESSURE => [
'label' => 'Governance pressure',
'windowed' => false,
],
self::REVIEW_PACK_READINESS => [
'label' => 'Review-pack readiness',
'windowed' => true,
],
self::ENGAGEMENT_FRESHNESS => [
'label' => 'Engagement freshness',
'windowed' => true,
],
];
/**
* @return list<string>
*/
public function names(): array
{
return array_keys(self::DEFINITIONS);
}
/**
* @return array{label: string, windowed: bool}
*/
public function definition(string $dimension): array
{
$definition = self::DEFINITIONS[$dimension] ?? null;
if (! is_array($definition)) {
throw new InvalidArgumentException("Unknown customer health dimension [{$dimension}].");
}
return $definition;
}
public function label(string $dimension): string
{
return $this->definition($dimension)['label'];
}
public function isWindowed(string $dimension): bool
{
return $this->definition($dimension)['windowed'];
}
/**
* @return array<string, string>
*/
public function visibleDimensions(): array
{
$dimensions = [];
foreach ($this->names() as $dimension) {
$dimensions[$dimension] = $this->label($dimension);
}
return $dimensions;
}
/**
* @param list<string> $levels
*/
public function resolveOverallLevel(array $levels): string
{
foreach (['critical', 'warn', 'unknown'] as $level) {
if (in_array($level, $levels, true)) {
return $level;
}
}
return 'ok';
}
}

View File

@ -1,766 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\CustomerHealth;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProductUsageEvent;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\Workspace;
use App\Support\Onboarding\OnboardingLifecycleState;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Auth\PlatformCapabilities;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\StuckRunClassifier;
use App\Support\SystemConsole\SystemConsoleWindow;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
final class WorkspaceHealthSummaryQuery
{
public function __construct(
private readonly CustomerHealthDimensionCatalog $dimensionCatalog,
private readonly StuckRunClassifier $stuckRunClassifier,
) {}
/**
* @return Collection<int, array{
* workspace_id: int,
* workspace_name: string,
* overall_level: string,
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
* dominant_dimension_keys: list<string>,
* non_ok_dimension_count: int,
* next_link: array{label: string, url: string}
* }>
*/
public function summaries(SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): Collection
{
$resolvedWindow = $this->resolveWindow($window);
$now ??= CarbonImmutable::now();
$startAt = $resolvedWindow->startAt($now);
$workspaces = Workspace::query()
->whereNull('archived_at')
->orderBy('name')
->orderBy('id')
->get(['id', 'name']);
if ($workspaces->isEmpty()) {
return collect();
}
$workspaceIds = $workspaces
->pluck('id')
->map(static fn (mixed $workspaceId): int => (int) $workspaceId)
->all();
$activeTenants = Tenant::query()
->whereIn('workspace_id', $workspaceIds)
->whereNull('deleted_at')
->where('status', '!=', Tenant::STATUS_ARCHIVED)
->orderBy('name')
->orderBy('id')
->get(['id', 'workspace_id', 'external_id', 'name', 'status']);
$tenantsByWorkspace = $activeTenants->groupBy(static fn (Tenant $tenant): int => (int) $tenant->workspace_id);
$latestOnboardingSessions = TenantOnboardingSession::query()
->whereIn('workspace_id', $workspaceIds)
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
->orderByDesc('updated_at')
->orderByDesc('id')
->get(['id', 'workspace_id', 'tenant_id', 'lifecycle_state', 'updated_at', 'created_at'])
->groupBy(static fn (TenantOnboardingSession $session): int => (int) $session->workspace_id)
->map(static fn (Collection $sessions): ?TenantOnboardingSession => $sessions->first());
$providerConnectionsByWorkspace = ProviderConnection::query()
->whereIn('workspace_id', $workspaceIds)
->where('is_default', true)
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
->orderByDesc('is_enabled')
->orderBy('id')
->get([
'id',
'workspace_id',
'tenant_id',
'is_enabled',
'consent_status',
'verification_status',
])
->groupBy(static fn (ProviderConnection $connection): int => (int) $connection->workspace_id);
$recentRunCounts = $this->groupedCounts(
OperationRun::query()
->whereIn('workspace_id', $workspaceIds)
->where('created_at', '>=', $startAt)
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
);
$recentFailedRunCounts = $this->groupedCounts(
OperationRun::query()
->whereIn('workspace_id', $workspaceIds)
->where('created_at', '>=', $startAt)
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Failed->value)
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
);
$recentStuckRunCounts = $this->groupedCounts(
$this->stuckRunClassifier->apply(
OperationRun::query()
->whereIn('workspace_id', $workspaceIds)
->where('created_at', '>=', $startAt)
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
}),
$now,
)
);
$activeHighSeverityFindingCounts = $this->groupedCounts(
Finding::query()
->whereIn('workspace_id', $workspaceIds)
->whereIn('severity', Finding::highSeverityValues())
->whereIn('status', Finding::openStatusesForQuery())
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
);
$anyGovernanceFindingCounts = $this->groupedCounts(
Finding::query()
->whereIn('workspace_id', $workspaceIds)
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
);
$overdueHighSeverityFindingCounts = $this->groupedCounts(
Finding::query()
->whereIn('workspace_id', $workspaceIds)
->whereIn('severity', Finding::highSeverityValues())
->whereIn('status', Finding::openStatusesForQuery())
->whereNotNull('due_at')
->where('due_at', '<', $now)
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
);
$warningExceptionCounts = $this->groupedCounts(
FindingException::query()
->whereIn('workspace_id', $workspaceIds)
->where(function (Builder $query): void {
$query
->whereIn('status', [
FindingException::STATUS_PENDING,
FindingException::STATUS_EXPIRING,
])
->orWhere('current_validity_state', FindingException::VALIDITY_EXPIRING);
})
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
);
$criticalExceptionCounts = $this->groupedCounts(
FindingException::query()
->whereIn('workspace_id', $workspaceIds)
->where(function (Builder $query): void {
$query
->whereIn('status', [
FindingException::STATUS_EXPIRED,
FindingException::STATUS_REVOKED,
])
->orWhereIn('current_validity_state', [
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_REVOKED,
FindingException::VALIDITY_REJECTED,
FindingException::VALIDITY_MISSING_SUPPORT,
]);
})
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
);
$reviewPackRequestCounts = $this->groupedCounts(
ProductUsageEvent::query()
->whereIn('workspace_id', $workspaceIds)
->where('event_name', ProductUsageEventCatalog::REVIEW_PACK_REQUESTED)
->where('occurred_at', '>=', $startAt)
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
);
$recentReviewPacks = ReviewPack::query()
->whereIn('workspace_id', $workspaceIds)
->where('created_at', '>=', $startAt)
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
->orderByDesc('created_at')
->orderByDesc('id')
->get(['id', 'workspace_id', 'tenant_id', 'status', 'expires_at', 'created_at'])
->groupBy(static fn (ReviewPack $reviewPack): int => (int) $reviewPack->workspace_id)
->map(static fn (Collection $reviewPacks): ?ReviewPack => $reviewPacks->first());
$recentUsageEventCounts = $this->groupedCounts(
ProductUsageEvent::query()
->whereIn('workspace_id', $workspaceIds)
->where('occurred_at', '>=', $startAt)
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
);
$historicalUsageEventCounts = $this->groupedCounts(
ProductUsageEvent::query()
->whereIn('workspace_id', $workspaceIds)
->where(function (Builder $query): void {
$this->constrainToActiveTenantTruth($query);
})
);
return $workspaces
->map(function (Workspace $workspace) use (
$tenantsByWorkspace,
$latestOnboardingSessions,
$providerConnectionsByWorkspace,
$recentRunCounts,
$recentFailedRunCounts,
$recentStuckRunCounts,
$activeHighSeverityFindingCounts,
$anyGovernanceFindingCounts,
$overdueHighSeverityFindingCounts,
$warningExceptionCounts,
$criticalExceptionCounts,
$reviewPackRequestCounts,
$recentReviewPacks,
$recentUsageEventCounts,
$historicalUsageEventCounts,
$resolvedWindow,
$now,
): array {
$workspaceId = (int) $workspace->getKey();
/** @var Collection<int, Tenant> $workspaceTenants */
$workspaceTenants = $tenantsByWorkspace->get($workspaceId, collect());
/** @var TenantOnboardingSession|null $latestOnboardingSession */
$latestOnboardingSession = $latestOnboardingSessions->get($workspaceId);
/** @var Collection<int, ProviderConnection> $providerConnections */
$providerConnections = $providerConnectionsByWorkspace->get($workspaceId, collect());
/** @var ReviewPack|null $latestReviewPack */
$latestReviewPack = $recentReviewPacks->get($workspaceId);
$dimensions = $this->buildDimensions(
tenants: $workspaceTenants,
latestOnboardingSession: $latestOnboardingSession,
providerConnections: $providerConnections,
recentRunCount: $this->countForWorkspace($recentRunCounts, $workspaceId),
recentFailedRunCount: $this->countForWorkspace($recentFailedRunCounts, $workspaceId),
recentStuckRunCount: $this->countForWorkspace($recentStuckRunCounts, $workspaceId),
activeHighSeverityFindingCount: $this->countForWorkspace($activeHighSeverityFindingCounts, $workspaceId),
anyGovernanceFindingCount: $this->countForWorkspace($anyGovernanceFindingCounts, $workspaceId),
overdueHighSeverityFindingCount: $this->countForWorkspace($overdueHighSeverityFindingCounts, $workspaceId),
warningExceptionCount: $this->countForWorkspace($warningExceptionCounts, $workspaceId),
criticalExceptionCount: $this->countForWorkspace($criticalExceptionCounts, $workspaceId),
reviewPackRequestCount: $this->countForWorkspace($reviewPackRequestCounts, $workspaceId),
latestReviewPack: $latestReviewPack,
recentUsageEventCount: $this->countForWorkspace($recentUsageEventCounts, $workspaceId),
historicalUsageEventCount: $this->countForWorkspace($historicalUsageEventCounts, $workspaceId),
now: $now,
);
$overallLevel = $this->dimensionCatalog->resolveOverallLevel(
array_map(static fn (array $dimension): string => $dimension['level'], $dimensions),
);
$dominantDimensionKeys = $this->dominantDimensionKeys($dimensions);
return [
'workspace_id' => $workspaceId,
'workspace_name' => (string) $workspace->name,
'overall_level' => $overallLevel,
'dimensions' => $dimensions,
'dominant_dimension_keys' => $dominantDimensionKeys,
'non_ok_dimension_count' => count(array_filter(
$dimensions,
static fn (array $dimension): bool => $dimension['level'] !== 'ok',
)),
'next_link' => $this->nextLink(
workspace: $workspace,
tenants: $workspaceTenants,
dominantDimensionKeys: $dominantDimensionKeys,
window: $resolvedWindow,
),
];
})
->values();
}
/**
* @return array{
* workspace_id: int,
* workspace_name: string,
* overall_level: string,
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
* dominant_dimension_keys: list<string>,
* non_ok_dimension_count: int,
* next_link: array{label: string, url: string}
* }|null
*/
public function summaryForWorkspace(Workspace|int $workspace, SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): ?array
{
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->getKey() : (int) $workspace;
/** @var array{
* workspace_id: int,
* workspace_name: string,
* overall_level: string,
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
* dominant_dimension_keys: list<string>,
* non_ok_dimension_count: int,
* next_link: array{label: string, url: string}
* }|null $summary
*/
$summary = $this->summaries($window, $now)->firstWhere('workspace_id', $workspaceId);
return $summary;
}
/**
* @return array{ok: int, warn: int, critical: int, unknown: int}
*/
public function healthCounts(SystemConsoleWindow|string|null $window = null, ?CarbonImmutable $now = null): array
{
$counts = [
'ok' => 0,
'warn' => 0,
'critical' => 0,
'unknown' => 0,
];
foreach ($this->summaries($window, $now) as $summary) {
$counts[$summary['overall_level']]++;
}
return $counts;
}
/**
* @return Collection<int, array{
* workspace_id: int,
* workspace_name: string,
* overall_level: string,
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
* dominant_dimension_keys: list<string>,
* non_ok_dimension_count: int,
* next_link: array{label: string, url: string}
* }>
*/
public function attentionNeeded(SystemConsoleWindow|string|null $window = null, int $limit = 10, ?CarbonImmutable $now = null): Collection
{
return $this->summaries($window, $now)
->filter(static fn (array $summary): bool => $summary['overall_level'] !== 'ok')
->sort(function (array $left, array $right): int {
$severityComparison = $this->levelRank($right['overall_level']) <=> $this->levelRank($left['overall_level']);
if ($severityComparison !== 0) {
return $severityComparison;
}
$nonOkComparison = $right['non_ok_dimension_count'] <=> $left['non_ok_dimension_count'];
if ($nonOkComparison !== 0) {
return $nonOkComparison;
}
$nameComparison = strcasecmp($left['workspace_name'], $right['workspace_name']);
if ($nameComparison !== 0) {
return $nameComparison;
}
return $left['workspace_id'] <=> $right['workspace_id'];
})
->values()
->take(max(1, $limit));
}
private function resolveWindow(SystemConsoleWindow|string|null $window): SystemConsoleWindow
{
if ($window instanceof SystemConsoleWindow) {
return $window;
}
return SystemConsoleWindow::fromNullable(is_string($window) ? $window : null);
}
/**
* @param Collection<int, Tenant> $tenants
* @param Collection<int, ProviderConnection> $providerConnections
* @return array<string, array{label: string, level: string, windowed: bool}>
*/
private function buildDimensions(
Collection $tenants,
?TenantOnboardingSession $latestOnboardingSession,
Collection $providerConnections,
int $recentRunCount,
int $recentFailedRunCount,
int $recentStuckRunCount,
int $activeHighSeverityFindingCount,
int $anyGovernanceFindingCount,
int $overdueHighSeverityFindingCount,
int $warningExceptionCount,
int $criticalExceptionCount,
int $reviewPackRequestCount,
?ReviewPack $latestReviewPack,
int $recentUsageEventCount,
int $historicalUsageEventCount,
CarbonImmutable $now,
): array {
$levels = [
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => $this->onboardingReadinessLevel($tenants, $latestOnboardingSession),
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => $this->providerConnectionHealthLevel($providerConnections),
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => $this->operationalStabilityLevel($recentRunCount, $recentFailedRunCount, $recentStuckRunCount),
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => $this->governancePressureLevel(
activeHighSeverityFindingCount: $activeHighSeverityFindingCount,
anyGovernanceFindingCount: $anyGovernanceFindingCount,
overdueHighSeverityFindingCount: $overdueHighSeverityFindingCount,
warningExceptionCount: $warningExceptionCount,
criticalExceptionCount: $criticalExceptionCount,
),
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => $this->reviewPackReadinessLevel($reviewPackRequestCount, $latestReviewPack, $now),
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => $this->engagementFreshnessLevel($recentUsageEventCount, $historicalUsageEventCount),
];
$dimensions = [];
foreach ($this->dimensionCatalog->visibleDimensions() as $dimensionKey => $label) {
$dimensions[$dimensionKey] = [
'label' => $label,
'level' => $levels[$dimensionKey],
'windowed' => $this->dimensionCatalog->isWindowed($dimensionKey),
];
}
return $dimensions;
}
/**
* @param Collection<int, Tenant> $tenants
*/
private function onboardingReadinessLevel(Collection $tenants, ?TenantOnboardingSession $latestOnboardingSession): string
{
if ($latestOnboardingSession instanceof TenantOnboardingSession) {
return match ($latestOnboardingSession->lifecycleState()) {
OnboardingLifecycleState::ReadyForActivation,
OnboardingLifecycleState::Completed => 'ok',
OnboardingLifecycleState::Cancelled => 'critical',
OnboardingLifecycleState::Draft,
OnboardingLifecycleState::Verifying,
OnboardingLifecycleState::ActionRequired,
OnboardingLifecycleState::Bootstrapping => 'warn',
};
}
if ($tenants->isEmpty()) {
return 'unknown';
}
if ($tenants->contains(static fn (Tenant $tenant): bool => (string) $tenant->status === Tenant::STATUS_ACTIVE)) {
return 'ok';
}
return 'warn';
}
/**
* @param Collection<int, ProviderConnection> $providerConnections
*/
private function providerConnectionHealthLevel(Collection $providerConnections): string
{
if ($providerConnections->isEmpty()) {
return 'unknown';
}
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
if (! $connection->is_enabled) {
return false;
}
$consentStatus = $this->normalizeBackedEnumValue($connection->consent_status);
$verificationStatus = $this->normalizeBackedEnumValue($connection->verification_status);
return in_array($consentStatus, [
ProviderConsentStatus::Required->value,
ProviderConsentStatus::Failed->value,
ProviderConsentStatus::Revoked->value,
], true) || in_array($verificationStatus, [
ProviderVerificationStatus::Blocked->value,
ProviderVerificationStatus::Error->value,
], true);
})) {
return 'critical';
}
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
if (! $connection->is_enabled) {
return true;
}
$consentStatus = $this->normalizeBackedEnumValue($connection->consent_status);
$verificationStatus = $this->normalizeBackedEnumValue($connection->verification_status);
return in_array($consentStatus, [
ProviderConsentStatus::Unknown->value,
], true) || in_array($verificationStatus, [
ProviderVerificationStatus::Unknown->value,
ProviderVerificationStatus::Pending->value,
ProviderVerificationStatus::Degraded->value,
], true);
})) {
return 'warn';
}
if ($providerConnections->contains(function (ProviderConnection $connection): bool {
return $connection->is_enabled
&& $this->normalizeBackedEnumValue($connection->consent_status) === ProviderConsentStatus::Granted->value
&& $this->normalizeBackedEnumValue($connection->verification_status) === ProviderVerificationStatus::Healthy->value;
})) {
return 'ok';
}
return 'unknown';
}
private function operationalStabilityLevel(int $recentRunCount, int $recentFailedRunCount, int $recentStuckRunCount): string
{
if ($recentFailedRunCount > 0 || $recentStuckRunCount > 0) {
return 'critical';
}
if ($recentRunCount > 0) {
return 'ok';
}
return 'unknown';
}
private function governancePressureLevel(
int $activeHighSeverityFindingCount,
int $anyGovernanceFindingCount,
int $overdueHighSeverityFindingCount,
int $warningExceptionCount,
int $criticalExceptionCount,
): string {
if ($overdueHighSeverityFindingCount > 0 || $criticalExceptionCount > 0) {
return 'critical';
}
if ($activeHighSeverityFindingCount > 0 || $warningExceptionCount > 0) {
return 'warn';
}
if ($anyGovernanceFindingCount === 0 && $warningExceptionCount === 0 && $criticalExceptionCount === 0) {
return 'unknown';
}
return 'ok';
}
private function reviewPackReadinessLevel(int $reviewPackRequestCount, ?ReviewPack $latestReviewPack, CarbonImmutable $now): string
{
if ($reviewPackRequestCount === 0 && ! $latestReviewPack instanceof ReviewPack) {
return 'unknown';
}
if (! $latestReviewPack instanceof ReviewPack) {
return 'warn';
}
if (
(string) $latestReviewPack->status === ReviewPack::STATUS_READY
&& (! $latestReviewPack->expires_at instanceof CarbonImmutable || $latestReviewPack->expires_at->gt($now))
) {
return 'ok';
}
if (
in_array((string) $latestReviewPack->status, [
ReviewPack::STATUS_FAILED,
ReviewPack::STATUS_EXPIRED,
], true)
|| ($latestReviewPack->expires_at !== null && $latestReviewPack->expires_at->lte($now))
) {
return 'critical';
}
return 'warn';
}
private function engagementFreshnessLevel(int $recentUsageEventCount, int $historicalUsageEventCount): string
{
if ($recentUsageEventCount > 0) {
return 'ok';
}
if ($historicalUsageEventCount > 0) {
return 'warn';
}
return 'unknown';
}
/**
* @param array<string, array{label: string, level: string, windowed: bool}> $dimensions
* @return list<string>
*/
private function dominantDimensionKeys(array $dimensions): array
{
$catalogOrder = array_flip($this->dimensionCatalog->names());
return collect($dimensions)
->reject(static fn (array $dimension): bool => $dimension['level'] === 'ok')
->keys()
->sort(function (string $left, string $right) use ($dimensions, $catalogOrder): int {
$severityComparison = $this->levelRank($dimensions[$right]['level']) <=> $this->levelRank($dimensions[$left]['level']);
if ($severityComparison !== 0) {
return $severityComparison;
}
return ($catalogOrder[$left] ?? PHP_INT_MAX) <=> ($catalogOrder[$right] ?? PHP_INT_MAX);
})
->values()
->all();
}
/**
* @param Collection<int, Tenant> $tenants
* @param list<string> $dominantDimensionKeys
* @return array{label: string, url: string}
*/
private function nextLink(Workspace $workspace, Collection $tenants, array $dominantDimensionKeys, SystemConsoleWindow $window): array
{
$dominantDimension = $dominantDimensionKeys[0] ?? null;
if ($dominantDimension === CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY && $this->canOpenRunsLink()) {
return [
'label' => 'Open runs',
'url' => SystemOperationRunLinks::index(),
];
}
/** @var Tenant|null $tenant */
$tenant = $tenants->first();
if ($tenant instanceof Tenant) {
return [
'label' => 'Review health details',
'url' => $this->withWindowQuery(SystemDirectoryLinks::tenantDetail($tenant), $window),
];
}
return [
'label' => 'Review health details',
'url' => $this->withWindowQuery(SystemDirectoryLinks::workspaceDetail($workspace), $window),
];
}
private function withWindowQuery(string $url, SystemConsoleWindow $window): string
{
$separator = str_contains($url, '?') ? '&' : '?';
return $url.$separator.http_build_query(['window' => $window->value]);
}
private function canOpenRunsLink(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::OPERATIONS_VIEW)
|| ($user->hasCapability(PlatformCapabilities::OPS_VIEW) && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW));
}
/**
* @param Builder<OperationRun>|Builder<ProductUsageEvent>|Builder<ReviewPack>|Builder<Finding>|Builder<FindingException> $query
* @return array<int, int>
*/
private function groupedCounts(Builder $query): array
{
return $query
->selectRaw('workspace_id, COUNT(*) as aggregate')
->groupBy('workspace_id')
->pluck('aggregate', 'workspace_id')
->mapWithKeys(static fn (mixed $count, mixed $workspaceId): array => [
(int) $workspaceId => (int) $count,
])
->all();
}
/**
* @param array<int, int> $counts
*/
private function countForWorkspace(array $counts, int $workspaceId): int
{
return (int) ($counts[$workspaceId] ?? 0);
}
private function levelRank(string $level): int
{
return match ($level) {
'critical' => 4,
'warn' => 3,
'unknown' => 2,
default => 1,
};
}
private function normalizeBackedEnumValue(mixed $value): string
{
if (is_object($value) && property_exists($value, 'value')) {
return (string) $value->value;
}
return (string) $value;
}
private function constrainToActiveTenantTruth(Builder $query): void
{
$query
->whereNull('tenant_id')
->orWhereHas('tenant', function (Builder $tenantQuery): void {
$tenantQuery
->whereNull('deleted_at')
->where('status', '!=', Tenant::STATUS_ARCHIVED);
});
}
}

View File

@ -17,13 +17,6 @@ final class OperationalControlCatalog
'operation_types' => ['restore.execute'],
'affected_surfaces' => ['tenant.restore_runs.create'],
],
'ai.execution' => [
'key' => 'ai.execution',
'label' => 'AI execution',
'supported_scopes' => ['global'],
'operation_types' => ['ai.execution'],
'affected_surfaces' => ['governed_ai.execution'],
],
];
/**

View File

@ -6,7 +6,6 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Ai\AiDataClassification;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ReasonTranslation\ReasonPresenter;
@ -148,43 +147,6 @@ public function knowledgeSource(): array
return $this->catalog->knowledgeSource();
}
/**
* @return array{
* use_case_key: string,
* source_family: string,
* data_classifications: list<string>,
* operational_metadata: array{version: int, topic_count: int},
* topics: list<array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* glossary_terms: list<string>,
* docs_links: list<array{label: string, kind: string, url: ?string, resolver: ?string}>
* }>
* }
*/
public function aiProductKnowledgeAnswerDraftSource(): array
{
$source = $this->knowledgeSource();
return [
'use_case_key' => 'product_knowledge.answer_draft',
'source_family' => 'product_knowledge',
'data_classifications' => [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
'operational_metadata' => [
'version' => (int) $source['version'],
'topic_count' => (int) $source['topic_count'],
],
'topics' => $source['topics'],
];
}
/**
* @param array<string, mixed>|null $verificationReport
*/

View File

@ -4,9 +4,7 @@
namespace App\Support\Settings;
use App\Support\Ai\AiPolicyMode;
use App\Models\Finding;
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
final class SettingsRegistry
{
@ -19,15 +17,6 @@ public function __construct()
{
$this->definitions = [];
$this->register(new SettingDefinition(
domain: 'ai',
key: 'policy_mode',
type: 'string',
systemDefault: AiPolicyMode::Disabled->value,
rules: ['required', 'string', 'in:disabled,private_only'],
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
));
$this->register(new SettingDefinition(
domain: 'backup',
key: 'retention_keep_last_default',
@ -229,91 +218,6 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
rules: ['required', 'integer', 'min:0', 'max:10080'],
normalizer: static fn (mixed $value): int => (int) $value,
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'plan_profile',
type: 'string',
systemDefault: WorkspacePlanProfileCatalog::defaultProfileId(),
rules: [
'nullable',
'string',
'in:'.implode(',', WorkspacePlanProfileCatalog::profileIds()),
],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'managed_tenant_limit_override_value',
type: 'int',
systemDefault: null,
rules: ['nullable', 'integer', 'min:0'],
normalizer: static function (mixed $value): ?int {
if ($value === null || $value === '') {
return null;
}
return (int) $value;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'managed_tenant_limit_override_reason',
type: 'string',
systemDefault: null,
rules: ['nullable', 'string', 'max:500'],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'review_pack_generation_override_value',
type: 'bool',
systemDefault: null,
rules: ['nullable', 'boolean'],
normalizer: static function (mixed $value): ?bool {
if ($value === null || $value === '') {
return null;
}
return filter_var($value, FILTER_VALIDATE_BOOL);
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
type: 'string',
systemDefault: null,
rules: ['nullable', 'string', 'max:500'],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
}
/**

View File

@ -19,7 +19,6 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Ai\AiDataClassification;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
@ -134,39 +133,6 @@ public function forOperationRun(OperationRun $run, ?User $actor = null): array
);
}
/**
* @return array{
* use_case_key: string,
* source_family: string,
* data_classifications: list<string>,
* summary: array{
* headline: string,
* dominant_issue: string,
* freshness_state: string,
* completeness_note: ?string,
* redaction_note: string,
* generated_from: string
* },
* redaction: array{mode: string, markers: list<string>},
* notes: list<string>
* }
*/
public function aiSupportDiagnosticsSummaryDraftSource(Tenant $tenant, ?User $actor = null): array
{
$bundle = $this->forTenant($tenant, $actor);
return [
'use_case_key' => 'support_diagnostics.summary_draft',
'source_family' => 'support_diagnostics',
'data_classifications' => [
AiDataClassification::RedactedSupportSummary->value,
],
'summary' => $bundle['summary'],
'redaction' => $bundle['redaction'],
'notes' => $bundle['notes'],
];
}
/**
* @param list<array<string, mixed>> $sections
* @return array<string, mixed>

View File

@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\SupportRequests;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
final class SupportRequestContextBuilder
{
public const ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED = 'diagnostic_snapshot_attached';
public const ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY = 'canonical_context_only';
public function __construct(
private readonly SupportDiagnosticBundleBuilder $supportDiagnosticBundleBuilder,
) {}
/**
* @return array<string, mixed>
*/
public function forTenant(Tenant $tenant, User $actor, bool $attachDiagnosticSnapshot): array
{
return $this->buildEnvelope(
bundle: $this->supportDiagnosticBundleBuilder->forTenant($tenant, $actor),
attachDiagnosticSnapshot: $attachDiagnosticSnapshot,
);
}
/**
* @return array<string, mixed>
*/
public function forOperationRun(OperationRun $run, User $actor, bool $attachDiagnosticSnapshot): array
{
return $this->buildEnvelope(
bundle: $this->supportDiagnosticBundleBuilder->forOperationRun($run, $actor),
attachDiagnosticSnapshot: $attachDiagnosticSnapshot,
);
}
/**
* @param array<string, mixed> $bundle
* @return array<string, mixed>
*/
private function buildEnvelope(array $bundle, bool $attachDiagnosticSnapshot): array
{
$attachmentMode = $attachDiagnosticSnapshot
? self::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED
: self::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY;
return [
'schema_version' => 1,
'generated_from' => 'support_diagnostics_bundle',
'attachment_mode' => $attachmentMode,
'redaction_mode' => (string) data_get($bundle, 'redaction.mode', 'default_redacted'),
'primary_context' => [
'type' => (string) data_get($bundle, 'context.type'),
'workspace_id' => data_get($bundle, 'context.workspace_id'),
'tenant_id' => data_get($bundle, 'context.tenant_id'),
'operation_run_id' => data_get($bundle, 'context.operation_run_id'),
'workspace_label' => data_get($bundle, 'context.workspace_label'),
'tenant_label' => data_get($bundle, 'context.tenant_label'),
],
'canonical_context' => [
'headline' => (string) data_get($bundle, 'summary.headline', data_get($bundle, 'headline')),
'dominant_issue' => (string) data_get($bundle, 'summary.dominant_issue', data_get($bundle, 'dominant_issue')),
'freshness_state' => (string) data_get($bundle, 'freshness_state'),
'completeness_note' => data_get($bundle, 'summary.completeness_note'),
'redaction_note' => data_get($bundle, 'summary.redaction_note'),
'context' => data_get($bundle, 'context', []),
'tenant' => data_get($bundle, 'tenant'),
'operation_run' => data_get($bundle, 'operation_run'),
'sections' => $this->canonicalSections($bundle),
'notes' => is_array($bundle['notes'] ?? null)
? array_values($bundle['notes'])
: [],
],
'diagnostic_snapshot' => $attachDiagnosticSnapshot
? [
'contextual_help' => data_get($bundle, 'contextual_help'),
'sections' => is_array($bundle['sections'] ?? null)
? array_values($bundle['sections'])
: [],
'redaction' => is_array($bundle['redaction'] ?? null)
? $bundle['redaction']
: [],
'notes' => is_array($bundle['notes'] ?? null)
? array_values($bundle['notes'])
: [],
]
: null,
'omissions' => $attachDiagnosticSnapshot
? []
: [[
'type' => 'diagnostic_snapshot',
'reason' => 'omitted_without_support_diagnostics_view',
'message' => 'Redacted diagnostic evidence was omitted because the creator could not view support diagnostics.',
]],
];
}
/**
* @param array<string, mixed> $bundle
* @return list<array<string, mixed>>
*/
private function canonicalSections(array $bundle): array
{
if (! is_array($bundle['sections'] ?? null)) {
return [];
}
return array_values(array_map(
static fn (array $section): array => [
'key' => (string) ($section['key'] ?? ''),
'label' => (string) ($section['label'] ?? ''),
'availability' => (string) ($section['availability'] ?? 'missing'),
'summary' => (string) ($section['summary'] ?? ''),
'freshness_note' => $section['freshness_note'] ?? null,
'references' => is_array($section['references'] ?? null)
? array_values($section['references'])
: [],
],
$bundle['sections'],
));
}
}

View File

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\SupportRequests;
use Illuminate\Support\Str;
final class SupportRequestReferenceGenerator
{
public function generate(): string
{
return 'SR-'.strtoupper((string) Str::ulid());
}
}

View File

@ -1,186 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\SupportRequests;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class SupportRequestSubmissionService
{
public function __construct(
private readonly CapabilityResolver $capabilityResolver,
private readonly SupportRequestContextBuilder $supportRequestContextBuilder,
private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {}
/**
* @param array<string, mixed> $data
*/
public function submitForTenant(Tenant $tenant, User $actor, array $data): SupportRequest
{
$this->authorizeCreation($tenant, $actor);
return $this->submit(
tenant: $tenant,
actor: $actor,
data: $data,
primaryContextType: SupportRequest::PRIMARY_CONTEXT_TENANT,
operationRun: null,
);
}
/**
* @param array<string, mixed> $data
*/
public function submitForOperationRun(OperationRun $run, User $actor, array $data): SupportRequest
{
$run->loadMissing('tenant.workspace');
$tenant = $run->tenant;
if (! $tenant instanceof Tenant) {
abort(404);
}
$this->authorizeCreation($tenant, $actor);
return $this->submit(
tenant: $tenant,
actor: $actor,
data: $data,
primaryContextType: SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
operationRun: $run,
);
}
private function authorizeCreation(Tenant $tenant, User $actor): void
{
if (! $this->capabilityResolver->isMember($actor, $tenant)) {
abort(404);
}
if (! $this->capabilityResolver->can($actor, $tenant, Capabilities::SUPPORT_REQUESTS_CREATE)) {
abort(403);
}
}
/**
* @param array<string, mixed> $data
*/
private function submit(
Tenant $tenant,
User $actor,
array $data,
string $primaryContextType,
?OperationRun $operationRun,
): SupportRequest {
$validated = $this->validate($data);
$attachDiagnosticSnapshot = $this->capabilityResolver->can($actor, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
$contextEnvelope = $operationRun instanceof OperationRun
? $this->supportRequestContextBuilder->forOperationRun($operationRun, $actor, $attachDiagnosticSnapshot)
: $this->supportRequestContextBuilder->forTenant($tenant, $actor, $attachDiagnosticSnapshot);
$contactName = $validated['contact_name'] ?? $this->normalizeNullableString($actor->name) ?? $this->normalizeNullableString($actor->email);
$contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email);
$connection = SupportRequest::query()->getModel()->getConnection();
return $connection->transaction(function () use (
$actor,
$contactEmail,
$contactName,
$contextEnvelope,
$operationRun,
$primaryContextType,
$tenant,
$validated,
): SupportRequest {
$supportRequest = SupportRequest::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
'initiated_by_user_id' => (int) $actor->getKey(),
'internal_reference' => $this->supportRequestReferenceGenerator->generate(),
'primary_context_type' => $primaryContextType,
'attachment_mode' => (string) data_get($contextEnvelope, 'attachment_mode', SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY),
'severity' => $validated['severity'],
'summary' => $validated['summary'],
'reproduction_notes' => $validated['reproduction_notes'],
'contact_name' => $contactName,
'contact_email' => $contactEmail,
'context_envelope' => $contextEnvelope,
]);
$supportRequest->loadMissing(['tenant.workspace']);
$this->workspaceAuditLogger->logSupportRequestCreated($supportRequest, $actor);
return $supportRequest;
});
}
/**
* @param array<string, mixed> $data
* @return array{
* severity: string,
* summary: string,
* reproduction_notes: ?string,
* contact_name: ?string,
* contact_email: ?string,
* }
*/
private function validate(array $data): array
{
$validated = validator(
[
'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL,
'summary' => $data['summary'] ?? null,
'reproduction_notes' => $data['reproduction_notes'] ?? null,
'contact_name' => $data['contact_name'] ?? null,
'contact_email' => $data['contact_email'] ?? null,
],
[
'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())],
'summary' => ['required', 'string'],
'reproduction_notes' => ['nullable', 'string'],
'contact_name' => ['nullable', 'string'],
'contact_email' => ['nullable', 'email'],
],
)->validate();
$validated['summary'] = trim((string) $validated['summary']);
if ($validated['summary'] === '') {
throw ValidationException::withMessages([
'summary' => 'The summary field is required.',
]);
}
$validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null);
$validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null);
$validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null);
return $validated;
}
private function normalizeNullableString(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$normalized = trim($value);
return $normalized === '' ? null : $normalized;
}
}

View File

@ -39,7 +39,6 @@
use App\Filament\System\Pages\Dashboard as SystemDashboard;
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
use App\Filament\System\Pages\Ops\Controls;
use App\Filament\System\Pages\Ops\Runbooks;
use App\Filament\System\Pages\Ops\ViewRun;
use App\Filament\System\Pages\RepairWorkspaceOwners;
@ -662,32 +661,6 @@ public static function spec195ResidualSurfaceInventory(): array
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
Controls::class => [
'surfaceKey' => 'system_ops_controls',
'surfaceName' => 'System Ops Controls',
'pageClass' => Controls::class,
'panelPlane' => 'system',
'surfaceKind' => 'system_utility',
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'separately_governed',
'reasonCategory' => 'workflow_specific_governance',
'explicitReason' => 'Operational controls is a dedicated system control workbench with confirmation-backed pause, resume, and history actions plus restore-gate coupling, so it remains governed by focused workflow tests instead of the generic declaration-backed contract.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/OpsControls/OperationalControlManagementTest.php',
'proves' => 'The controls page keeps capability-gated operational-control actions, confirmation semantics, scope previews, and audited pause or resume behavior under dedicated coverage.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php',
'proves' => 'Restore execution stays coupled to the shared operational-control workflow, including blocked execution and non-retroactive pause behavior after acceptance.',
],
],
'followUpAction' => 'add_guard_only',
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
RepairWorkspaceOwners::class => [
'surfaceKey' => 'repair_workspace_owners',
'surfaceName' => 'Repair Workspace Owners',

View File

@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SupportRequest>
*/
class SupportRequestFactory extends Factory
{
protected $model = SupportRequest::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'initiated_by_user_id' => User::factory(),
'internal_reference' => 'SR-'.strtoupper((string) Str::ulid()),
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
'severity' => SupportRequest::SEVERITY_NORMAL,
'summary' => fake()->sentence(),
'reproduction_notes' => fake()->optional()->paragraph(),
'contact_name' => fake()->name(),
'contact_email' => fake()->safeEmail(),
'context_envelope' => [
'schema_version' => 1,
'generated_from' => 'factory',
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
'primary_context' => [
'type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
],
'canonical_context' => [
'sections' => [],
],
'diagnostic_snapshot' => [
'sections' => [],
],
'omissions' => [],
],
];
}
public function canonicalContextOnly(): static
{
return $this->state(fn (array $attributes): array => [
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
'context_envelope' => array_replace_recursive($attributes['context_envelope'] ?? [], [
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
'diagnostic_snapshot' => null,
'omissions' => [[
'type' => 'diagnostic_snapshot',
'reason' => 'omitted_without_support_diagnostics_view',
]],
]),
]);
}
public function forOperationRun(OperationRun $operationRun): static
{
return $this->state(fn (): array => [
'tenant_id' => (int) $operationRun->tenant_id,
'workspace_id' => (int) $operationRun->workspace_id,
'operation_run_id' => (int) $operationRun->getKey(),
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
'context_envelope' => [
'schema_version' => 1,
'generated_from' => 'factory',
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
'primary_context' => [
'type' => SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
'tenant_id' => (int) $operationRun->tenant_id,
'operation_run_id' => (int) $operationRun->getKey(),
],
'canonical_context' => [
'sections' => [],
],
'diagnostic_snapshot' => [
'sections' => [],
],
'omissions' => [],
],
]);
}
}

View File

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('support_requests', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
$table->foreignId('initiated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('internal_reference')->unique();
$table->string('primary_context_type');
$table->string('attachment_mode');
$table->string('severity');
$table->text('summary');
$table->text('reproduction_notes')->nullable();
$table->string('contact_name')->nullable();
$table->string('contact_email')->nullable();
$table->jsonb('context_envelope')->default('{}');
$table->timestamps();
$table->index(['workspace_id', 'tenant_id']);
$table->index(['tenant_id', 'created_at']);
$table->index(['operation_run_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('support_requests');
}
};

View File

@ -1,59 +0,0 @@
@php
/** @var array{
* overall: array{label: string, color: string, icon: string|null},
* reason: string,
* impact: string,
* recommended_action: string,
* dominant_dimensions: list<array{label: string, color: string, icon: string|null}>,
* window_label: string
* } $decision */
@endphp
<x-filament::section>
<x-slot name="heading">
Customer health decision
</x-slot>
<x-slot name="description">
Decision-first summary. Operational stability, review-pack readiness, and engagement freshness honor {{ $decision['window_label'] }}.
</x-slot>
<div class="space-y-5">
<div class="flex flex-wrap items-center gap-3">
<div>
<p class="text-sm font-medium text-gray-950 dark:text-white">Overall health</p>
</div>
<x-filament::badge :color="$decision['overall']['color']" :icon="$decision['overall']['icon']">
{{ $decision['overall']['label'] }}
</x-filament::badge>
</div>
<div class="space-y-2">
<p class="text-sm font-medium text-gray-950 dark:text-white">Reason</p>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $decision['reason'] }}</p>
@if ($decision['dominant_dimensions'] !== [])
<div class="flex flex-wrap gap-2">
@foreach ($decision['dominant_dimensions'] as $dimension)
<x-filament::badge :color="$dimension['color']" :icon="$dimension['icon']">
{{ $dimension['label'] }}
</x-filament::badge>
@endforeach
</div>
@endif
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div class="space-y-2">
<p class="text-sm font-medium text-gray-950 dark:text-white">Impact</p>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $decision['impact'] }}</p>
</div>
<div class="space-y-2">
<p class="text-sm font-medium text-gray-950 dark:text-white">Recommended next action</p>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $decision['recommended_action'] }}</p>
</div>
</div>
</div>
</x-filament::section>

View File

@ -1,7 +1,6 @@
@php
/** @var \App\Models\Tenant $tenant */
$tenant = $this->tenant;
$customerHealthDecision = $this->customerHealthDecision();
$providerConnections = $this->providerConnections();
$permissions = $this->tenantPermissions();
$runs = $this->recentRuns();
@ -33,19 +32,11 @@
<div class="mt-4">
<x-filament::link :href="$this->adminTenantUrl()" icon="heroicon-m-arrow-top-right-on-square">
Open in tenant admin
Open in /admin
</x-filament::link>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Requires tenant admin membership.
</p>
</div>
</x-filament::section>
@if ($customerHealthDecision)
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
@endif
<x-filament::section>
<x-slot name="heading">
Connectivity signals

View File

@ -1,14 +1,8 @@
@php
/** @var \App\Models\Workspace $workspace */
$workspace = $this->workspace;
$customerHealthDecision = $this->customerHealthDecision();
$tenants = $this->workspaceTenants();
$runs = $this->recentRuns();
$workspaceEntitlementSummary = $this->workspaceEntitlementSummary();
$planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null;
$entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? [];
$managedTenantDecision = $entitlementDecisions['managed_tenant_activation_limit'] ?? null;
$reviewPackDecision = $entitlementDecisions['review_pack_generation_enabled'] ?? null;
@endphp
<x-filament-panels::page>
@ -36,62 +30,6 @@
</div>
</x-filament::section>
@if ($customerHealthDecision)
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
@endif
@if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision))
<x-filament::section>
<x-slot name="heading">
Workspace entitlements
</x-slot>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Plan profile</p>
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $planProfile['label'] }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $planProfile['description'] }}</p>
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last changed</p>
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $managedTenantDecision['last_changed_by'] ?? 'Not set' }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['last_changed_at']?->diffForHumans() ?? 'No entitlement override recorded yet.' }}</p>
</div>
</div>
<div class="mt-4 space-y-3">
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold text-gray-950 dark:text-white">Managed tenant activation limit</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['current_usage'] }} active of {{ $managedTenantDecision['effective_value'] }} allowed</p>
</div>
<x-filament::badge :color="$managedTenantDecision['source'] === 'workspace_override' ? 'warning' : 'gray'">
{{ $managedTenantDecision['source'] === 'workspace_override' ? 'workspace override' : ($managedTenantDecision['plan_profile_label'].' plan profile') }}
</x-filament::badge>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['rationale'] }}</p>
</div>
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold text-gray-950 dark:text-white">Review pack generation</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $reviewPackDecision['effective_value'] ? 'Enabled' : 'Disabled' }}</p>
</div>
<x-filament::badge :color="$reviewPackDecision['source'] === 'workspace_override' ? 'warning' : 'gray'">
{{ $reviewPackDecision['source'] === 'workspace_override' ? 'workspace override' : ($reviewPackDecision['plan_profile_label'].' plan profile') }}
</x-filament::badge>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $reviewPackDecision['rationale'] }}</p>
</div>
</div>
</x-filament::section>
@endif
<x-filament::section>
<x-slot name="heading">
Tenants summary

View File

@ -1,53 +0,0 @@
<x-filament-widgets::widget>
<x-filament::section>
<x-slot name="heading">
Attention-needed workspaces
</x-slot>
<x-slot name="description">
Worst derived workspace health first. Operational stability, review-pack readiness, and engagement freshness honor {{ $windowLabel }}.
</x-slot>
@if ($rows->isEmpty())
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-500 dark:border-white/15 dark:text-gray-400">
No workspaces need attention in the selected view.
</div>
@else
<div class="space-y-3">
@foreach ($rows as $row)
<article class="rounded-xl border border-gray-200 bg-white/70 p-4 dark:border-white/10 dark:bg-white/5">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold text-gray-950 dark:text-white">{{ $row['workspace_label'] }}</h3>
<x-filament::badge :color="$row['overall']['color']" :icon="$row['overall']['icon']">
{{ $row['overall']['label'] }}
</x-filament::badge>
</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
Top drivers: {{ $row['dominant_copy'] }}
</p>
<div class="flex flex-wrap gap-2">
@foreach ($row['dominant_dimensions'] as $dimension)
<x-filament::badge :color="$dimension['color']" :icon="$dimension['icon']">
{{ $dimension['label'] }}
</x-filament::badge>
@endforeach
</div>
</div>
<div>
<x-filament::link :href="$row['next_link']['url']" icon="heroicon-m-arrow-top-right-on-square">
{{ $row['next_link']['label'] }}
</x-filament::link>
</div>
</div>
</article>
@endforeach
</div>
@endif
</x-filament::section>
</x-filament-widgets::widget>

View File

@ -9,8 +9,6 @@
/** @var ?string $pollingInterval */
/** @var bool $canView */
/** @var bool $canManage */
/** @var bool $generationBlocked */
/** @var ?string $generationBlockReason */
/** @var ?string $downloadUrl */
/** @var ?string $failedReason */
/** @var ?string $failedReasonDetail */
@ -26,12 +24,6 @@
@endif
>
<x-filament::section heading="Review Pack">
@if ($canManage && $generationBlocked && $generationBlockReason)
<div class="mb-3 rounded-lg border border-warning-200 bg-warning-50 px-3 py-2 text-sm text-warning-800 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-200">
{{ $generationBlockReason }}
</div>
@endif
@if (! $pack)
{{-- State 1: No pack --}}
<div class="flex flex-col items-center gap-3 py-4 text-center">
@ -45,15 +37,12 @@
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Generate pack
</x-filament::button>
@endif
</div>
@endif
@if ($pack && ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating))
@elseif ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating)
{{-- State 2: Queued / Generating --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -74,9 +63,7 @@
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
</div>
</div>
@endif
@if ($pack && $statusEnum === ReviewPackStatus::Ready)
@elseif ($statusEnum === ReviewPackStatus::Ready)
{{-- State 3: Ready --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -129,16 +116,13 @@
color="gray"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Generate new
</x-filament::button>
@endif
</div>
</div>
@endif
@if ($pack && $statusEnum === ReviewPackStatus::Failed)
@elseif ($statusEnum === ReviewPackStatus::Failed)
{{-- State 4: Failed --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -179,15 +163,12 @@
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Retry
</x-filament::button>
@endif
</div>
@endif
@if ($pack && $statusEnum === ReviewPackStatus::Expired)
@elseif ($statusEnum === ReviewPackStatus::Expired)
{{-- State 5: Expired --}}
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
@ -208,7 +189,6 @@
size="sm"
wire:click="generatePack"
wire:loading.attr="disabled"
:disabled="$generationBlocked"
>
Generate new
</x-filament::button>

View File

@ -5,14 +5,12 @@
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
use App\Models\BackupSchedule;
use App\Models\BackupSet;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\RestoreRun;
use App\Models\User;
use App\Models\Workspace;
@ -242,47 +240,6 @@ function getPlacementEmptyStateAction(Testable $component, string $name): ?Actio
expect($headerCreate?->isVisible())->toBeTrue();
});
it('shows generate only in empty state when review packs table is empty', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateGenerate = getPlacementEmptyStateAction($component, 'generate_first');
expect($emptyStateGenerate)->not->toBeNull();
expect($emptyStateGenerate?->getLabel())->toBe('Generate first pack');
$headerGenerate = getHeaderAction($component, 'generate_pack');
expect($headerGenerate)->not->toBeNull();
expect($headerGenerate?->isVisible())->toBeFalse();
});
it('shows generate only in header when review packs table is not empty', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::test(ListReviewPacks::class)
->assertCountTableRecords(1);
$headerGenerate = getHeaderAction($component, 'generate_pack');
expect($headerGenerate)->not->toBeNull();
expect($headerGenerate?->isVisible())->toBeTrue();
expect($headerGenerate?->getLabel())->toBe('Generate Pack');
});
it('shows create only in empty state when tenants table is empty', function (): void {
$workspace = Workspace::factory()->create([
'archived_at' => now(),

View File

@ -1,111 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
/**
* @return array{0: Workspace, 1: User}
*/
function entitlementSettingsManager(): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$workspace, $user];
}
it('saves entitlement plan profile and override pairs through the workspace settings page', function (): void {
[$workspace, $user] = entitlementSettingsManager();
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful()
->assertSee('Workspace entitlements');
$component = Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.entitlements_plan_profile', null)
->assertSet('data.entitlements_managed_tenant_limit_override_value', null)
->assertSet('data.entitlements_managed_tenant_limit_override_reason', null)
->assertSet('data.entitlements_review_pack_generation_override_value', null)
->assertSet('data.entitlements_review_pack_generation_override_reason', null)
->set('data.entitlements_plan_profile', 'starter')
->set('data.entitlements_managed_tenant_limit_override_value', 2)
->set('data.entitlements_managed_tenant_limit_override_reason', 'Temporary support-approved exception')
->set('data.entitlements_review_pack_generation_override_value', '0')
->set('data.entitlements_review_pack_generation_override_reason', 'Workspace is temporarily limited to manual reporting only')
->callAction('save')
->assertHasNoErrors()
->assertSet('data.entitlements_plan_profile', 'starter')
->assertSet('data.entitlements_managed_tenant_limit_override_value', 2)
->assertSet('data.entitlements_managed_tenant_limit_override_reason', 'Temporary support-approved exception')
->assertSet('data.entitlements_review_pack_generation_override_value', '0')
->assertSet('data.entitlements_review_pack_generation_override_reason', 'Workspace is temporarily limited to manual reporting only');
$summary = app(WorkspaceEntitlementResolver::class)->summary($workspace);
expect($summary['plan_profile']['id'])->toBe('starter')
->and($summary['decisions'][WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT])
->toMatchArray([
'effective_value' => 2,
'source' => 'workspace_override',
'rationale' => 'Temporary support-approved exception',
'last_changed_by' => $user->name,
])
->and($summary['decisions'][WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED])
->toMatchArray([
'effective_value' => false,
'source' => 'workspace_override',
'rationale' => 'Workspace is temporarily limited to manual reporting only',
'last_changed_by' => $user->name,
]);
$component
->mountFormComponentAction('entitlements_managed_tenant_limit_override_value', 'reset_entitlements_managed_tenant_limit_override_value', [], 'content')
->callMountedFormComponentAction()
->assertHasNoErrors()
->assertSet('data.entitlements_managed_tenant_limit_override_value', null)
->assertSet('data.entitlements_managed_tenant_limit_override_reason', null);
$summary = app(WorkspaceEntitlementResolver::class)->summary($workspace);
expect($summary['decisions'][WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT])
->toMatchArray([
'effective_value' => 1,
'source' => 'plan_profile_default',
'rationale' => 'Minimal allowance for early workspace access and low-volume operations.',
]);
});
it('requires an override reason when a workspace entitlement override value is set', function (): void {
[, $user] = entitlementSettingsManager();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->set('data.entitlements_managed_tenant_limit_override_value', 3)
->set('data.entitlements_managed_tenant_limit_override_reason', '')
->callAction('save')
->assertHasErrors(['data.entitlements_managed_tenant_limit_override_reason']);
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->set('data.entitlements_review_pack_generation_override_value', '0')
->set('data.entitlements_review_pack_generation_override_reason', '')
->callAction('save')
->assertHasErrors(['data.entitlements_review_pack_generation_override_reason']);
});

View File

@ -950,7 +950,6 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\Ops\Controls::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,

View File

@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\File;
it('prevents ai governance surfaces from declaring direct outbound or vendor-specific provider runtime code', function (): void {
$root = app_path();
$files = collect(File::allFiles($root))
->map(fn (\SplFileInfo $file): string => str_replace($root.'/', '', $file->getPathname()))
->filter(fn (string $relativePath): bool => str_starts_with($relativePath, 'Support/Ai/')
|| $relativePath === 'Support/ProductKnowledge/ContextualHelpResolver.php'
|| $relativePath === 'Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php')
->values();
$patterns = [
'outbound_http' => '/\bHttp::/',
'guzzle_client' => '/\bnew\s+Client\b/',
'curl_runtime' => '/\bcurl_/i',
'openai_vendor' => '/\bOpenAI\b/i',
'anthropic_vendor' => '/\bAnthropic\b/i',
'gemini_vendor' => '/\bGemini\b/i',
'openrouter_vendor' => '/\bOpenRouter\b/i',
'chat_completions_runtime' => '/\bChatCompletion\b/i',
];
$hits = [];
foreach ($files as $relativePath) {
$contents = file_get_contents($root.'/'.$relativePath);
if (! is_string($contents) || $contents === '') {
continue;
}
$lines = preg_split('/\R/', $contents) ?: [];
foreach ($patterns as $label => $pattern) {
foreach ($lines as $index => $line) {
if (preg_match($pattern, $line) === 1) {
$hits[] = $relativePath.':'.($index + 1).' ['.$label.'] '.trim($line);
}
}
}
}
expect($hits)->toBeEmpty("AI governance surfaces must stay vendor-neutral and must not perform outbound provider runtime calls directly:\n".implode("\n", $hits));
});

View File

@ -35,7 +35,6 @@ function spec195FormattedIssues(array $issues): string
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\Ops\Controls::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
@ -68,7 +67,6 @@ function spec195FormattedIssues(array $issues): string
expect($inventory[\App\Filament\System\Pages\Ops\ViewRun::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\Ops\Runbooks::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\Ops\Controls::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\RepairWorkspaceOwners::class]['closureDecision'] ?? null)->toBe('separately_governed')
->and($inventory[\App\Filament\System\Pages\Directory\ViewTenant::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
->and($inventory[\App\Filament\System\Pages\Directory\ViewWorkspace::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
@ -78,7 +76,6 @@ function spec195FormattedIssues(array $issues): string
\App\Filament\System\Pages\Dashboard::class,
\App\Filament\System\Pages\Ops\ViewRun::class,
\App\Filament\System\Pages\Ops\Runbooks::class,
\App\Filament\System\Pages\Ops\Controls::class,
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
\App\Filament\System\Pages\Directory\ViewTenant::class,
\App\Filament\System\Pages\Directory\ViewWorkspace::class,

View File

@ -1,190 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable}
*/
function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $limitOverride = null, ?string $overrideReason = null): array
{
Queue::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
if ($activeTenantCount > 0) {
Tenant::factory()->count($activeTenantCount)->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
}
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Ready connection',
'is_default' => true,
'consent_status' => 'granted',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
],
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'bootstrap',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
if ($limitOverride !== null) {
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
value: $limitOverride,
);
if ($overrideReason !== null) {
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
value: $overrideReason,
);
}
}
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $draft->getKey(),
]);
return compact('workspace', 'user', 'tenant', 'draft', 'component');
}
it('allows onboarding activation when the workspace is within its managed tenant limit', function (): void {
$context = readyOnboardingEntitlementContext(activeTenantCount: 0);
$context['component']->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeTrue();
});
it('blocks onboarding activation with a business-state reason when the workspace is at limit', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 1,
limitOverride: 1,
overrideReason: 'Customer currently allows one active tenant',
);
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
$context['workspace'],
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
expect($decision['is_blocked'])->toBeTrue();
$context['component']
->assertSee('Activation entitlement')
->assertSee('Blocked')
->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING)
->and(AuditLog::query()
->where('workspace_id', (int) $context['workspace']->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeFalse();
});
it('allows onboarding activation when a workspace override raises the limit above current usage', function (): void {
$context = readyOnboardingEntitlementContext(
activeTenantCount: 1,
limitOverride: 2,
overrideReason: 'Temporary support-approved exception',
);
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
$context['workspace'],
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
expect($decision)
->toMatchArray([
'source' => 'workspace_override',
'effective_value' => 2,
'current_usage' => 1,
'is_blocked' => false,
'rationale' => 'Temporary support-approved exception',
]);
$context['component']->call('completeOnboarding');
$context['tenant']->refresh();
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE);
});

View File

@ -2,17 +2,14 @@
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Controls;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationalControlActivation;
use App\Models\PlatformUser;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -134,49 +131,3 @@ function seedRestoreAuthorizationContext(): array
->call('create')
->assertNotified('Restore execution paused');
});
it('forbids ai execution controls for platform users missing system panel access', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::OPS_CONTROLS_MANAGE,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Controls::getUrl(panel: 'system'))
->assertForbidden();
});
it('forbids ai execution controls for platform users missing ops controls manage', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Controls::getUrl(panel: 'system'))
->assertForbidden();
});
it('shows ai execution controls only to platform users with the existing system control capabilities', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_CONTROLS_MANAGE,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Controls::getUrl(panel: 'system'))
->assertSuccessful()
->assertSee('AI execution');
Livewire::actingAs($user, 'platform')
->test(Controls::class)
->assertActionVisible('pause_ai_execution')
->assertActionVisible('resume_ai_execution');
});

View File

@ -1,190 +0,0 @@
<?php
declare(strict_types=1);
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
beforeEach(function (): void {
Storage::fake('exports');
});
function seedEntitlementReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
{
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'payload' => ['required_count' => 1, 'granted_count' => 1],
]);
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
'payload' => ['roles' => [['displayName' => 'Global Administrator']]],
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
]);
OperationRun::factory()->forTenant($tenant)->create();
/** @var EvidenceSnapshotService $service */
$service = app(EvidenceSnapshotService::class);
$payload = $service->buildSnapshotPayload($tenant);
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'fingerprint' => $payload['fingerprint'],
'completeness_state' => $payload['completeness'],
'summary' => $payload['summary'],
'generated_at' => now(),
]);
foreach ($payload['items'] as $item) {
$snapshot->items()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'dimension_key' => $item['dimension_key'],
'state' => $item['state'],
'required' => $item['required'],
'source_kind' => $item['source_kind'],
'source_record_type' => $item['source_record_type'],
'source_record_id' => $item['source_record_id'],
'source_fingerprint' => $item['source_fingerprint'],
'measured_at' => $item['measured_at'],
'freshness_at' => $item['freshness_at'],
'summary_payload' => $item['summary_payload'],
'sort_order' => $item['sort_order'],
]);
}
return $snapshot;
}
function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, string $reason): void
{
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_value',
value: false,
);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
value: $reason,
);
}
it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant);
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
$initialRunCount = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'Workspace is temporarily limited to manual reporting only');
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
});
it('blocks executive pack export before creating a review pack or operation run when the workspace is not entitled', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedEntitlementReviewPackSnapshot($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
$initialRunCount = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generateFromReview($review, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'Workspace is temporarily limited to manual reporting only');
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
});
it('shows the blocked reason on the review pack card and keeps existing pack downloads accessible', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
$initialRunCount = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Workspace is temporarily limited to manual reporting only')
->assertSee('Generate pack')
->call('generatePack', true, true)
->assertHasNoErrors();
expect(ReviewPack::query()->count())->toBe(0)
->and(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::ReviewPackGenerate->value)
->count())->toBe($initialRunCount);
$filePath = 'review-packs/entitlement-download-test.zip';
Storage::disk('exports')->put($filePath, 'PK-test');
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => $filePath,
'file_disk' => 'exports',
]);
$this->actingAs($user)
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant'))
->assertOk()
->assertSee('Download');
});

View File

@ -9,13 +9,11 @@
use App\Services\ReviewPackService;
use App\Support\Auth\UiTooltips;
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Livewire\Livewire;
use Livewire\Features\SupportTesting\Testable;
uses(RefreshDatabase::class);
@ -23,17 +21,6 @@
Storage::fake('exports');
});
function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
// ─── Non-Member Access ───────────────────────────────────────
it('returns 404 for non-member on list page', function (): void {
@ -137,15 +124,11 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)
Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackRbacEmptyStateAction($component, 'generate_first');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->isDisabled())->toBeTrue()
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
->assertActionVisible('generate_pack')
->assertActionDisabled('generate_pack')
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
});
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
@ -154,12 +137,6 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);

View File

@ -13,19 +13,16 @@
use App\Models\Tenant;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\UiTooltips;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunLinks;
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Livewire\Features\SupportTesting\Testable;
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
@ -34,31 +31,6 @@
Storage::fake('exports');
});
function getReviewPackEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
function getReviewPackHeaderAction(Testable $component, string $name): ?Action
{
$instance = $component->instance();
$instance->cacheInteractsWithHeaderActions();
foreach ($instance->getCachedHeaderActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
{
StoredReport::factory()->create([
@ -158,7 +130,8 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
'tenant_id' => (int) $otherTenant->getKey(),
]);
setTenantPanelContext($tenant);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListReviewPacks::class)
@ -177,112 +150,32 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
->assertSee('No review packs yet');
});
// ─── List Page Start CTA Placement ───────────────────────────
// ─── List Page Header Action ─────────────────────────────────
it('shows generate only in the empty state when no review packs exist', function (): void {
it('shows the generate_pack header action for a MANAGE user', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackEmptyStateAction($component, 'generate_first');
$headerAction = getReviewPackHeaderAction($component, 'generate_pack');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->getLabel())->toBe('Generate first pack')
->and($headerAction)->not->toBeNull()
->and($headerAction?->isVisible())->toBeFalse();
});
it('shows generate in the header once review packs exist', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertActionVisible('generate_pack');
});
it('disables the generate_first action for a readonly user in the empty state', function (): void {
it('disables the generate_pack action for a readonly user', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackEmptyStateAction($component, 'generate_first');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->isDisabled())->toBeTrue()
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
});
it('disables review pack generation actions when the workspace entitlement blocks them', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_value',
value: false,
);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $tenant->workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
value: 'Workspace is temporarily limited to manual reporting only',
);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
expect(ReviewPackResource::reviewPackGenerationActionTooltip($tenant))
->toBe('Review pack generation is disabled by workspace override. Reason: Workspace is temporarily limited to manual reporting only');
$listPage = Livewire::actingAs($user)
->test(ListReviewPacks::class)
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
$emptyStateAction = getReviewPackEmptyStateAction($listPage, 'generate_first');
$headerAction = getReviewPackHeaderAction($listPage, 'generate_pack');
expect($emptyStateAction)->not->toBeNull()
->and($emptyStateAction?->isDisabled())->toBeTrue()
->and($headerAction)->not->toBeNull()
->and($headerAction?->isVisible())->toBeFalse();
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
Livewire::actingAs($user)
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
->assertActionVisible('regenerate')
->assertActionDisabled('regenerate');
->test(ListReviewPacks::class)
->assertActionVisible('generate_pack')
->assertActionDisabled('generate_pack')
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
});
it('reuses an existing ready pack instead of starting a new run', function (): void {
@ -332,12 +225,6 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ReviewPack::factory()->failed()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
@ -349,7 +236,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
])
->assertNotified();
expect(ReviewPack::query()->count())->toBe(1);
expect(ReviewPack::query()->count())->toBe(0);
Queue::assertNothingPushed();
});

View File

@ -11,9 +11,6 @@
use App\Models\Tenant;
use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\ReasonTranslation\ReasonPresenter;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -51,13 +48,7 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
'finding_type' => Finding::FINDING_TYPE_DRIFT,
]);
OperationRun::factory()->forTenant($tenant)->create([
'type' => OperationRunType::TenantReviewCompose->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'started_at' => now()->subMinute(),
'completed_at' => now(),
]);
OperationRun::factory()->forTenant($tenant)->create();
/** @var EvidenceSnapshotService $service */
$service = app(EvidenceSnapshotService::class);

View File

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Settings\SettingsResolver;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
/**
* @return array{0: Workspace, 1: User}
*/
function workspaceAiPolicyManager(): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$workspace, $user];
}
it('renders the workspace ai policy section and lets managers save and reset the ai posture', function (): void {
[$workspace, $user] = workspaceAiPolicyManager();
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful()
->assertSee('Workspace AI policy')
->assertSee('Disabled')
->assertSee('Private only')
->assertSee('Approved use cases')
->assertSee('Blocked data classifications');
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
->toBe('disabled');
$component = Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.ai_policy_mode', null)
->set('data.ai_policy_mode', 'private_only')
->callAction('save')
->assertHasNoErrors()
->assertSet('data.ai_policy_mode', 'private_only');
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
->toBe('private_only');
$component
->mountFormComponentAction('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
->callMountedFormComponentAction()
->assertHasNoErrors()
->assertSet('data.ai_policy_mode', null);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
->toBe('disabled');
});

View File

@ -79,76 +79,3 @@
->and(data_get($audit?->metadata, 'before_value'))->toBe(48)
->and(data_get($audit?->metadata, 'after_value'))->toBe(30);
});
it('writes a workspace-scoped audit entry when ai policy mode is updated', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'ai',
key: 'policy_mode',
value: 'private_only',
);
$audit = AuditLog::query()->latest('id')->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
->and($audit?->tenant_id)->toBeNull()
->and($audit?->action)->toBe(AuditActionId::WorkspaceSettingUpdated->value)
->and(data_get($audit?->metadata, 'domain'))->toBe('ai')
->and(data_get($audit?->metadata, 'key'))->toBe('policy_mode')
->and(data_get($audit?->metadata, 'scope'))->toBe('workspace')
->and(data_get($audit?->metadata, 'before_value'))->toBeNull()
->and(data_get($audit?->metadata, 'after_value'))->toBe('private_only');
});
it('writes a workspace-scoped audit entry when ai policy mode is reset', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'ai',
key: 'policy_mode',
value: 'private_only',
);
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: 'ai',
key: 'policy_mode',
);
$audit = AuditLog::query()
->where('action', AuditActionId::WorkspaceSettingReset->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
->and($audit?->tenant_id)->toBeNull()
->and(data_get($audit?->metadata, 'domain'))->toBe('ai')
->and(data_get($audit?->metadata, 'key'))->toBe('policy_mode')
->and(data_get($audit?->metadata, 'scope'))->toBe('workspace')
->and(data_get($audit?->metadata, 'before_value'))->toBe('private_only')
->and(data_get($audit?->metadata, 'after_value'))->toBe('disabled');
});

View File

@ -44,7 +44,6 @@ function workspaceManagerUser(): array
$component = Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.ai_policy_mode', null)
->assertSet('data.backup_retention_keep_last_default', null)
->assertSet('data.backup_retention_min_floor', null)
->assertSet('data.drift_severity_mapping', [])
@ -59,7 +58,6 @@ function workspaceManagerUser(): array
->assertSet('data.findings_sla_low', null)
->assertSet('data.operations_operation_run_retention_days', null)
->assertSet('data.operations_stuck_run_threshold_minutes', null)
->set('data.ai_policy_mode', 'private_only')
->set('data.backup_retention_keep_last_default', 55)
->set('data.backup_retention_min_floor', 12)
->set('data.drift_severity_mapping', ['drift' => 'critical'])
@ -76,7 +74,6 @@ function workspaceManagerUser(): array
->set('data.operations_stuck_run_threshold_minutes', 60)
->callAction('save')
->assertHasNoErrors()
->assertSet('data.ai_policy_mode', 'private_only')
->assertSet('data.backup_retention_keep_last_default', 55)
->assertSet('data.backup_retention_min_floor', 12)
->assertSet('data.baseline_severity_missing_policy', 'critical')
@ -100,9 +97,6 @@ function workspaceManagerUser(): array
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default'))
->toBe(55);
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
->toBe('private_only');
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_min_floor'))
->toBe(12);
@ -148,18 +142,6 @@ function workspaceManagerUser(): array
->where('key', 'retention_keep_last_default')
->exists())->toBeFalse();
$component
->mountFormComponentAction('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
->callMountedFormComponentAction()
->assertHasNoErrors()
->assertSet('data.ai_policy_mode', null);
expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', 'ai')
->where('key', 'policy_mode')
->exists())->toBeFalse();
$component
->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
->callMountedFormComponentAction()

View File

@ -5,7 +5,6 @@
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
@ -13,14 +12,6 @@
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceSetting::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'domain' => 'ai',
'key' => 'policy_mode',
'value' => 'private_only',
'updated_by_user_id' => null,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)

View File

@ -30,14 +30,6 @@
'updated_by_user_id' => null,
]);
WorkspaceSetting::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'domain' => 'ai',
'key' => 'policy_mode',
'value' => 'private_only',
'updated_by_user_id' => null,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
@ -46,7 +38,6 @@
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.ai_policy_mode', 'private_only')
->assertSet('data.backup_retention_keep_last_default', 27)
->assertSet('data.backup_retention_min_floor', null)
->assertSet('data.drift_severity_mapping', [])
@ -65,8 +56,6 @@
->assertActionDisabled('save')
->assertFormComponentActionVisible('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
->assertFormComponentActionDisabled('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
->assertFormComponentActionVisible('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
->assertFormComponentActionDisabled('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
->assertFormComponentActionVisible('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
@ -86,11 +75,6 @@
->call('save')
->assertStatus(403);
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->call('resetSetting', 'ai_policy_mode')
->assertStatus(403);
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->call('resetSetting', 'backup_retention_keep_last_default')
@ -104,12 +88,5 @@
->where('key', 'retention_keep_last_default')
->first();
$aiSetting = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', 'ai')
->where('key', 'policy_mode')
->first();
expect($setting)->not->toBeNull()
->and($aiSetting)->not->toBeNull();
expect($setting)->not->toBeNull();
});

View File

@ -1,166 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function operationSupportRequestComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
function operationSupportRequestHeaderActions(\Livewire\Features\SupportTesting\Testable $component): array
{
$instance = $component->instance();
if ($instance->getCachedHeaderActions() === []) {
$instance->cacheInteractsWithHeaderActions();
}
return $instance->getCachedHeaderActions();
}
function operationSupportRequestHeaderPrimaryNames(\Livewire\Features\SupportTesting\Testable $component): array
{
return collect(operationSupportRequestHeaderActions($component))
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
->filter()
->values()
->all();
}
function operationSupportRequestHeaderMoreActionNames(\Livewire\Features\SupportTesting\Testable $component): array
{
$moreGroup = collect(operationSupportRequestHeaderActions($component))
->first(static fn ($action): bool => $action instanceof ActionGroup && $action->getLabel() === 'More');
return collect($moreGroup?->getActions() ?? [])
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
->filter()
->values()
->all();
}
function operationSupportRequestHeaderMoreAction(\Livewire\Features\SupportTesting\Testable $component, string $name): ?Action
{
$moreGroup = collect(operationSupportRequestHeaderActions($component))
->first(static fn ($action): bool => $action instanceof ActionGroup && $action->getLabel() === 'More');
$action = collect($moreGroup?->getActions() ?? [])
->first(static fn ($action): bool => $action instanceof Action && $action->getName() === $name);
return $action instanceof Action ? $action : null;
}
it('creates a run-scoped support request from the tenantless operation viewer', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'failure_summary' => [[
'message' => 'Run failed after provider validation.',
]],
'completed_at' => now()->subMinutes(10),
]);
$component = operationSupportRequestComponent($user, $run);
expect(operationSupportRequestHeaderPrimaryNames($component))
->not->toContain('openSupportDiagnostics')
->not->toContain('requestSupport')
->and(operationSupportRequestHeaderMoreActionNames($component))
->toEqualCanonicalizing(['openSupportDiagnostics', 'requestSupport'])
->and(operationSupportRequestHeaderMoreAction($component, 'openSupportDiagnostics')?->isIconButton())
->toBeFalse();
$component
->assertActionVisible('openSupportDiagnostics')
->assertActionEnabled('openSupportDiagnostics')
->assertActionVisible('requestSupport')
->assertActionEnabled('requestSupport')
->assertActionExists('requestSupport', fn (Action $action): bool => $action->getLabel() === 'Request support')
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_BLOCKING,
'summary' => 'This failed operation needs support escalation.',
'reproduction_notes' => 'Open the canonical run detail and submit the request from the grouped secondary action.',
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->internal_reference)->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
->and($supportRequest->workspace_id)->toBe((int) $tenant->workspace_id)
->and($supportRequest->tenant_id)->toBe((int) $tenant->getKey())
->and($supportRequest->initiated_by_user_id)->toBe((int) $user->getKey())
->and($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
->and($supportRequest->operation_run_id)->toBe((int) $run->getKey())
->and($supportRequest->attachment_mode)->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
->and($supportRequest->severity)->toBe(SupportRequest::SEVERITY_BLOCKING)
->and($supportRequest->summary)->toBe('This failed operation needs support escalation.')
->and(data_get($supportRequest->context_envelope, 'primary_context.type'))->toBe('operation_run')
->and(data_get($supportRequest->context_envelope, 'primary_context.operation_run_id'))->toBe((int) $run->getKey())
->and(data_get($supportRequest->context_envelope, 'diagnostic_snapshot'))->toBeArray();
});
it('keeps tenantless operation detail deny-as-not-found for workspace members without tenant entitlement', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});

View File

@ -1,204 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function supportRequestAuditTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function supportRequestAuditOperationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
it('records a redacted audit entry for tenant-scoped support requests', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Audit Support Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ProviderConnection::factory()
->withCredential()
->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Audit Microsoft connection',
'verification_status' => ProviderVerificationStatus::Blocked->value,
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'last_error_message' => 'tenant-provider-secret',
]);
supportRequestAuditTenantComponent($user, $tenant)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_HIGH,
'summary' => 'Need tenant support audit proof.',
])
->callMountedAction()
->assertHasNoActionErrors();
$supportRequest = SupportRequest::query()->sole();
$audit = AuditLog::query()
->where('action', AuditActionId::SupportRequestCreated->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
->and($audit?->resource_type)->toBe('support_request')
->and($audit?->resource_id)->toBe((string) $supportRequest->getKey())
->and($audit?->target_label)->toBe($supportRequest->internal_reference)
->and($audit?->operation_run_id)->toBeNull()
->and(data_get($audit?->metadata, 'internal_reference'))->toBe($supportRequest->internal_reference)
->and(data_get($audit?->metadata, 'primary_context_type'))->toBe(SupportRequest::PRIMARY_CONTEXT_TENANT)
->and(data_get($audit?->metadata, 'primary_context_id'))->toBe((string) $tenant->getKey())
->and(data_get($audit?->metadata, 'attachment_mode'))->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
->and(data_get($audit?->metadata, 'redaction_mode'))->toBe('default_redacted')
->and((string) json_encode($audit?->metadata))->not->toContain('tenant-provider-secret');
});
it('records a redacted audit entry for run-scoped support requests', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'context' => [
'raw_response_body' => 'run-provider-secret',
],
'failure_summary' => [[
'message' => 'Run failed after provider validation.',
]],
'completed_at' => now(),
]);
supportRequestAuditOperationComponent($user, $run)
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_BLOCKING,
'summary' => 'Need run support audit proof.',
])
->callMountedAction()
->assertHasNoActionErrors();
$supportRequest = SupportRequest::query()->sole();
$audit = AuditLog::query()
->where('action', AuditActionId::SupportRequestCreated->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
->and($audit?->resource_type)->toBe('support_request')
->and($audit?->resource_id)->toBe((string) $supportRequest->getKey())
->and($audit?->target_label)->toBe($supportRequest->internal_reference)
->and($audit?->operation_run_id)->toBe((int) $run->getKey())
->and(data_get($audit?->metadata, 'internal_reference'))->toBe($supportRequest->internal_reference)
->and(data_get($audit?->metadata, 'primary_context_type'))->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
->and(data_get($audit?->metadata, 'primary_context_id'))->toBe((string) $run->getKey())
->and(data_get($audit?->metadata, 'attachment_mode'))->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
->and(data_get($audit?->metadata, 'redaction_mode'))->toBe('default_redacted')
->and((string) json_encode($audit?->metadata))->not->toContain('run-provider-secret');
});
it('creates distinct support references for duplicate submissions without outbound http or operation-run side effects', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'failure_summary' => [[
'message' => 'Run failed after provider validation.',
]],
'completed_at' => now(),
]);
$component = supportRequestAuditOperationComponent($user, $run);
$existingRunCount = OperationRun::query()->count();
assertNoOutboundHttp(function () use ($component): void {
$component
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_HIGH,
'summary' => 'Duplicate run support request.',
])
->callMountedAction()
->assertHasNoActionErrors()
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_HIGH,
'summary' => 'Duplicate run support request.',
])
->callMountedAction()
->assertHasNoActionErrors();
});
$supportRequests = SupportRequest::query()
->orderBy('id')
->get();
$auditReferences = AuditLog::query()
->where('action', AuditActionId::SupportRequestCreated->value)
->orderBy('id')
->pluck('target_label');
expect($supportRequests)->toHaveCount(2)
->and($supportRequests->pluck('summary')->all())->toBe([
'Duplicate run support request.',
'Duplicate run support request.',
])
->and($supportRequests->pluck('internal_reference')->unique())->toHaveCount(2)
->and($supportRequests->pluck('operation_run_id')->unique()->all())->toBe([(int) $run->getKey()])
->and($auditReferences->all())->toBe($supportRequests->pluck('internal_reference')->all())
->and(OperationRun::query()->count())->toBe($existingRunCount)
->and($run->fresh()?->status)->toBe(OperationRunStatus::Completed->value)
->and($run->fresh()?->outcome)->toBe(OperationRunOutcome::Failed->value);
});

View File

@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function supportRequestAuthorizationTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function supportRequestAuthorizationOperationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
it('returns forbidden for entitled tenant members without support request capability', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
supportRequestAuthorizationTenantComponent($user, $tenant)
->assertActionVisible('requestSupport')
->assertActionDisabled('requestSupport')
->call('authorizeTenantSupportRequest')
->assertForbidden();
expect(SupportRequest::query()->count())->toBe(0);
});
it('returns forbidden for entitled run viewers without support request capability', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'completed_at' => now(),
]);
supportRequestAuthorizationOperationComponent($user, $run)
->assertActionVisible('requestSupport')
->assertActionDisabled('requestSupport')
->call('authorizeOperationRunSupportRequest')
->assertForbidden();
expect(SupportRequest::query()->count())->toBe(0);
});

View File

@ -1,137 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Livewire\Livewire;
use function Pest\Laravel\mock;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
function tenantSupportRequestComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
it('creates a tenant support request from the dashboard', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
tenantSupportRequestComponent($user, $tenant)
->assertActionVisible('requestSupport')
->assertActionEnabled('requestSupport')
->assertActionExists('requestSupport', fn (Action $action): bool => $action->getLabel() === 'Request support')
->mountAction('requestSupport')
->setActionData([
'severity' => SupportRequest::SEVERITY_HIGH,
'summary' => 'Policy sync failed after the latest tenant refresh.',
'reproduction_notes' => 'Open the tenant dashboard after a failed sync and request support from the header action.',
'contact_name' => 'Ops On Call',
'contact_email' => 'ops@example.test',
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->internal_reference)->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
->and($supportRequest->workspace_id)->toBe((int) $tenant->workspace_id)
->and($supportRequest->tenant_id)->toBe((int) $tenant->getKey())
->and($supportRequest->initiated_by_user_id)->toBe((int) $user->getKey())
->and($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_TENANT)
->and($supportRequest->operation_run_id)->toBeNull()
->and($supportRequest->attachment_mode)->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
->and($supportRequest->severity)->toBe(SupportRequest::SEVERITY_HIGH)
->and($supportRequest->summary)->toBe('Policy sync failed after the latest tenant refresh.')
->and($supportRequest->reproduction_notes)->toContain('failed sync')
->and($supportRequest->contact_name)->toBe('Ops On Call')
->and($supportRequest->contact_email)->toBe('ops@example.test')
->and(data_get($supportRequest->context_envelope, 'primary_context.type'))->toBe('tenant')
->and(data_get($supportRequest->context_envelope, 'primary_context.tenant_id'))->toBe((int) $tenant->getKey())
->and(data_get($supportRequest->context_envelope, 'diagnostic_snapshot'))->toBeArray();
});
it('stores canonical context only when the creator cannot view support diagnostics', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
$mock->shouldReceive('primeMemberships')->andReturnNull();
$mock->shouldReceive('isMember')
->andReturnUsing(static fn ($user, Tenant $resolvedTenant): bool => (int) $resolvedTenant->getKey() === (int) $tenant->getKey());
$mock->shouldReceive('can')
->andReturnUsing(static function ($user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
return match ($capability) {
Capabilities::SUPPORT_REQUESTS_CREATE => true,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW => false,
default => true,
};
});
});
tenantSupportRequestComponent($user, $tenant)
->assertActionVisible('requestSupport')
->assertActionEnabled('requestSupport')
->mountAction('requestSupport')
->setActionData([
'summary' => 'Need help reviewing the latest tenant support context.',
])
->callMountedAction()
->assertHasNoActionErrors()
->assertNotified('Support request submitted');
$supportRequest = SupportRequest::query()->sole();
expect($supportRequest->severity)->toBe(SupportRequest::SEVERITY_NORMAL)
->and($supportRequest->contact_name)->toBe($user->name)
->and($supportRequest->contact_email)->toBe($user->email)
->and($supportRequest->attachment_mode)->toBe(SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY)
->and(data_get($supportRequest->context_envelope, 'diagnostic_snapshot'))->toBeNull()
->and(data_get($supportRequest->context_envelope, 'omissions.0.reason'))->toBe('omitted_without_support_diagnostics_view');
});
it('keeps tenant dashboard support requests deny-as-not-found for workspace members without tenant entitlement', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'role' => 'operator',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
->assertNotFound();
});
it('returns forbidden for entitled tenant members without support request capability', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
tenantSupportRequestComponent($user, $tenant)
->assertActionVisible('requestSupport')
->assertActionDisabled('requestSupport')
->call('authorizeTenantSupportRequest')
->assertForbidden();
expect(SupportRequest::query()->count())->toBe(0);
});

View File

@ -1,171 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Dashboard;
use App\Filament\System\Widgets\CustomerHealthKpis;
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderVerificationStatus;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('shows customer health widgets to authorized system users', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Dashboard::getUrl(panel: 'system'))
->assertSuccessful()
->assertSeeLivewire(CustomerHealthKpis::class)
->assertSeeLivewire(CustomerHealthTopWorkspaces::class);
});
it('keeps the attention-needed widget hidden when no linked system detail surface is accessible', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Dashboard::getUrl(panel: 'system'))
->assertSuccessful()
->assertSeeLivewire(CustomerHealthKpis::class)
->assertDontSeeLivewire(CustomerHealthTopWorkspaces::class);
});
it('shows the attention-needed widget to operations-only users when operational rows are accessible', function (): void {
seedOperationalAttentionWorkspace('Ops Only Workspace');
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
PlatformCapabilities::OPERATIONS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Dashboard::getUrl(panel: 'system'))
->assertSuccessful()
->assertSeeLivewire(CustomerHealthKpis::class)
->assertSeeLivewire(CustomerHealthTopWorkspaces::class);
});
it('shows the attention-needed widget to ops and runbooks users when operational rows are accessible', function (): void {
seedOperationalAttentionWorkspace('Runbooks Ops Workspace');
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Dashboard::getUrl(panel: 'system'))
->assertSuccessful()
->assertSeeLivewire(CustomerHealthKpis::class)
->assertSeeLivewire(CustomerHealthTopWorkspaces::class);
});
it('filters directory-only attention rows out for operations-only users', function (): void {
seedOperationalAttentionWorkspace('Accessible Ops Workspace');
seedProviderAttentionWorkspace('Directory Only Workspace');
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
PlatformCapabilities::OPERATIONS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Dashboard::getUrl(panel: 'system'))
->assertSuccessful()
->assertSeeLivewire(CustomerHealthKpis::class)
->assertSeeLivewire(CustomerHealthTopWorkspaces::class)
->assertSee('Accessible Ops Workspace')
->assertDontSee('Directory Only Workspace');
});
it('forbids customer health widgets when system dashboard access is denied', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Dashboard::getUrl(panel: 'system'))
->assertForbidden();
});
function seedOperationalAttentionWorkspace(string $workspaceName): void
{
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
$tenant = Tenant::factory()->for($workspace)->create([
'name' => $workspaceName.' Tenant',
'status' => Tenant::STATUS_ACTIVE,
]);
OperationRun::factory()
->forTenant($tenant)
->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subHours(2),
'started_at' => null,
]);
}
function seedProviderAttentionWorkspace(string $workspaceName): void
{
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
$tenant = Tenant::factory()->for($workspace)->create([
'name' => $workspaceName.' Tenant',
'status' => Tenant::STATUS_ACTIVE,
]);
ProviderConnection::factory()
->for($tenant)
->create([
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Blocked->value,
]);
}

View File

@ -1,186 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Dashboard;
use App\Filament\System\Widgets\CustomerHealthKpis;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProductUsageEvent;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\SystemConsole\SystemConsoleWindow;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-27 12:00:00'));
});
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('renders aggregate healthy, warning, critical, and unknown counts with a visible time-basis cue', function (): void {
actingAsCustomerHealthSystemUser();
$baselineStats = customerHealthStats(Livewire::withQueryParams([
'window' => SystemConsoleWindow::LastDay,
])->test(CustomerHealthKpis::class));
seedCustomerHealthWorkspace('Healthy Workspace');
seedCustomerHealthWorkspace('Warning Workspace', recentUsage: false, historicalUsage: true);
seedCustomerHealthWorkspace('Critical Workspace', failedRun: true);
seedCustomerHealthWorkspace('Unknown Workspace', recentRun: false, readyReviewPack: false);
$stats = customerHealthStats(Livewire::withQueryParams([
'window' => SystemConsoleWindow::LastDay,
])->test(CustomerHealthKpis::class));
expect((int) $stats['Healthy']['value'] - (int) $baselineStats['Healthy']['value'])->toBe(1)
->and($stats['Healthy']['description'])->toBe('Operational stability, review-pack readiness, and engagement freshness honor Last 24 hours.')
->and((int) $stats['Warning']['value'] - (int) $baselineStats['Warning']['value'])->toBe(1)
->and($stats['Warning']['description'])->toBe('Onboarding readiness, provider health, and governance pressure stay point-in-time.')
->and((int) $stats['Critical']['value'] - (int) $baselineStats['Critical']['value'])->toBe(1)
->and((int) $stats['Unknown']['value'] - (int) $baselineStats['Unknown']['value'])->toBe(1)
->and($stats['Unknown']['description'])->toBe('Missing or stale inputs stay explicit instead of silently reading healthy.');
});
it('registers the customer health widget on the system dashboard for authorized users', function (): void {
$user = actingAsCustomerHealthSystemUser();
$response = $this->actingAs($user, 'platform')->get(Dashboard::getUrl(panel: 'system'));
$response->assertSuccessful()
->assertSee('Customer health')
->assertSeeLivewire(CustomerHealthKpis::class);
});
/**
* @return array<string, array{value:string,description:string|null}>
*/
function customerHealthStats($component): array
{
$method = new ReflectionMethod(CustomerHealthKpis::class, 'getStats');
$method->setAccessible(true);
return collect($method->invoke($component->instance()))
->mapWithKeys(fn (Stat $stat): array => [
(string) $stat->getLabel() => [
'value' => (string) $stat->getValue(),
'description' => $stat->getDescription(),
],
])
->all();
}
function actingAsCustomerHealthSystemUser(): PlatformUser
{
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
],
'is_active' => true,
]);
test()->actingAs($user, 'platform');
return $user;
}
/**
* @return array{workspace: Workspace, tenant: Tenant}
*/
function seedCustomerHealthWorkspace(
string $workspaceName,
bool $readyReviewPack = true,
bool $recentUsage = true,
bool $historicalUsage = false,
bool $recentRun = true,
bool $failedRun = false,
): array {
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
$tenant = Tenant::factory()->for($workspace)->create([
'name' => $workspaceName.' Tenant',
'status' => Tenant::STATUS_ACTIVE,
]);
ProviderConnection::factory()
->for($tenant)
->verifiedHealthy()
->create([
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
]);
if ($readyReviewPack) {
ReviewPack::factory()
->for($tenant)
->ready()
->create([
'workspace_id' => (int) $workspace->getKey(),
'created_at' => now()->subHour(),
'generated_at' => now()->subHour(),
'expires_at' => now()->addDays(30),
]);
}
if ($recentUsage) {
ProductUsageEvent::factory()
->for($tenant)
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
->create([
'workspace_id' => (int) $workspace->getKey(),
'occurred_at' => now()->subMinutes(20),
]);
} elseif ($historicalUsage) {
ProductUsageEvent::factory()
->for($tenant)
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
->create([
'workspace_id' => (int) $workspace->getKey(),
'occurred_at' => now()->subDays(3),
]);
}
if ($recentRun) {
OperationRun::factory()
->forTenant($tenant)
->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => OperationRunStatus::Completed->value,
'outcome' => $failedRun ? OperationRunOutcome::Failed->value : OperationRunOutcome::Succeeded->value,
'created_at' => now()->subMinutes(10),
'started_at' => now()->subMinutes(15),
'completed_at' => now()->subMinutes(5),
]);
}
Finding::factory()
->for($tenant)
->closed()
->create([
'severity' => Finding::SEVERITY_LOW,
]);
return [
'workspace' => $workspace,
'tenant' => $tenant,
];
}

View File

@ -1,158 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProductUsageEvent;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\System\SystemDirectoryLinks;
use App\Support\SystemConsole\SystemConsoleWindow;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-27 12:00:00'));
});
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('renders a decision-first customer health card on the tenant detail page before diagnostics', function (): void {
$fixture = createCustomerHealthDecisionFixture('Tenant Decision Workspace');
$platformUser = createDirectoryPlatformUser();
$response = $this->actingAs($platformUser, 'platform')
->get(SystemDirectoryLinks::tenantDetail($fixture['tenant']).'?window='.SystemConsoleWindow::LastWeek)
->assertSuccessful()
->assertSee('Customer health decision')
->assertSee('Overall health')
->assertSee('Reason')
->assertSee('Impact')
->assertSee('Recommended next action')
->assertSee('Top driver: Provider connection health')
->assertSee('Default provider consent or verification is blocking reliable tenant management.')
->assertSee('Review connectivity signals below and confirm the default provider consent and verification state.')
->assertSee('Last 7 days');
$html = $response->getContent();
$decisionPosition = strpos($html, 'Customer health decision');
$diagnosticsPosition = strpos($html, 'Connectivity signals');
expect($decisionPosition)->not->toBeFalse()
->and($diagnosticsPosition)->not->toBeFalse()
->and($decisionPosition)->toBeLessThan($diagnosticsPosition);
});
it('renders a decision-first customer health card on the workspace detail page before tenant diagnostics', function (): void {
$fixture = createCustomerHealthDecisionFixture('Workspace Decision Workspace');
$platformUser = createDirectoryPlatformUser();
$response = $this->actingAs($platformUser, 'platform')
->get(SystemDirectoryLinks::workspaceDetail($fixture['workspace']).'?window='.SystemConsoleWindow::LastWeek)
->assertSuccessful()
->assertSee('Customer health decision')
->assertSee('Overall health')
->assertSee('Reason')
->assertSee('Impact')
->assertSee('Recommended next action')
->assertSee('Top driver: Provider connection health')
->assertSee('Default provider consent or verification is blocking reliable tenant management.')
->assertSee('Open the affected tenant below and review the default provider connection state.')
->assertSee('Last 7 days');
$html = $response->getContent();
$decisionPosition = strpos($html, 'Customer health decision');
$diagnosticsPosition = strpos($html, 'Tenants summary');
expect($decisionPosition)->not->toBeFalse()
->and($diagnosticsPosition)->not->toBeFalse()
->and($decisionPosition)->toBeLessThan($diagnosticsPosition);
});
function createDirectoryPlatformUser(): PlatformUser
{
return PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
}
/**
* @return array{workspace: Workspace, tenant: Tenant}
*/
function createCustomerHealthDecisionFixture(string $workspaceName): array
{
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
$tenant = Tenant::factory()->for($workspace)->create([
'name' => $workspaceName.' Tenant',
'status' => Tenant::STATUS_ACTIVE,
]);
ProviderConnection::factory()
->for($tenant)
->create([
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Blocked->value,
]);
ReviewPack::factory()
->for($tenant)
->ready()
->create([
'workspace_id' => (int) $workspace->getKey(),
'created_at' => now()->subDays(3),
'generated_at' => now()->subDays(3),
'expires_at' => now()->addDays(30),
]);
ProductUsageEvent::factory()
->for($tenant)
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
->create([
'workspace_id' => (int) $workspace->getKey(),
'occurred_at' => now()->subDays(3),
]);
OperationRun::factory()
->forTenant($tenant)
->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'created_at' => now()->subDays(3),
'started_at' => now()->subDays(3)->subMinutes(5),
'completed_at' => now()->subDays(3),
]);
Finding::factory()
->for($tenant)
->closed()
->create([
'severity' => Finding::SEVERITY_LOW,
]);
return [
'workspace' => $workspace,
'tenant' => $tenant,
];
}

View File

@ -1,177 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProductUsageEvent;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\SystemConsoleWindow;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-27 12:00:00'));
});
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('lists the worst workspaces first with dominant reasons and one platform-safe next link per row', function (): void {
actingAsExplainabilitySystemUser();
$opsWorkspace = seedExplainabilityWorkspace('Alpha Operations');
OperationRun::factory()
->forTenant($opsWorkspace['tenant'])
->create([
'workspace_id' => (int) $opsWorkspace['workspace']->getKey(),
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subHours(2),
'started_at' => null,
]);
$providerWorkspace = seedExplainabilityWorkspace('Beta Provider');
ProviderConnection::query()
->where('tenant_id', (int) $providerWorkspace['tenant']->getKey())
->update([
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Blocked->value,
]);
$viewData = customerHealthTopWorkspaceViewData(SystemConsoleWindow::LastWeek);
$rows = collect($viewData['rows'])->take(2)->values();
expect($rows[0]['workspace_label'])->toBe('Alpha Operations')
->and($rows[0]['overall']['label'])->toBe('Critical')
->and(array_column($rows[0]['dominant_dimensions'], 'label'))->toBe(['Operational stability'])
->and($rows[0]['next_link'])->toBe([
'label' => 'Open runs',
'url' => SystemOperationRunLinks::index(),
])
->and($rows[1]['workspace_label'])->toBe('Beta Provider')
->and($rows[1]['overall']['label'])->toBe('Critical')
->and(array_column($rows[1]['dominant_dimensions'], 'label'))->toBe(['Provider connection health'])
->and($rows[1]['next_link'])->toBe([
'label' => 'Review health details',
'url' => SystemDirectoryLinks::tenantDetail($providerWorkspace['tenant']).'?window='.SystemConsoleWindow::LastWeek,
])
->and($rows->every(fn (array $row): bool => count($row['dominant_dimensions']) <= 2))->toBeTrue()
->and($rows->every(fn (array $row): bool => ! str_contains($row['next_link']['url'], '/admin/')))->toBeTrue()
->and(array_key_exists('failed_count', $rows[0]))->toBeFalse();
});
/**
* @return array<string, mixed>
*/
function customerHealthTopWorkspaceViewData(string $window = SystemConsoleWindow::LastDay): array
{
$component = Livewire::withQueryParams([
'window' => $window,
])->test(CustomerHealthTopWorkspaces::class);
$method = new ReflectionMethod(CustomerHealthTopWorkspaces::class, 'getViewData');
$method->setAccessible(true);
return $method->invoke($component->instance());
}
function actingAsExplainabilitySystemUser(): PlatformUser
{
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::OPERATIONS_VIEW,
],
'is_active' => true,
]);
test()->actingAs($user, 'platform');
return $user;
}
/**
* @return array{workspace: Workspace, tenant: Tenant}
*/
function seedExplainabilityWorkspace(string $workspaceName): array
{
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
$tenant = Tenant::factory()->for($workspace)->create([
'name' => $workspaceName.' Tenant',
'status' => Tenant::STATUS_ACTIVE,
]);
ProviderConnection::factory()
->for($tenant)
->verifiedHealthy()
->create([
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
]);
ReviewPack::factory()
->for($tenant)
->ready()
->create([
'workspace_id' => (int) $workspace->getKey(),
'created_at' => now()->subHour(),
'generated_at' => now()->subHour(),
'expires_at' => now()->addDays(30),
]);
ProductUsageEvent::factory()
->for($tenant)
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
->create([
'workspace_id' => (int) $workspace->getKey(),
'occurred_at' => now()->subMinutes(20),
]);
OperationRun::factory()
->forTenant($tenant)
->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'created_at' => now()->subMinutes(10),
'started_at' => now()->subMinutes(15),
'completed_at' => now()->subMinutes(5),
]);
Finding::factory()
->for($tenant)
->closed()
->create([
'severity' => Finding::SEVERITY_LOW,
]);
return [
'workspace' => $workspace,
'tenant' => $tenant,
];
}

View File

@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Controls;
use App\Models\AuditLog;
use App\Models\OperationalControlActivation;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
function makeAiControlsManager(): PlatformUser
{
return PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_CONTROLS_MANAGE,
],
'is_active' => true,
]);
}
it('pauses and resumes ai execution through the global-only controls card', function (): void {
$workspaceA = Workspace::factory()->create(['name' => 'Acme']);
$workspaceB = Workspace::factory()->create(['name' => 'Bravo']);
Tenant::factory()->count(2)->create(['workspace_id' => (int) $workspaceA->getKey()]);
Tenant::factory()->count(1)->create(['workspace_id' => (int) $workspaceB->getKey()]);
$user = makeAiControlsManager();
$this->actingAs($user, 'platform');
$this->get(Controls::getUrl(panel: 'system'))
->assertSuccessful()
->assertSee("mountAction('pause_ai_execution')", escape: false);
$component = Livewire::test(Controls::class)
->assertActionExists('pause_ai_execution', fn (Action $action): bool => $action->isConfirmationRequired())
->assertActionExists('resume_ai_execution', fn (Action $action): bool => $action->isConfirmationRequired())
->assertActionExists('view_history_ai_execution', fn (Action $action): bool => $action->getLabel() === 'View AI execution history');
$summary = $component->instance()->controlSummary('ai.execution');
$preview = $component->instance()->scopeImpactPreview('ai.execution', 'global', null);
expect($summary['label'])->toBe('AI execution')
->and($summary['supported_scopes'])->toBe(['global'])
->and($summary['effective_state'])->toBe('enabled')
->and($preview['summary'])->toContain('AI execution')
->and($preview['workspace_count'])->toBe(2)
->and($preview['tenant_count'])->toBe(3);
$component
->callAction('pause_ai_execution', data: [
'scope_type' => 'global',
'reason_text' => 'Paused for AI rollout review.',
'expires_at' => now()->addDay()->toDateTimeString(),
])
->assertNotified('AI execution paused');
$activation = OperationalControlActivation::query()
->forControl('ai.execution')
->forGlobalScope()
->first();
expect($activation)->not->toBeNull()
->and($activation?->reason_text)->toBe('Paused for AI rollout review.');
$pausedSummary = $component->instance()->controlSummary('ai.execution');
expect($pausedSummary['effective_state'])->toBe('paused')
->and($pausedSummary['state_label'])->toBe('Paused globally');
$component
->callAction('resume_ai_execution', data: [
'scope_type' => 'global',
])
->assertNotified('AI execution resumed');
expect(OperationalControlActivation::query()
->forControl('ai.execution')
->forGlobalScope()
->count())->toBe(0);
$audits = AuditLog::query()
->whereIn('action', [
AuditActionId::OperationalControlPaused->value,
AuditActionId::OperationalControlResumed->value,
])
->where('metadata->control_key', 'ai.execution')
->orderBy('id')
->get();
expect($audits)->toHaveCount(2)
->and($audits[0]->workspace_id)->toBeNull()
->and($audits[1]->workspace_id)->toBeNull();
});

View File

@ -6,8 +6,6 @@
use App\Filament\System\Widgets\ControlTowerKpis;
use App\Filament\System\Widgets\ControlTowerRecentFailures;
use App\Filament\System\Widgets\ControlTowerTopOffenders;
use App\Filament\System\Widgets\CustomerHealthKpis;
use App\Filament\System\Widgets\CustomerHealthTopWorkspaces;
use App\Filament\System\Widgets\ProductTelemetryKpis;
use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities;
@ -74,23 +72,17 @@
$widgets = $component->instance()->getWidgets();
expect($widgets)->toHaveCount(7)
expect($widgets)->toHaveCount(5)
->and($widgets[1])->toBeInstanceOf(WidgetConfiguration::class)
->and($widgets[2])->toBeInstanceOf(WidgetConfiguration::class)
->and($widgets[3])->toBeInstanceOf(WidgetConfiguration::class)
->and($widgets[4])->toBeInstanceOf(WidgetConfiguration::class)
->and($widgets[5])->toBeInstanceOf(WidgetConfiguration::class)
->and($widgets[6])->toBeInstanceOf(WidgetConfiguration::class)
->and($widgets[1]->widget)->toBe(CustomerHealthKpis::class)
->and($widgets[2]->widget)->toBe(CustomerHealthTopWorkspaces::class)
->and($widgets[3]->widget)->toBe(ControlTowerKpis::class)
->and($widgets[4]->widget)->toBe(ProductTelemetryKpis::class)
->and($widgets[5]->widget)->toBe(ControlTowerTopOffenders::class)
->and($widgets[6]->widget)->toBe(ControlTowerRecentFailures::class)
->and($widgets[1]->widget)->toBe(ControlTowerKpis::class)
->and($widgets[2]->widget)->toBe(ProductTelemetryKpis::class)
->and($widgets[3]->widget)->toBe(ControlTowerTopOffenders::class)
->and($widgets[4]->widget)->toBe(ControlTowerRecentFailures::class)
->and($widgets[1]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
->and($widgets[2]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
->and($widgets[3]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
->and($widgets[4]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
->and($widgets[5]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
->and($widgets[6]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek]);
->and($widgets[4]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek]);
});

View File

@ -76,8 +76,7 @@
->assertSee('Residual Directory Workspace')
->assertSee('Connectivity signals')
->assertSee('Residual Default Connection')
->assertSee('Open in tenant admin')
->assertSee('Requires tenant admin membership.')
->assertSee('Open in /admin')
->assertSee(SystemDirectoryLinks::adminTenant($tenant), false)
->assertSee('Open operations runs')
->assertSee(SystemOperationRunLinks::index(), false)

View File

@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Directory\ViewWorkspace;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
it('renders the read-only workspace entitlement summary on the system workspace detail page', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Acme Workspace']);
$manager = User::factory()->create(['name' => 'Workspace Manager']);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $manager->getKey(),
'role' => 'manager',
]);
Tenant::factory()->count(2)->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'plan_profile',
value: 'starter',
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'managed_tenant_limit_override_value',
value: 2,
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'managed_tenant_limit_override_reason',
value: 'Pilot workspace',
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_value',
value: false,
);
$writer->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
value: 'Escalation only',
);
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
$this->actingAs($platformUser, 'platform')
->get(ViewWorkspace::getUrl(panel: 'system', parameters: ['workspace' => $workspace]))
->assertSuccessful()
->assertSee('Workspace entitlements')
->assertSee('Starter')
->assertSee('Pilot workspace')
->assertSee('Escalation only')
->assertSee('workspace override')
->assertDontSee('Save');
});

View File

@ -1,155 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: Workspace, 1: User}
*/
function entitledWorkspaceManager(): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
return [$workspace, $user];
}
it('falls back to the default plan profile when a workspace has no entitlement settings', function (): void {
[$workspace] = entitledWorkspaceManager();
$resolver = app(WorkspaceEntitlementResolver::class);
$managedTenantLimit = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
$reviewPackGeneration = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
expect($managedTenantLimit)
->toMatchArray([
'plan_profile_id' => 'standard',
'key' => WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
'effective_value' => 25,
'source' => 'plan_profile_default',
'current_usage' => 0,
'remaining_capacity' => 25,
'is_blocked' => false,
])
->and($managedTenantLimit['rationale'])->toBe('Balanced defaults for most managed workspaces.')
->and($managedTenantLimit['last_changed_at'])->toBeNull()
->and($managedTenantLimit['last_changed_by'])->toBeNull();
expect($reviewPackGeneration)
->toMatchArray([
'plan_profile_id' => 'standard',
'key' => WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
'effective_value' => true,
'source' => 'plan_profile_default',
'is_blocked' => false,
]);
});
it('applies the selected plan profile defaults when no explicit override is set', function (): void {
[$workspace, $user] = entitledWorkspaceManager();
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_PLAN_PROFILE,
value: 'starter',
);
$resolver = app(WorkspaceEntitlementResolver::class);
$managedTenantLimit = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
$reviewPackGeneration = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
expect($managedTenantLimit)
->toMatchArray([
'plan_profile_id' => 'starter',
'effective_value' => 1,
'source' => 'plan_profile_default',
'current_usage' => 0,
'remaining_capacity' => 1,
'is_blocked' => false,
])
->and($managedTenantLimit['last_changed_by'])->toBe($user->name)
->and($managedTenantLimit['last_changed_at'])->not->toBeNull();
expect($reviewPackGeneration)
->toMatchArray([
'plan_profile_id' => 'starter',
'effective_value' => false,
'source' => 'plan_profile_default',
'is_blocked' => true,
])
->and($reviewPackGeneration['block_reason'])->toContain('Starter');
});
it('applies workspace override values, rationale, and usage-aware blocking', function (): void {
[$workspace, $user] = entitledWorkspaceManager();
$writer = app(SettingsWriter::class);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_PLAN_PROFILE,
value: 'starter',
);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
value: 2,
);
$writer->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
value: 'Temporary support-approved exception',
);
Tenant::factory()->count(2)->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
expect($decision)
->toMatchArray([
'plan_profile_id' => 'starter',
'effective_value' => 2,
'source' => 'workspace_override',
'rationale' => 'Temporary support-approved exception',
'current_usage' => 2,
'remaining_capacity' => 0,
'is_blocked' => true,
'last_changed_by' => $user->name,
])
->and($decision['last_changed_at'])->not->toBeNull()
->and($decision['block_reason'])->toContain('workspace override')
->and($decision['block_reason'])->toContain('Temporary support-approved exception');
});

View File

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
it('exposes a bounded profile catalog with exactly one default profile', function (): void {
$catalog = app(WorkspacePlanProfileCatalog::class);
$profiles = $catalog->all();
expect($profiles)
->toHaveCount(3)
->and(collect($profiles)->where('is_default', true))->toHaveCount(1)
->and(WorkspacePlanProfileCatalog::defaultProfileId())->toBe('standard')
->and($catalog->default()['label'])->toBe('Standard');
});
it('resolves known profiles and falls back to the default for unknown identifiers', function (): void {
$catalog = app(WorkspacePlanProfileCatalog::class);
expect($catalog->resolve('starter'))
->toMatchArray([
'id' => 'starter',
'managed_tenant_limit_default' => 1,
'review_pack_generation_default' => false,
])
->and($catalog->resolve('missing-profile')['id'])->toBe('standard')
->and($catalog->optionLabels())
->toMatchArray([
'starter' => 'Starter',
'standard' => 'Standard',
'scale' => 'Scale',
]);
});

View File

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Ai\AiDataClassification;
use App\Support\ProductKnowledge\ContextualHelpResolver;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('exposes only the approved product knowledge source input for ai answer drafts', function (): void {
$source = app(ContextualHelpResolver::class)->aiProductKnowledgeAnswerDraftSource();
expect($source)->toMatchArray([
'use_case_key' => 'product_knowledge.answer_draft',
'source_family' => 'product_knowledge',
'data_classifications' => [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
])
->and($source['topics'])->not->toBeEmpty()
->and($source['operational_metadata'])->toHaveKeys(['version', 'topic_count'])
->and($source)->not->toHaveKeys(['tenant', 'tenant_id', 'workspace', 'workspace_id']);
});
it('exposes only the approved redacted support summary input for ai diagnostic drafts', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$source = app(SupportDiagnosticBundleBuilder::class)->aiSupportDiagnosticsSummaryDraftSource($tenant);
expect($source)->toMatchArray([
'use_case_key' => 'support_diagnostics.summary_draft',
'source_family' => 'support_diagnostics',
'data_classifications' => [
AiDataClassification::RedactedSupportSummary->value,
],
])
->and($source['summary'])->toHaveKeys([
'headline',
'dominant_issue',
'freshness_state',
'redaction_note',
'generated_from',
])
->and(data_get($source, 'redaction.mode'))->toBe('default_redacted')
->and($source)->not->toHaveKeys(['sections', 'context', 'tenant', 'workspace', 'operation_run']);
});

View File

@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Ai\AiDataClassification;
use App\Support\Ai\AiDecisionAuditMetadataFactory;
use App\Support\Ai\AiDecisionReasonCode;
use App\Support\Ai\AiExecutionDecision;
use App\Support\Ai\AiExecutionRequest;
use App\Support\Ai\AiProviderClass;
use App\Support\Audit\AuditActionId;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds bounded decision metadata without raw prompt, source, provider, or output payloads', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
$request = new AiExecutionRequest(
workspace: $workspace,
tenant: $tenant,
actor: null,
useCaseKey: 'support_diagnostics.summary_draft',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
sourceFamily: 'support_diagnostics',
callerSurface: 'support_diagnostics',
contextFingerprint: 'support_diagnostics:summary:v1',
);
$decision = new AiExecutionDecision(
outcome: 'blocked',
reasonCode: AiDecisionReasonCode::DataClassificationBlocked,
workspaceAiPolicyMode: 'private_only',
matchedOperationalControlScope: null,
useCaseKey: 'support_diagnostics.summary_draft',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
sourceFamily: 'support_diagnostics',
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
auditMetadata: [],
);
$metadata = app(AiDecisionAuditMetadataFactory::class)->make($request, $decision);
expect($metadata)->toMatchArray([
'use_case_key' => 'support_diagnostics.summary_draft',
'decision_outcome' => 'blocked',
'decision_reason' => AiDecisionReasonCode::DataClassificationBlocked->value,
'workspace_ai_policy_mode' => 'private_only',
'requested_provider_class' => 'local_private',
'data_classifications' => ['redacted_support_summary'],
'source_family' => 'support_diagnostics',
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'context_fingerprint' => 'support_diagnostics:summary:v1',
])
->and($metadata)->not->toHaveKeys([
'prompt_text',
'source_payload',
'provider_payload',
'output_text',
]);
});

View File

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
use App\Support\Ai\AiDataClassification;
use App\Support\Ai\AiPolicyMode;
use App\Support\Ai\AiUseCaseCatalog;
it('locks the first slice to the two approved private-only use cases', function (): void {
$definitions = app(AiUseCaseCatalog::class)->all();
expect($definitions)->toHaveCount(2)
->and($definitions[0])->toMatchArray([
'key' => 'product_knowledge.answer_draft',
'label' => 'Product knowledge answer draft',
'future_consumer' => 'ContextualHelpResolver',
'source_family' => 'product_knowledge',
'tenant_context_permitted' => false,
])
->and($definitions[0]['allowed_provider_classes'])->toBe(['local_private'])
->and($definitions[0]['allowed_data_classifications'])->toBe([
'product_knowledge',
'operational_metadata',
])
->and($definitions[1])->toMatchArray([
'key' => 'support_diagnostics.summary_draft',
'label' => 'Support diagnostics summary draft',
'future_consumer' => 'SupportDiagnosticBundleBuilder',
'source_family' => 'support_diagnostics',
'tenant_context_permitted' => true,
])
->and($definitions[1]['allowed_provider_classes'])->toBe(['local_private'])
->and($definitions[1]['allowed_data_classifications'])->toBe([
'redacted_support_summary',
]);
});
it('derives provider and blocked-data summaries from the catalog for the workspace policy surface', function (): void {
$catalog = app(AiUseCaseCatalog::class);
expect($catalog->allowedProviderClassLabelsForMode(AiPolicyMode::Disabled))->toBe([])
->and($catalog->allowedProviderClassLabelsForMode(AiPolicyMode::PrivateOnly))->toBe(['Local private'])
->and($catalog->blockedDataClassificationLabels())->toBe([
AiDataClassification::PersonalData->label(),
AiDataClassification::CustomerConfidential->label(),
AiDataClassification::RawProviderPayload->label(),
]);
});

View File

@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\OperationalControlActivation;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Models\WorkspaceSetting;
use App\Support\Ai\AiDataClassification;
use App\Support\Ai\AiDecisionReasonCode;
use App\Support\Ai\AiExecutionRequest;
use App\Support\Ai\AiProviderClass;
use App\Support\Ai\GovernedAiExecutionBoundary;
use App\Support\Audit\AuditActionId;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: Workspace, 1: User}
*/
function aiPolicyWorkspace(string $policyMode = 'private_only'): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
WorkspaceSetting::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'domain' => 'ai',
'key' => 'policy_mode',
'value' => $policyMode,
'updated_by_user_id' => (int) $user->getKey(),
]);
return [$workspace, $user];
}
it('allows approved local-private support-diagnostics requests and writes bounded audit metadata', function (): void {
[$workspace, $user] = aiPolicyWorkspace();
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
workspace: $workspace,
tenant: $tenant,
actor: $user,
useCaseKey: 'support_diagnostics.summary_draft',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
sourceFamily: 'support_diagnostics',
callerSurface: 'support_diagnostics',
contextFingerprint: 'support_diagnostics:summary:v1',
)));
expect($decision->isAllowed())->toBeTrue()
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::Allowed)
->and($decision->workspaceAiPolicyMode)->toBe('private_only')
->and($decision->matchedOperationalControlScope)->toBeNull();
$audit = AuditLog::query()->latest('id')->first();
expect($audit)->not->toBeNull()
->and($audit?->action)->toBe(AuditActionId::AiExecutionDecisionEvaluated->value)
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
->and(data_get($audit?->metadata, 'decision_outcome'))->toBe('allowed')
->and(data_get($audit?->metadata, 'decision_reason'))->toBe(AiDecisionReasonCode::Allowed->value)
->and(data_get($audit?->metadata, 'use_case_key'))->toBe('support_diagnostics.summary_draft')
->and(data_get($audit?->metadata, 'requested_provider_class'))->toBe('local_private')
->and(data_get($audit?->metadata, 'data_classifications'))->toBe(['redacted_support_summary'])
->and(data_get($audit?->metadata, 'context_fingerprint'))->toBe('support_diagnostics:summary:v1')
->and(data_get($audit?->metadata, 'prompt_text'))->toBeNull()
->and(data_get($audit?->metadata, 'output_text'))->toBeNull();
});
it('blocks external-public provider classes before any provider resolution', function (): void {
[$workspace, $user] = aiPolicyWorkspace();
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
workspace: $workspace,
tenant: null,
actor: $user,
useCaseKey: 'product_knowledge.answer_draft',
requestedProviderClass: AiProviderClass::ExternalPublic->value,
dataClassifications: [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
sourceFamily: 'product_knowledge',
callerSurface: 'product_knowledge',
contextFingerprint: 'product_knowledge:answer:v1',
)));
expect($decision->isBlocked())->toBeTrue()
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::ProviderClassBlocked)
->and($decision->matchedOperationalControlScope)->toBeNull();
});
it('blocks disallowed data classifications before any provider resolution', function (): void {
[$workspace, $user] = aiPolicyWorkspace();
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
workspace: $workspace,
tenant: $tenant,
actor: $user,
useCaseKey: 'support_diagnostics.summary_draft',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [AiDataClassification::RawProviderPayload->value],
sourceFamily: 'support_diagnostics',
callerSurface: 'support_diagnostics',
contextFingerprint: 'support_diagnostics:raw:v1',
)));
expect($decision->isBlocked())->toBeTrue()
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::DataClassificationBlocked);
});
it('blocks unregistered use cases', function (): void {
[$workspace, $user] = aiPolicyWorkspace();
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
workspace: $workspace,
tenant: null,
actor: $user,
useCaseKey: 'customer_email.reply',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [AiDataClassification::ProductKnowledge->value],
sourceFamily: 'product_knowledge',
callerSurface: 'product_knowledge',
contextFingerprint: 'customer_email:reply:v1',
)));
expect($decision->isBlocked())->toBeTrue()
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::UnregisteredUseCase);
});
it('lets the ai execution operational control override an otherwise valid request', function (): void {
[$workspace, $user] = aiPolicyWorkspace();
OperationalControlActivation::factory()->forGlobalScope()->create([
'control_key' => 'ai.execution',
'reason_text' => 'Paused for AI rollout review.',
]);
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
workspace: $workspace,
tenant: null,
actor: $user,
useCaseKey: 'product_knowledge.answer_draft',
requestedProviderClass: AiProviderClass::LocalPrivate->value,
dataClassifications: [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
sourceFamily: 'product_knowledge',
callerSurface: 'product_knowledge',
contextFingerprint: 'product_knowledge:answer:v1',
)));
expect($decision->isBlocked())->toBeTrue()
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::OperationalControlPaused)
->and($decision->matchedOperationalControlScope)->toBe('global');
});

View File

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
it('exposes the fixed first-slice health dimensions and their time basis', function (): void {
$catalog = new CustomerHealthDimensionCatalog();
expect($catalog->names())->toBe([
CustomerHealthDimensionCatalog::ONBOARDING_READINESS,
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH,
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY,
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE,
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS,
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS,
])->and($catalog->visibleDimensions())->toBe([
CustomerHealthDimensionCatalog::ONBOARDING_READINESS => 'Onboarding readiness',
CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH => 'Provider connection health',
CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY => 'Operational stability',
CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE => 'Governance pressure',
CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS => 'Review-pack readiness',
CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS => 'Engagement freshness',
])->and($catalog->isWindowed(CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY))->toBeTrue()
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS))->toBeTrue()
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS))->toBeTrue()
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::ONBOARDING_READINESS))->toBeFalse()
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH))->toBeFalse()
->and($catalog->isWindowed(CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE))->toBeFalse();
});
it('resolves the overall health level using the fixed severity precedence', function (): void {
$catalog = new CustomerHealthDimensionCatalog();
expect($catalog->resolveOverallLevel(['ok', 'unknown']))->toBe('unknown')
->and($catalog->resolveOverallLevel(['ok', 'warn', 'unknown']))->toBe('warn')
->and($catalog->resolveOverallLevel(['critical', 'ok']))->toBe('critical')
->and($catalog->resolveOverallLevel(['ok']))->toBe('ok');
});
it('rejects unknown customer health dimensions', function (): void {
$catalog = new CustomerHealthDimensionCatalog();
expect(fn () => $catalog->definition('customer_health.unknown'))
->toThrow(InvalidArgumentException::class, 'Unknown customer health dimension');
});

View File

@ -1,414 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProductUsageEvent;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\Workspace;
use App\Support\CustomerHealth\CustomerHealthDimensionCatalog;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\Onboarding\OnboardingLifecycleState;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Auth\PlatformCapabilities;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\SystemConsoleWindow;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::parse('2025-02-15 12:00:00'));
});
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
it('derives workspace health from existing onboarding, provider, and telemetry truth', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Acme']);
$tenant = Tenant::factory()->for($workspace)->create([
'name' => 'Acme Production',
'status' => Tenant::STATUS_ACTIVE,
]);
ProviderConnection::factory()
->for($tenant)
->create([
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Blocked->value,
]);
ProductUsageEvent::factory()
->for($tenant)
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
->create([
'workspace_id' => (int) $workspace->getKey(),
'occurred_at' => now()->subHours(2),
]);
Finding::factory()
->for($tenant)
->closed()
->create([
'severity' => Finding::SEVERITY_LOW,
]);
$summary = summaryForWorkspace($workspace);
expect($summary['overall_level'])->toBe('critical')
->and($summary['dimensions'][CustomerHealthDimensionCatalog::ONBOARDING_READINESS]['level'])->toBe('ok')
->and($summary['dimensions'][CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH]['level'])->toBe('critical')
->and($summary['dimensions'][CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS]['level'])->toBe('ok')
->and($summary['dominant_dimension_keys'])->toBe([CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH, CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY, CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS])
->and($summary['next_link'])->toBe([
'label' => 'Review health details',
'url' => SystemDirectoryLinks::tenantDetail($tenant).'?window='.SystemConsoleWindow::LastDay,
]);
});
it('applies the selected window review-pack readiness rules', function (): void {
$unknownWorkspace = createWorkspaceWithActiveTenant('Unknown Workspace');
$warnWorkspace = createWorkspaceWithActiveTenant('Warn Workspace');
$okWorkspace = createWorkspaceWithActiveTenant('Ok Workspace');
$criticalWorkspace = createWorkspaceWithActiveTenant('Critical Workspace');
ProductUsageEvent::factory()
->for($warnWorkspace['tenant'])
->forEvent(ProductUsageEventCatalog::REVIEW_PACK_REQUESTED)
->create([
'workspace_id' => (int) $warnWorkspace['workspace']->getKey(),
'occurred_at' => now()->subHours(1),
]);
ReviewPack::factory()
->for($okWorkspace['tenant'])
->ready()
->create([
'workspace_id' => (int) $okWorkspace['workspace']->getKey(),
'created_at' => now()->subHours(3),
'generated_at' => now()->subHours(2),
'expires_at' => now()->addDays(30),
]);
ReviewPack::factory()
->for($criticalWorkspace['tenant'])
->failed()
->create([
'workspace_id' => (int) $criticalWorkspace['workspace']->getKey(),
'created_at' => now()->subHours(2),
]);
$summaries = app(WorkspaceHealthSummaryQuery::class)
->summaries(SystemConsoleWindow::LastDay)
->keyBy('workspace_id');
expect($summaries[(int) $unknownWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('unknown')
->and($summaries[(int) $warnWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('warn')
->and($summaries[(int) $okWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('ok')
->and($summaries[(int) $criticalWorkspace['workspace']->getKey()]['dimensions'][CustomerHealthDimensionCatalog::REVIEW_PACK_READINESS]['level'])->toBe('critical');
});
it('uses the selected window for operations and engagement freshness', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Windowed Signals']);
$tenant = Tenant::factory()->for($workspace)->create([
'status' => Tenant::STATUS_ACTIVE,
'name' => 'Windowed Tenant',
]);
ProviderConnection::factory()
->for($tenant)
->verifiedHealthy()
->create([
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
]);
ProductUsageEvent::factory()
->for($tenant)
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
->create([
'workspace_id' => (int) $workspace->getKey(),
'occurred_at' => now()->subDays(3),
]);
OperationRun::factory()
->forTenant($tenant)
->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'created_at' => now()->subDays(2),
'started_at' => now()->subDays(2)->subMinutes(5),
'completed_at' => now()->subDays(2),
]);
$summary = summaryForWorkspace($workspace);
expect($summary['dimensions'][CustomerHealthDimensionCatalog::ENGAGEMENT_FRESHNESS]['level'])->toBe('warn')
->and($summary['dimensions'][CustomerHealthDimensionCatalog::OPERATIONAL_STABILITY]['level'])->toBe('unknown');
});
it('keeps governance pressure explicit until governance truth exists', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Governance Signals']);
$tenant = Tenant::factory()->for($workspace)->create([
'status' => Tenant::STATUS_ACTIVE,
'name' => 'Governance Tenant',
]);
ProviderConnection::factory()
->for($tenant)
->verifiedHealthy()
->create([
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
]);
ReviewPack::factory()
->for($tenant)
->ready()
->create([
'workspace_id' => (int) $workspace->getKey(),
'created_at' => now()->subHour(),
'generated_at' => now()->subHour(),
'expires_at' => now()->addDays(30),
]);
ProductUsageEvent::factory()
->for($tenant)
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
->create([
'workspace_id' => (int) $workspace->getKey(),
'occurred_at' => now()->subMinutes(20),
]);
OperationRun::factory()
->forTenant($tenant)
->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'created_at' => now()->subMinutes(10),
'started_at' => now()->subMinutes(15),
'completed_at' => now()->subMinutes(5),
]);
$summaryWithoutGovernanceTruth = summaryForWorkspace($workspace);
expect($summaryWithoutGovernanceTruth['dimensions'][CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE]['level'])->toBe('unknown');
Finding::factory()
->for($tenant)
->closed()
->create([
'severity' => Finding::SEVERITY_LOW,
]);
$summaryWithGovernanceTruth = summaryForWorkspace($workspace);
expect($summaryWithGovernanceTruth['dimensions'][CustomerHealthDimensionCatalog::GOVERNANCE_PRESSURE]['level'])->toBe('ok');
});
it('ignores archived workspaces and archived tenant truth for active workspace summaries', function (): void {
$archivedWorkspace = Workspace::factory()->create([
'name' => 'Archived Workspace',
'archived_at' => now()->subDay(),
]);
$archivedWorkspaceTenant = Tenant::factory()->for($archivedWorkspace)->create([
'status' => Tenant::STATUS_ACTIVE,
]);
ProductUsageEvent::factory()
->for($archivedWorkspaceTenant)
->create([
'workspace_id' => (int) $archivedWorkspace->getKey(),
'occurred_at' => now()->subHour(),
]);
$workspace = Workspace::factory()->create(['name' => 'Mixed Workspace']);
$activeTenant = Tenant::factory()->for($workspace)->create([
'status' => Tenant::STATUS_ACTIVE,
'name' => 'Active Tenant',
]);
$archivedTenant = Tenant::factory()->for($workspace)->create([
'status' => Tenant::STATUS_ARCHIVED,
'name' => 'Archived Tenant',
'deleted_at' => now()->subDay(),
]);
ProviderConnection::factory()
->for($activeTenant)
->verifiedHealthy()
->create([
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
]);
ProviderConnection::factory()
->for($archivedTenant)
->create([
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Blocked->value,
]);
$summaries = app(WorkspaceHealthSummaryQuery::class)->summaries();
$summary = $summaries->firstWhere('workspace_id', (int) $workspace->getKey());
expect($summaries->contains(fn (array $item): bool => $item['workspace_id'] === (int) $archivedWorkspace->getKey()))->toBeFalse()
->and($summary)->not->toBeNull()
->and($summary['dimensions'][CustomerHealthDimensionCatalog::PROVIDER_CONNECTION_HEALTH]['level'])->toBe('ok');
});
it('sorts the attention list by severity, breadth, and name', function (): void {
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
PlatformCapabilities::OPERATIONS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($platformUser, 'platform');
$criticalWorkspace = Workspace::factory()->create(['name' => 'Critical Workspace']);
$criticalTenant = Tenant::factory()->for($criticalWorkspace)->create([
'status' => Tenant::STATUS_ACTIVE,
'name' => 'Critical Tenant',
]);
ProviderConnection::factory()
->for($criticalTenant)
->verifiedHealthy()
->create([
'workspace_id' => (int) $criticalWorkspace->getKey(),
'is_default' => true,
]);
OperationRun::factory()
->forTenant($criticalTenant)
->create([
'workspace_id' => (int) $criticalWorkspace->getKey(),
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subHours(2),
'started_at' => null,
]);
$warnWorkspace = Workspace::factory()->create(['name' => 'Warn Workspace']);
$warnTenant = Tenant::factory()->for($warnWorkspace)->create([
'status' => Tenant::STATUS_ACTIVE,
'name' => 'Warn Tenant',
]);
ProviderConnection::factory()
->for($warnTenant)
->verifiedHealthy()
->create([
'workspace_id' => (int) $warnWorkspace->getKey(),
'is_default' => true,
]);
ProductUsageEvent::factory()
->for($warnTenant)
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
->create([
'workspace_id' => (int) $warnWorkspace->getKey(),
'occurred_at' => now()->subDays(5),
]);
$unknownWorkspace = Workspace::factory()->create(['name' => 'Unknown Workspace']);
TenantOnboardingSession::factory()
->forWorkspace($unknownWorkspace)
->create([
'lifecycle_state' => OnboardingLifecycleState::Draft->value,
]);
$attention = app(WorkspaceHealthSummaryQuery::class)
->attentionNeeded(SystemConsoleWindow::LastDay, 3)
->values();
expect($attention->pluck('workspace_name')->all())->toBe([
'Critical Workspace',
'Unknown Workspace',
'Warn Workspace',
])
->and($attention[0]['next_link'])->toBe([
'label' => 'Open runs',
'url' => SystemOperationRunLinks::index(),
]);
});
/**
* @return array{workspace: Workspace, tenant: Tenant}
*/
function createWorkspaceWithActiveTenant(string $workspaceName): array
{
$workspace = Workspace::factory()->create(['name' => $workspaceName]);
$tenant = Tenant::factory()->for($workspace)->create([
'name' => $workspaceName.' Tenant',
'status' => Tenant::STATUS_ACTIVE,
]);
ProviderConnection::factory()
->for($tenant)
->verifiedHealthy()
->create([
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
]);
return [
'workspace' => $workspace,
'tenant' => $tenant,
];
}
/**
* @return array{
* workspace_id: int,
* workspace_name: string,
* overall_level: string,
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
* dominant_dimension_keys: list<string>,
* non_ok_dimension_count: int,
* next_link: array{label: string, url: string}
* }
*/
function summaryForWorkspace(Workspace $workspace): array
{
/** @var array{
* workspace_id: int,
* workspace_name: string,
* overall_level: string,
* dimensions: array<string, array{label: string, level: string, windowed: bool}>,
* dominant_dimension_keys: list<string>,
* non_ok_dimension_count: int,
* next_link: array{label: string, url: string}
* } $summary
*/
$summary = app(WorkspaceHealthSummaryQuery::class)
->summaryForWorkspace($workspace, SystemConsoleWindow::LastDay);
return $summary;
}

View File

@ -7,18 +7,12 @@
it('exposes only active runtime controls in the bounded control catalog', function (): void {
$catalog = app(OperationalControlCatalog::class);
expect($catalog->keys())->toBe(['restore.execute', 'ai.execution'])
expect($catalog->keys())->toBe(['restore.execute'])
->and($catalog->definition('restore.execute'))->toMatchArray([
'key' => 'restore.execute',
'label' => 'Restore execution',
'supported_scopes' => ['global', 'workspace'],
'operation_types' => ['restore.execute'],
])
->and($catalog->definition('ai.execution'))->toMatchArray([
'key' => 'ai.execution',
'label' => 'AI execution',
'supported_scopes' => ['global'],
'operation_types' => ['ai.execution'],
]);
});

View File

@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\SupportRequests\SupportRequestContextBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds deterministic canonical context with omission markers when diagnostics are not attached', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Omission Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$builder = app(SupportRequestContextBuilder::class);
$first = $builder->forTenant($tenant, $user, false);
$second = $builder->forTenant($tenant->fresh(), $user, false);
$sections = collect($first['canonical_context']['sections'])->keyBy('key');
expect($first)
->toEqual($second)
->and($first['attachment_mode'])
->toBe(SupportRequestContextBuilder::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY)
->and($first['diagnostic_snapshot'])
->toBeNull()
->and(data_get($first, 'primary_context.type'))
->toBe('tenant')
->and(data_get($first, 'omissions.0.reason'))
->toBe('omitted_without_support_diagnostics_view')
->and(data_get($sections->get('provider_connection'), 'references.0.label'))
->toBe('Provider connection not observed')
->and(data_get($sections->get('operation_context'), 'references.0.label'))
->toBe('Operation not yet observed')
->and(data_get($sections->get('audit_history'), 'references.0.label'))
->toBe('Audit event not yet observed');
});
it('attaches a redacted diagnostic snapshot without raw payload content', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Redaction Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$connection = ProviderConnection::factory()
->withCredential()
->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Redaction Connection',
'verification_status' => ProviderVerificationStatus::Blocked->value,
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'last_error_message' => 'raw-provider-secret-message',
'last_health_check_at' => now()->subMinutes(15),
]);
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'raw_response_body' => 'secret-provider-body',
],
'failure_summary' => [[
'message' => 'Run failed after provider validation.',
]],
'completed_at' => now()->subMinutes(10),
]);
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'payload' => [
'raw_response_body' => 'stored-report-secret-body',
],
]);
AuditLog::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'operation_run_id' => (int) $run->getKey(),
'action' => 'operation.failed',
'resource_type' => 'operation_run',
'resource_id' => (string) $run->getKey(),
'target_label' => 'Operation #'.$run->getKey(),
'metadata' => [
'raw_response_body' => 'audit-secret-body',
],
'outcome' => 'success',
'recorded_at' => now()->subMinutes(5),
]);
$builder = app(SupportRequestContextBuilder::class);
$envelope = $builder->forOperationRun($run, $user, true);
$encoded = json_encode($envelope, JSON_THROW_ON_ERROR);
expect($envelope['attachment_mode'])
->toBe(SupportRequestContextBuilder::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
->and(data_get($envelope, 'primary_context.type'))
->toBe('operation_run')
->and(data_get($envelope, 'primary_context.operation_run_id'))
->toBe((int) $run->getKey())
->and(data_get($envelope, 'diagnostic_snapshot.redaction.mode'))
->toBe('default_redacted')
->and(data_get($envelope, 'diagnostic_snapshot.sections.0.key'))
->toBe('overview')
->and($encoded)
->not->toContain('raw-provider-secret-message')
->and($encoded)
->not->toContain('secret-provider-body')
->and($encoded)
->not->toContain('stored-report-secret-body')
->and($encoded)
->not->toContain('audit-secret-body');
});

View File

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
use App\Support\SupportRequests\SupportRequestReferenceGenerator;
it('generates unique uppercase support references', function (): void {
$generator = new SupportRequestReferenceGenerator();
$first = $generator->generate();
$second = $generator->generate();
expect($first)
->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
->and($second)
->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
->and($first)
->not->toBe($second);
});

View File

@ -1,273 +0,0 @@
# TenantPilot Implementation Ledger
## Purpose
Dieses Dokument beschreibt den aktuellen repo-basierten Implementierungsstand von TenantPilot. Es ergaenzt `roadmap.md` und `spec-candidates.md`, ersetzt sie aber nicht.
Bewertungsregeln fuer dieses Ledger:
- Repo-basiert only: Aussagen zaehlen nur, wenn Code, Datenmodell, Workflow, UI-Adoption oder Test-Artefakte im Repo belastbar darauf hinweisen.
- Keine Roadmap- oder Spec-Absicht ohne Repo-Evidence.
- `sellable` wird nur dort verwendet, wo UI, Workflow, Datenmodell, RBAC/Audit und passende Test-Artefakte plausibel zusammenpassen.
- Backend-only bleibt `foundation-only`.
- UI-only gilt nicht als fertig.
- Wenn Tests unten als vorhanden markiert sind, bedeutet das: passende Test-Dateien existieren im Repo. Sie wurden fuer dieses Ledger nicht ausgefuehrt.
## Current Product Position
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig.
## Status Model
- `planned`: nur in Roadmap oder Kandidatenliste, ohne belastbare Repo-Evidence
- `specified`: als Spec oder Draft angelegt, aber nicht repo-verifiziert umgesetzt
- `implemented_partial`: Teilumsetzung vorhanden, aber noch nicht als fertig bewertbar
- `implemented_backend`: belastbare Backend- oder Modelllogik vorhanden, aber keine ausreichende UI-Adoption
- `implemented_ui`: sichtbare UI vorhanden, aber Workflow- oder Backend-Proof ist noch zu schwach
- `implemented_verified`: Code, Modell, Workflow und Test-Artefakte sind plausibel vorhanden
- `adopted`: implementiert und bereits in zentrale Produktoberflaechen oder Kernablaeufe uebernommen
- `deferred`: bewusst verschoben
- `obsolete`: durch neuere Repo-Realitaet oder andere Implementierung ueberholt
Evidence-Level im Dokument:
- `none`: keine belastbare Repo-Evidence
- `weak`: duenne Code- oder Doc-Spur, aber kein belastbarer Gesamtworkflow
- `medium`: mehrere Repo-Signale, aber noch nicht durchgaengig
- `strong`: Datenmodell, Workflow, UI- oder Test-Spur greifen konsistent ineinander
## Roadmap Coverage Summary
| Roadmap Area | Status | Evidence Level | UI Ready | Tested | Sellable | Notes |
|---|---|---:|---|---|---|---|
| R1 Golden Master Governance | adopted | strong | yes | repo tests, not run | yes | Baselines, Drift, Findings und OperationRun-Truth sind breit im Produkt verankert. |
| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | almost | Review-, Evidence- und Control-Foundations sind stark; Customer Review Workspace fehlt noch. |
| Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. |
| Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. |
| UI & Product Maturity Polish | implemented_partial | medium | partial | partial repo tests, not run | no | Einzelne Polishing-Slices sind da, aber kein geschlossenes "fertig"-Signal auf Theme-Ebene. |
| Secret & Security Hardening | implemented_verified | strong | yes | repo tests, not run | almost | Provider-Verifikation, Permission-Diagnostics und Redaction sind belastbar. |
| Baseline Drift Engine (Cutover) | adopted | strong | yes | repo tests, not run | yes | Compare- und Drift-Workflow wirken als produktive Kernfunktion. |
| R1.9 Platform Localization v1 | planned | none | no | no | no | Keine belastbare Locale-Foundation im Repo gefunden. |
| Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. |
| R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. |
| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. |
| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt. |
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
| Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
| MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. |
| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow. |
| Drift & Change Governance | specified | weak | no | no | no | Einzelne Foundations existieren, die thematische Produkt-Lane aber nicht. |
| Standardization & Policy Quality | planned | none | no | no | no | Keine starke Repo-Evidence fuer eine Intune-Linting- oder Policy-Quality-Oberflaeche. |
| PSA / Ticketing Handoff | planned | none | no | no | no | Support Requests existieren, externe Handoff-Integration aber nicht. |
## Implemented Capabilities
| Capability | Status | Backend | UI | Tests | RBAC/Audit | Sellable | Evidence |
|---|---|---|---|---|---|---|---|
| OperationRun truth layer | implemented_verified | yes | partial | repo tests, not run | yes | foundation-only | `app/Models/OperationRun.php`; `tests/Feature/System/*`; `tests/Feature/ReviewPack/*` |
| Baseline profiles, snapshots and compare | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/BaselineProfile.php`; `app/Models/BaselineSnapshot.php`; `app/Services/Baselines/BaselineCompareService.php` |
| Drift findings and governance pressure | adopted | yes | yes | repo tests, not run | yes | yes | `app/Models/Finding.php`; `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`; `tests/Feature/Findings/*` |
| Restore workflow with safety gates | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/OperationRun.php`; restore gates and tests in `tests/Feature/Restore/*` |
| Evidence snapshots | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/EvidenceSnapshot.php`; `app/Services/Evidence/EvidenceSnapshotService.php`; `tests/Feature/Evidence/*` |
| Tenant reviews | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/TenantReview.php`; `app/Services/TenantReviews/TenantReviewService.php`; `tests/Feature/TenantReview/*` |
| Review pack generation and export | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/ReviewPack.php`; `app/Services/ReviewPackService.php`; `tests/Feature/ReviewPack/*` |
| Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` |
| Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` |
| Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` |
| Entra admin roles reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/EntraAdminRoles/EntraAdminRolesReportService.php`; `tests/Feature/EntraAdminRoles/*` |
| Stored reports substrate | implemented_verified | yes | partial | repo tests, not run | partial | foundation-only | `app/Models/StoredReport.php`; `tests/Feature/PermissionPosture/StoredReportModelTest.php`; `tests/Feature/EntraAdminRoles/StoredReportFingerprintTest.php` |
| Support diagnostics | adopted | yes | yes | repo tests, not run | yes | almost | `app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`; `app/Filament/Pages/TenantDashboard.php`; `tests/Feature/SupportDiagnostics/*` |
| In-app support requests | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/SupportRequest.php`; `app/Support/SupportRequests/*`; `tests/Feature/SupportRequests/*` |
| Product knowledge and contextual help | implemented_partial | yes | yes | repo tests, not run | partial | almost | `app/Support/ProductKnowledge/ContextualHelpCatalog.php`; `tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` |
| Product telemetry | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/ProductUsageEvent.php`; `app/Filament/System/Widgets/ProductTelemetryKpis.php`; `tests/Feature/System/ProductTelemetry/*` |
| Customer health scoring | implemented_verified | yes | yes | repo tests, not run | partial | almost | `app/Filament/System/Widgets/CustomerHealthKpis.php`; `app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`; `tests/Feature/System/CustomerHealth/*` |
| Operational controls | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/OperationalControlActivation.php`; `app/Support/OperationalControls/*`; `tests/Feature/System/OpsControls/*` |
| Workspace entitlements | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Services/Entitlements/WorkspaceEntitlementResolver.php`; `tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php` |
| Capability-first RBAC | adopted | yes | yes | repo tests, not run | yes | foundation-only | `app/Services/Auth/CapabilityResolver.php`; `app/Services/Auth/RoleCapabilityMap.php`; many `tests/Feature/Rbac/*` |
| Audit log foundation | adopted | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/AuditLog.php`; `app/Services/Audit/WorkspaceAuditLogger.php`; many audit-focused feature tests |
| Canonical control catalog | implemented_verified | yes | partial | repo tests, not run | partial | foundation-only | `app/Support/Governance/Controls/CanonicalControlCatalog.php`; `config/canonical_controls.php`; `tests/Unit/Governance/*` |
| Portfolio triage continuity | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Services/PortfolioTriage/TenantTriageReviewService.php`; `app/Support/PortfolioTriage/*`; `tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php` |
## Foundation-Only Capabilities
- OperationRun truth and canonical operation typing: starke Execution-Foundation, aber kein eigenstaendiger Kundennutzen-Surface.
- Audit log foundation: breit genutzt und wichtig fuer Governance, aber allein nicht verkaufbar.
- Capability-first RBAC: belastbar und testnah, bleibt aber Enablement-Layer.
- Workspace entitlements: reale Gate- und Override-Logik, aber noch keine volle Commercial Lifecycle Story.
- Canonical control catalog: starke semantische Foundation fuer Evidence, Findings und Reviews.
- Stored reports substrate: wichtig fuer Reports, Evidence und Diagnostics, aber kein eigenstaendiges Produktversprechen.
- Evidence snapshot substrate: tragende technische Basis fuer Reviews und Exports.
- Operational control registry and evaluator: starke Safety-Control-Foundation, primar operatorseitig.
- Customer health scoring: reale interne SaaS-Operations-Layer, aber noch keine eigenstaendige Kundenoberflaeche.
- Portfolio triage continuity: sinnvoller Multi-Tenant-Unterbau, aber noch kein vollstaendiges Portfolio-Produkt.
## Partial Capabilities
- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt.
- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer.
- Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht.
- MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen.
- Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch.
- Product knowledge rollout: Help-Katalog und Resolver sind real, aber noch nicht breit genug adoptiert fuer "fertig".
## Planned But Not Implemented
- Platform Localization v1
- Private AI Execution & Usage Governance Foundation
- Human-in-the-Loop Autonomous Governance
- Standardization & Policy Quality / Intune Linting
- PSA / Ticketing Handoff
- Customer Review Workspace v1
- Cross-Tenant Compare and Promotion v1
- Later compliance overlays beyond the current control/evidence foundation
## Release Readiness
| Release / Theme | Readiness | Notes |
|---|---|---|
| R1 Golden Master Governance | implemented | Die zentrale Governance- und Execution-Layer ist repo-verifiziert und breit adoptiert. |
| R2 Tenant Reviews & Evidence Packs | partially implemented | Reviews, Evidence Snapshots und Review Packs sind stark; kundensichere Consumption fehlt noch. |
| R3 MSP Portfolio OS | foundation only | Portfolio-Triage ist da, aber Compare/Promotion und Decision Workflows fehlen. |
| Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. |
## Commercial Readiness
### Demo-ready
- Baseline compare and drift walkthroughs
- Review pack generation and export
- Provider health, onboarding readiness and required permissions
- Support diagnostics
- Permission posture and Entra admin roles reporting
### Almost sellable
- Review-driven governance workflow around tenant reviews and review packs
- Baseline drift and restore governance
- Alerting and run visibility for governance operations
- Support requests with contextual diagnostics
- Provider readiness and permission posture reporting
### Foundation-only
- OperationRun truth layer
- Audit foundation
- Capability-first RBAC
- Workspace entitlements
- Canonical control catalog
- Stored reports substrate
- Evidence snapshot substrate
- Product telemetry
- Customer health scoring
- Operational controls
- Portfolio triage continuity
### Not sellable yet
- Customer Review Workspace v1
- Cross-Tenant Compare and Promotion v1
- Localization v1
- Private AI Execution Governance Foundation
- External Support Desk / PSA Handoff
- Compliance Light product layer
## Open Gaps & Blockers
| Gap | Type | Impact | Roadmap Area | Recommended Spec |
|---|---|---|---|---|
| Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 |
| No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 |
| Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 |
| Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 |
| Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity |
| Support requests do not hand off to an external desk | Commercialization blocker | Support operations still depend on manual follow-through outside the product | R2 completion / Support | P2 External Support Desk / PSA Handoff |
| AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution & Usage Governance | P3 Private AI Execution Governance Foundation |
| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs lag implementation | Product planning / roadmap maintenance | none - docs alignment |
| Test files were not executed for this ledger update | Testing blocker | This document relies on code plus test presence, not live runtime validation | all areas | none - run targeted suites |
## Recommended Next Specs
- `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface.
- `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface.
- `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action.
- `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands.
- `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle.
- `P2 External Support Desk / PSA Handoff`: extends support requests beyond internal persistence.
- `P3 Private AI Execution Governance Foundation`: should exist before feature-level AI adoption, not after it.
## Roadmap Drift Notes
- `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas.
- `roadmap.md` understates product supportability. Support diagnostics, in-app support requests and contextual help already exist in the repo.
- `roadmap.md` understates operational maturity. Product telemetry, customer health and operational controls are already implemented and wired into the system panel.
- `roadmap.md` understates commercial foundations. A workspace entitlement resolver, plan profiles and enforcement points already exist, even though full billing-state maturity does not.
- The roadmap is stronger at describing missing customer-facing consumption than missing backend foundations. Customer Review Workspace v1, Cross-Tenant Compare and Promotion, Localization and AI Governance still look genuinely unimplemented.
- The main drift pattern is underestimation, not overestimation. The only place where optimism should still be resisted is customer-facing review maturity: internal review and evidence foundations are strong, but the repo does not yet prove a finished customer review workspace.
## Evidence Sources
Wichtigste Strategie- und Scope-Quellen:
- `docs/product/roadmap.md`
- `docs/product/spec-candidates.md`
Wichtige Plattform- und UI-Anker:
- `apps/platform/bootstrap/providers.php`
- `apps/platform/app/Providers/Filament/AdminPanelProvider.php`
- `apps/platform/app/Providers/Filament/SystemPanelProvider.php`
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
- `apps/platform/app/Filament/System/Pages/Dashboard.php`
- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`
Wichtige Models:
- `apps/platform/app/Models/OperationRun.php`
- `apps/platform/app/Models/Finding.php`
- `apps/platform/app/Models/FindingException.php`
- `apps/platform/app/Models/BaselineProfile.php`
- `apps/platform/app/Models/BaselineSnapshot.php`
- `apps/platform/app/Models/EvidenceSnapshot.php`
- `apps/platform/app/Models/TenantReview.php`
- `apps/platform/app/Models/ReviewPack.php`
- `apps/platform/app/Models/StoredReport.php`
- `apps/platform/app/Models/SupportRequest.php`
- `apps/platform/app/Models/ProductUsageEvent.php`
- `apps/platform/app/Models/OperationalControlActivation.php`
- `apps/platform/app/Models/AuditLog.php`
Wichtige Services und Jobs:
- `apps/platform/app/Services/ReviewPackService.php`
- `apps/platform/app/Services/TenantReviews/TenantReviewService.php`
- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
- `apps/platform/app/Services/Baselines/BaselineCompareService.php`
- `apps/platform/app/Services/Alerts/AlertDispatchService.php`
- `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php`
- `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`
- `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
- `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`
- `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
- `apps/platform/app/Services/Auth/CapabilityResolver.php`
Wichtige Test-Anker im Repo:
- `apps/platform/tests/Feature/ReviewPack/*`
- `apps/platform/tests/Feature/Evidence/*`
- `apps/platform/tests/Feature/PermissionPosture/*`
- `apps/platform/tests/Feature/EntraAdminRoles/*`
- `apps/platform/tests/Feature/SupportDiagnostics/*`
- `apps/platform/tests/Feature/SupportRequests/*`
- `apps/platform/tests/Feature/System/CustomerHealth/*`
- `apps/platform/tests/Feature/System/ProductTelemetry/*`
- `apps/platform/tests/Feature/System/OpsControls/*`
- `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php`
- `apps/platform/tests/Unit/Governance/*`
- `apps/platform/tests/Unit/Entitlements/*`
## Last Updated
2026-04-27 on branch `248-private-ai-policy-foundation`

View File

@ -104,52 +104,6 @@ ### Data minimization & safe logging
---
## Governance & Decision Model
### Decision-first surfaces (non-negotiable)
Every operator-facing surface must default to:
- Decision
- Reason
- Impact
- One primary next action
Diagnostics and evidence must be progressively disclosed.
### Surface layering (mandatory)
All operator surfaces must follow a strict layering model:
1. Decision layer (default-visible)
2. Diagnostic layer (expandable)
3. Evidence layer (deep, raw, or audit-level)
No surface may start at diagnostic or raw data level.
### Multiple truth layers (explicit separation)
The platform separates:
- **Execution truth** (OperationRun)
- **Artifact truth** (Reports, Evidence)
- **Backup truth** (Snapshots)
- **Governance truth** (Findings, Exceptions)
These layers must never be conflated or implicitly derived from each other.
### Governance-first model
The system models governance explicitly as:
- **Expected state** (Baselines)
- **Observed state** (Inventory / Evidence)
- **Deviations** (Findings)
- **Decisions** (Exceptions / Risk acceptance)
All governance workflows must align with this model.
### Baselines as reference truth
Baselines define the expected state.
All comparisons, drift detection, and governance decisions must reference an explicit baseline.
Implicit or “last state vs current state” comparisons are forbidden.
### No false calmness (strict)
Missing, stale, or partial data must be explicitly visible.
The system must never present a "healthy" or "complete" state without sufficient evidence.
## UI & Information Architecture
### UI/UX constitution governs operator surfaces

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More