Compare commits

...

6 Commits

Author SHA1 Message Date
12fb5ebb30 feat: add findings hygiene report and control catalog layering (#264)
Some checks failed
Main Confidence / confidence (push) Failing after 1m20s
## Summary
- add the workspace-scoped findings hygiene report, overview signal, and supporting classification service for broken assignments and stale in-progress work
- add Spec 225 artifacts and focused findings hygiene test coverage alongside the new Filament page and workspace overview wiring
- align product roadmap and spec candidates around the layered canonical control catalog, CIS library, and readiness model
- extend SpecKit constitution and templates with the XCUT-001 shared-pattern reuse guidance

## Notes
- validation commands and implementation close-out notes are documented in `specs/225-assignment-hygiene/plan.md` and `specs/225-assignment-hygiene/quickstart.md`
- this PR targets `dev` from `225-assignment-hygiene`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #264
2026-04-22 12:26:18 +00:00
ccd4a17209 spec: finalize 226 astrodeck inventory planning artifacts (#263)
Some checks failed
Main Confidence / confidence (push) Failing after 1m36s
## Summary
- finalize Spec 226 artifacts for AstroDeck inventory planning
- include completed planning set: spec, plan, research, data model, quickstart, tasks, checklist, contracts, and inventory outputs
- apply consistency fixes from the project analysis review

## Included changes
- updated `.github/agents/copilot-instructions.md` from agent-context sync
- added/updated all files under `specs/226-astrodeck-inventory-planning/`

## Notes
- docs/spec workflow changes only
- no runtime code paths changed

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #263
2026-04-22 11:52:09 +00:00
71f94c3afa spec: finalize 223 AstroDeck rebuild planning consistency (#262)
Some checks failed
Main Confidence / confidence (push) Failing after 44s
## Summary
- finalize Spec 223 planning artifact set for AstroDeck website rebuild
- align `spec.md`, `plan.md`, `tasks.md`, `research.md`, `data-model.md`, `quickstart.md`, and contract schema
- add/complete inventory, mapping, exception, drift-follow-up, and supersession artifacts
- mark legacy website-spec task references as superseded and wire follow-up ownership

## Key Outcomes
- no remaining cross-artifact consistency findings in the Spec 223 bundle
- explicit Spec 213 handling path added
- material-drift follow-up rules normalized
- exception register and documented exception model made explicit and schema-backed

## Validation
- Integrated browser smoke check passed for main website routes (`/`, `/product`, `/trust`, `/changelog`, `/contact`, `/privacy`, `/imprint`, `/legal`, `/security-trust`)
- no console errors/warnings observed during route smoke navigation
- YAML contract parses successfully

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #262
2026-04-22 07:52:32 +00:00
e15d80cca5 feat: implement findings notifications escalation (#261)
Some checks failed
Main Confidence / confidence (push) Failing after 48s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
## Summary
- implement Spec 224 findings notifications and escalation v1 on top of the existing alerts and Filament database notification infrastructure
- add finding assignment, reopen, due soon, and overdue event handling with direct recipient routing, dedupe, and optional external alert fan-out
- extend alert rule and alert delivery surfaces plus add the Spec 224 planning bundle and candidate-list promotion cleanup

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php tests/Feature/Alerts/SlaDueAlertTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php`

## Filament / Platform Notes
- Livewire v4.0+ compliance is preserved
- provider registration remains unchanged in `apps/platform/bootstrap/providers.php`
- no globally searchable resource behavior changed in this feature
- no new destructive action was introduced
- asset strategy is unchanged and the existing `cd apps/platform && php artisan filament:assets` deploy step remains sufficient

## Manual Smoke Note
- integrated-browser smoke testing confirmed the new alert rule event options, notification drawer entries, alert delivery history row, and tenant finding detail route on the active Sail host
- local notification deep links currently resolve from `APP_URL`, so a local `localhost` vs `127.0.0.1:8081` host mismatch can break the browser session if the app is opened on a different host/port combination

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #261
2026-04-22 00:54:38 +00:00
712576c447 feat: add findings intake queue and stabilize follow-up regressions (#260)
Some checks failed
Main Confidence / confidence (push) Failing after 44s
## Summary
- add the new admin findings intake queue at `/admin/findings/intake` with fixed `Unassigned` and `Needs triage` views, tenant-safe filtering, claim flow, and continuity into tenant finding detail and `My Findings`
- add Spec 222 artifacts (`spec`, `plan`, `tasks`, `research`, `data model`, `quickstart`, contract, checklist) and register the new admin page
- fix follow-up regressions uncovered during full-suite validation around findings action-surface declarations, findings list default columns, provider-blocked run messaging, operation catalog aliases, and workspace overview query volume

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Authorization/FindingsIntakeAuthorizationTest.php tests/Feature/Findings/FindingsClaimHandoffTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact`

## Notes
- Filament remains on v5 with Livewire v4-compatible patterns
- provider registration remains unchanged in `apps/platform/bootstrap/providers.php`
- no new assets or schema changes are introduced

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #260
2026-04-21 22:54:08 +00:00
cebd5ee1b0 Agent: commit workspace changes (217-homepage-hero-session-1776809852) (#259)
Some checks failed
Main Confidence / confidence (push) Failing after 50s
Automated commit by agent: commits workspace changes for feature 217-homepage-hero. Please review and merge into `dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #259
2026-04-21 22:24:29 +00:00
155 changed files with 16954 additions and 922 deletions

View File

@ -218,14 +218,26 @@ ## Active Technologies
- Static filesystem pages, content modules, and Astro content collections under `apps/website/src` and `apps/website/public`; no database (215-website-core-pages)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks` (216-provider-dispatch-gate)
- PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned (216-provider-dispatch-gate)
- Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (216-homepage-structure)
- Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (216-homepage-structure)
- Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (217-homepage-structure)
- Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (217-homepage-structure)
- Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, existing Astro content modules and section primitives, Playwright browser smoke tests (218-homepage-hero)
- Static filesystem content and assets under `apps/website/src` and `apps/website/public`; no database (218-homepage-hero)
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 (219-finding-ownership-semantics)
- PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned (219-finding-ownership-semantics)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders (220-governance-run-summaries)
- PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned (220-governance-run-summaries)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext` (221-findings-operator-inbox)
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned (221-findings-operator-inbox)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement` (222-findings-intake-team-queue)
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, `audit_logs`, and workspace session context; no schema changes planned (222-findings-intake-team-queue)
- TypeScript 5.9, Astro 6, Node.js 20+ + Astro, astro-icon, Tailwind CSS v4, Playwright 1.59 (223-astrodeck-website-rebuild)
- File-based route files, Astro content collections under `src/content`, public assets, and planning documents under `specs/223-astrodeck-website-rebuild`; no database (223-astrodeck-website-rebuild)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource` (224-findings-notifications-escalation)
- PostgreSQL via existing `findings`, `alert_rules`, `alert_deliveries`, `notifications`, `tenant_memberships`, and `audit_logs`; no schema changes planned (224-findings-notifications-escalation)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives (225-assignment-hygiene)
- PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned (225-assignment-hygiene)
- Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) (226-astrodeck-inventory-planning)
- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -260,9 +272,10 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 221-findings-operator-inbox: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext`
- 220-governance-run-summaries: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders
- 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
- 226-astrodeck-inventory-planning: Added Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`)
- 225-assignment-hygiene: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives
- 224-findings-notifications-escalation: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource`
- 222-findings-intake-team-queue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement`
<!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check

295
.github/skills/browsertest/SKILL.md vendored Normal file
View File

@ -0,0 +1,295 @@
---
name: browsertest
description: Führe einen vollständigen Smoke-Browser-Test im Integrated Browser für das aktuelle Feature aus, inklusive Happy Path, zentraler Regressionen, Kontext-Prüfung und belastbarer Ergebniszusammenfassung.
license: MIT
metadata:
author: GitHub Copilot
---
# Browser Smoke Test
## What This Skill Does
Use this skill to validate the current feature end-to-end in the integrated browser.
This is a focused smoke test, not a full exploratory test session. The goal is to prove that the primary operator flow:
- loads in the correct auth, workspace, and tenant context
- exposes the expected controls and decision points
- completes the main happy path without blocking issues
- lands in the expected end state or canonical drilldown
- does not show obvious regressions such as broken navigation, missing data, or conflicting actions
The skill should produce a concrete pass or fail result with actionable evidence.
## When To Apply
Activate this skill when:
- the user asks to smoke test the current feature in the browser
- a new Filament page, dashboard signal, report, wizard, or detail flow was just added
- a UI regression fix needs confirmation in a real browser context
- the primary question is whether the feature works from an operator perspective
- you need a quick integration-level check without writing a full browser test suite first
## What Success Looks Like
A successful smoke test confirms all of the following:
- the target route opens successfully
- the visible context is correct
- the main flow is usable
- the expected result appears after interaction
- the route or drilldown destination is correct
- the surface does not obviously violate its intended interaction model
If the test cannot be completed, the output must clearly state whether the blocker is:
- authentication
- missing data or fixture state
- routing
- UI interaction failure
- server error
- an unclear expected behavior contract
Do not guess. If the route or state is blocked, report the blocker explicitly.
## Preconditions
Before running the browser smoke test, make sure you know:
- the canonical route or entry point for the feature
- the primary operator action or happy path
- the expected success state
- whether the feature depends on a specific tenant, workspace, or seeded record
When available, use the feature spec, quickstart, tasks, or current browser page as the source of truth.
## Standard Workflow
### 1. Define the smoke-test scope
Identify:
- the route to open
- the primary action to perform
- the expected end state
- one or two critical regressions that must not break
The smoke test should stay narrow. Prefer one complete happy path plus one critical boundary over broad exploratory clicking.
### 2. Establish the browser state
- Reuse the current browser page if it already matches the target feature.
- Otherwise open the canonical route.
- Confirm the current auth and scope context before interacting.
For this repo, that usually means checking whether the page is on:
- `/admin/...` for workspace-context surfaces
- `/admin/t/{tenant}/...` for tenant-context surfaces
### 3. Inspect before acting
- Use `read_page` before interacting so you understand the live controls, refs, headings, and route context.
- Prefer `read_page` over screenshots for actual interaction planning.
- Use screenshots only for visual evidence or when the user asks for them.
### 4. Execute the primary happy path
Run the smallest meaningful flow that proves the feature works.
Typical steps include:
- open the page
- verify heading or key summary text
- click the primary CTA or row
- fill the minimum required form fields
- confirm modal or dialog text when relevant
- submit or navigate
- verify the expected destination or changed state
After each meaningful action, re-read the page so the next step is based on current DOM state.
### 5. Validate the outcome
Check the exact result that matters for the feature.
Examples:
- a new row appears
- a status changes
- a success message appears
- a report filter changes the result set
- a row click lands on the canonical detail page
- a dashboard signal links to the correct report page
### 6. Check for obvious regressions
Even in a smoke test, verify a few core non-negotiables:
- the page is not blank or half-rendered
- the main action is present and usable
- the visible context is correct
- the drilldown destination is canonical
- no obviously duplicated primary actions exist
- no stuck modal, spinner, or blocked interaction remains onscreen
### 7. Capture evidence and summarize clearly
Your result should state:
- route tested
- context used
- steps executed
- pass or fail
- exact blocker or discrepancy if failed
Include a screenshot only when it adds value.
## Tool Usage Guidance
Use the browser tools in this order by default:
1. `read_page`
2. `click_element`
3. `type_in_page`
4. `handle_dialog` when needed
5. `navigate_page` or `open_browser_page` only when route changes are required
6. `run_playwright_code` only if the normal browser tools are insufficient
7. `screenshot_page` for evidence, not for primary navigation logic
## Repo-Specific Guidance For TenantPilot
### Workspace surfaces
For `/admin` pages and similar workspace-context surfaces:
- verify the page is reachable without forcing tenant-route assumptions
- confirm any summary signal or CTA lands on the canonical destination
- verify calm-state versus attention-state behavior when the feature defines both
### Tenant surfaces
For `/admin/t/{tenant}/...` pages:
- verify the tenant context is explicit and correct
- verify drilldowns stay in the intended tenant scope
- treat cross-tenant leakage or silent scope changes as failures
### Filament list or report surfaces
For Filament tables, reports, or registry-style pages:
- verify the heading and table shell render
- verify fixed filters or summary controls exist when the spec requires them
- verify row click or the primary inspect affordance behaves as designed
- verify empty-state messaging is specific rather than generic when the feature defines custom behavior
### Filament detail pages
For detail or view surfaces:
- verify the canonical record loads
- verify expected sections or summary content are present
- verify critical actions or drillbacks are usable
## Result Format
Use a compact result format like this:
```text
Browser smoke result: PASS
Route: /admin/findings/hygiene
Context: workspace member with visible hygiene issues
Steps: opened report -> verified filters -> clicked finding row -> landed on canonical finding detail
Verified: report rendered, primary interaction worked, drilldown route was correct
```
If the test fails:
```text
Browser smoke result: FAIL
Route: /admin/findings/hygiene
Context: authenticated workspace member
Failed step: clicking the summary CTA
Expected: navigate to /admin/findings/hygiene
Actual: remained on /admin with no route change
Blocker: CTA appears rendered but is not interactive
```
## Examples
### Example 1: Smoke test a new report page
Use this when the feature adds a new read-only report.
Steps:
- open the canonical report route
- verify the page heading and main controls
- confirm the table or defined empty state is visible
- click one row or primary inspect affordance
- verify navigation lands on the canonical detail route
Pass criteria:
- report loads
- intended controls exist
- primary inspect path works
### Example 2: Smoke test a dashboard signal
Use this when the feature adds a summary signal on `/admin`.
Steps:
- open `/admin`
- find the signal
- verify the visible count or summary text
- click the CTA
- confirm navigation lands on the canonical downstream surface
Pass criteria:
- signal is visible in the correct state
- CTA text is present
- CTA opens the correct route
### Example 3: Smoke test a tenant detail follow-up
Use this when a workspace-level surface should drill into a tenant-level detail page.
Steps:
- open the workspace-level surface
- trigger the drilldown
- verify the target route includes the correct tenant and record
- confirm the target page actually loads the expected detail content
Pass criteria:
- drilldown route is canonical
- tenant context is correct
- destination content matches the selected record
## Common Pitfalls
- Clicking before reading the page state and refs
- Treating a blocked auth session as a feature failure
- Confusing workspace-context routes with tenant-context routes
- Reporting visual impressions without validating the actual interaction result
- Forgetting to re-read the page after a modal opens or a route changes
- Claiming success without verifying the final destination or changed state
## Non-Goals
This skill does not replace:
- full exploratory QA
- formal Pest browser coverage
- accessibility review
- visual regression approval
- backend correctness tests
It is a fast, real-browser confidence pass for the current feature.

View File

@ -1,17 +1,30 @@
<!--
Sync Impact Report
- Version change: 2.6.0 -> 2.7.0
- Version change: 2.7.0 -> 2.8.0
- Modified principles: None
- Added sections:
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases,
migration shims, dual-write logic, and compatibility fixtures in a
pre-production codebase; includes AI-agent verification checklist,
review rule, and explicit exit condition at first production deploy
- Shared Pattern First For Cross-Cutting Interaction Classes
(XCUT-001): requires shared contracts/presenters/builders for
notifications, status messaging, action links, dashboard signals,
navigation, and similar interaction classes before any local
domain-specific variant is allowed
- Removed sections: None
- Templates requiring updates:
- .specify/templates/spec-template.md: added "Compatibility posture"
default block ✅
- .specify/templates/spec-template.md: add cross-cutting shared-pattern
reuse block ✅
- .specify/templates/plan-template.md: add shared pattern and system
fit section ✅
- .specify/templates/tasks-template.md: add cross-cutting reuse task
requirements ✅
- .specify/templates/checklist-template.md: add shared-pattern reuse
review checks ✅
- .github/agents/copilot-instructions.md: added "Pre-production
compatibility check" agent checklist ✅
- Commands checked:
@ -70,6 +83,14 @@ ### UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
- Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures.
- Presentation helpers SHOULD remain optional adapters, not mandatory architecture.
### Shared Pattern First For Cross-Cutting Interaction Classes (XCUT-001)
- Cross-cutting interaction classes such as notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, and similar operator-facing infrastructure MUST first attach to an existing shared contract, presenter, builder, renderer, or other shared path when one already exists.
- New local or domain-specific implementations for an existing interaction class are allowed only when the current shared path is demonstrably insufficient for current-release truth.
- The active spec MUST name the shared path being reused or explicitly record the deviation, why the existing path is insufficient, what consistency must still be preserved, and what ownership or spread-control cost the deviation creates.
- The same interaction class MUST NOT develop parallel operator-facing UX languages for title/body/action structure, status semantics, action-label patterns, or deep-link behavior unless the deviation is explicit and justified.
- Reviews MUST treat undocumented bypass of an existing shared path as drift and block merge until the feature converges on the shared path or records a bounded exception.
- If the drift is discovered only after a feature is already implemented, the remedy is NOT to rewrite historical closed specs retroactively by default; instead the active work MUST record the issue as `document-in-feature` or escalate it as `follow-up-spec`, depending on whether the drift is contained or structural.
### V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
- For V1 and early product maturity, direct implementation, local mapping, explicit per-domain logic, small focused helpers, derived read models, and minimal UI adapters are preferred.
- Generic platform engines, meta-frameworks, universal resolver systems, workflow frameworks, and broad semantic taxonomies are disfavored until real variance proves them necessary.

View File

@ -26,18 +26,24 @@ ## Native, Shared-Family, And State Ownership
- [ ] CHK005 Shell, page, detail, and URL/query state owners are named once and do not collapse into one another.
- [ ] CHK006 The likely next operator action and the primary inspect/open model stay coherent with the declared surface class.
## Shared Pattern Reuse
- [ ] CHK007 Any cross-cutting interaction class is explicitly marked, and the existing shared contract/presenter/builder/renderer path is named once.
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
## Signals, Exceptions, And Test Depth
- [ ] CHK007 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
- [ ] CHK008 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
- [ ] CHK009 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
- [ ] CHK010 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.
- [ ] CHK010 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
- [ ] CHK011 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
- [ ] CHK012 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
- [ ] CHK013 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.
## Review Outcome
- [ ] CHK011 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
- [ ] CHK012 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
- [ ] CHK013 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
- [ ] CHK014 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
- [ ] CHK015 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
- [ ] CHK016 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
## Notes
@ -48,7 +54,7 @@ ## Notes
- `keep`: the current scope, guardrail handling, and proof depth are justified.
- `split`: the intent is valid, but the scope should narrow before merge.
- `document-in-feature`: the change is acceptable, but the active feature must record the exception, signal handling, or proof notes explicitly.
- `follow-up-spec`: the issue is recurring or structural and needs dedicated governance follow-up.
- `follow-up-spec`: the issue is recurring or structural and needs dedicated governance follow-up. For already-implemented historical drift, prefer a follow-up spec or active feature note instead of retroactively rewriting closed specs.
- `reject-or-split`: hidden drift, unresolved exception spread, or wrong proof depth blocks merge as proposed.
- Check items off as completed: `[x]`
- Add comments or findings inline

View File

@ -43,6 +43,17 @@ ## UI / Surface Guardrail Plan
- **Exception path and spread control**: [none / describe the named exception boundary]
- **Active feature PR close-out entry**: [Guardrail / Exception / Smoke Coverage / N/A]
## Shared Pattern & System Fit
> **Fill when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, or any other shared interaction family. Docs-only or template-only work may use concise `N/A`. Carry the same decision forward from the spec instead of renaming it here.**
- **Cross-cutting feature marker**: [yes / no / N/A]
- **Systems touched**: [List the existing shared systems or `N/A`]
- **Shared abstractions reused**: [Named contracts / presenters / builders / renderers / helpers or `N/A`]
- **New abstraction introduced? why?**: [none / short explanation]
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
@ -70,6 +81,7 @@ ## Constitution Check
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
- Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
- 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

View File

@ -35,6 +35,18 @@ ## Spec Scope Fields *(mandatory)*
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: [yes/no]
- **Interaction class(es)**: [notifications / status messaging / header actions / dashboard signals / navigation / reports / etc.]
- **Systems touched**: [List shared systems, surfaces, or infrastructure paths]
- **Existing pattern(s) to extend**: [Name the existing shared path(s) or write `none`]
- **Shared contract / presenter / builder / renderer to reuse**: [Exact class, helper, or surface path, or `none`]
- **Why the existing shared path is sufficient or insufficient**: [Short explanation tied to current-release truth]
- **Allowed deviation and why**: [none / bounded exception + why]
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
- **Review focus**: [What reviewers must verify to prevent parallel local patterns]
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
Use this section to classify UI and surface risk once. If the feature does
@ -214,6 +226,14 @@ ## Requirements *(mandatory)*
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
or taxonomy/classification system, the Proportionality Review section above is mandatory.
**Constitution alignment (XCUT-001):** If this feature touches a cross-cutting interaction class such as notifications, status messaging,
action links, header actions, dashboard signals/cards, alerts, navigation entry points, or evidence/report viewers, the spec MUST:
- state whether the feature is cross-cutting,
- name the existing shared pattern(s) and shared contract/presenter/builder/renderer to extend,
- explain why the existing shared path is sufficient or why it is insufficient for current-release truth,
- 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 (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
- the affected validation lane(s) and why they are the narrowest sufficient proof,

View File

@ -46,6 +46,11 @@ # Tasks: [FEATURE NAME]
- using source/domain terms only where same-screen disambiguation is required,
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
- removing implementation-first wording from primary operator-facing copy.
**Cross-Cutting Shared Pattern Reuse (XCUT-001)**: If this feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, or another shared interaction family, tasks MUST include:
- identifying the existing shared contract/presenter/builder/renderer before local implementation begins,
- extending the shared path when it is sufficient for current-release truth,
- or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled,
- and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception.
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),

View File

@ -0,0 +1,659 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Findings;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Findings\FindingAssignmentHygieneService;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class FindingsHygieneReport extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $title = 'Findings hygiene report';
protected static ?string $slug = 'findings/hygiene';
protected string $view = 'filament.pages.findings.findings-hygiene-report';
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleTenants = null;
private ?Workspace $workspace = null;
public string $reasonFilter = FindingAssignmentHygieneService::FILTER_ALL;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header controls keep the hygiene scope fixed and expose only fixed reason views plus tenant-prefilter recovery when needed.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The hygiene report stays read-only and exposes row click as the only inspect path.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The hygiene report does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm and only offers a tenant-prefilter reset when the active tenant filter hides otherwise visible issues.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Repair remains on the existing tenant finding detail surface.');
}
public function mount(): void
{
$this->reasonFilter = $this->resolveRequestedReasonFilter();
$this->authorizePageAccess();
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
[],
request(),
);
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
$this->normalizeTenantFilterState();
}
protected function getHeaderActions(): array
{
return [
Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()),
];
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->issueBaseQuery())
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->columns([
TextColumn::make('tenant.name')
->label('Tenant'),
TextColumn::make('subject_display_name')
->label('Finding')
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
->wrap(),
TextColumn::make('owner')
->label('Owner')
->state(fn (Finding $record): string => FindingResource::accountableOwnerDisplayFor($record)),
TextColumn::make('assignee')
->label('Assignee')
->state(fn (Finding $record): string => FindingResource::activeAssigneeDisplayFor($record))
->description(fn (Finding $record): ?string => $this->assigneeContext($record)),
TextColumn::make('due_at')
->label('Due')
->dateTime()
->placeholder('—')
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)),
TextColumn::make('hygiene_reasons')
->label('Hygiene reason')
->state(fn (Finding $record): string => implode(', ', $this->hygieneService()->reasonLabelsFor($record)))
->wrap(),
TextColumn::make('last_workflow_activity')
->label('Last workflow activity')
->state(fn (Finding $record): mixed => $this->hygieneService()->lastWorkflowActivityAt($record))
->dateTime()
->placeholder('—')
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($this->hygieneService()->lastWorkflowActivityAt($record))),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
])
->actions([])
->bulkActions([])
->recordUrl(fn (Finding $record): string => $this->findingDetailUrl($record))
->emptyStateHeading(fn (): string => $this->emptyState()['title'])
->emptyStateDescription(fn (): string => $this->emptyState()['body'])
->emptyStateIcon(fn (): string => $this->emptyState()['icon'])
->emptyStateActions($this->emptyStateActions());
}
/**
* @return array<string, mixed>
*/
public function appliedScope(): array
{
$tenant = $this->filteredTenant();
return [
'workspace_scoped' => true,
'fixed_scope' => 'visible_findings_hygiene_only',
'reason_filter' => $this->currentReasonFilter(),
'reason_filter_label' => $this->hygieneService()->filterLabel($this->currentReasonFilter()),
'tenant_prefilter_source' => $this->tenantPrefilterSource(),
'tenant_label' => $tenant?->name,
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function availableFilters(): array
{
return [
[
'key' => 'hygiene_scope',
'label' => 'Findings hygiene only',
'fixed' => true,
'options' => [],
],
[
'key' => 'tenant',
'label' => 'Tenant',
'fixed' => false,
'options' => collect($this->visibleTenants())
->map(fn (Tenant $tenant): array => [
'value' => (string) $tenant->getKey(),
'label' => (string) $tenant->name,
])
->values()
->all(),
],
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function availableReasonFilters(): array
{
$summary = $this->summaryCounts();
$currentFilter = $this->currentReasonFilter();
return [
[
'key' => FindingAssignmentHygieneService::FILTER_ALL,
'label' => 'All issues',
'active' => $currentFilter === FindingAssignmentHygieneService::FILTER_ALL,
'badge_count' => $summary['unique_issue_count'],
'url' => $this->reportUrl(['reason' => null]),
],
[
'key' => FindingAssignmentHygieneService::REASON_BROKEN_ASSIGNMENT,
'label' => 'Broken assignment',
'active' => $currentFilter === FindingAssignmentHygieneService::REASON_BROKEN_ASSIGNMENT,
'badge_count' => $summary['broken_assignment_count'],
'url' => $this->reportUrl(['reason' => FindingAssignmentHygieneService::REASON_BROKEN_ASSIGNMENT]),
],
[
'key' => FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS,
'label' => 'Stale in progress',
'active' => $currentFilter === FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS,
'badge_count' => $summary['stale_in_progress_count'],
'url' => $this->reportUrl(['reason' => FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS]),
],
];
}
/**
* @return array{unique_issue_count: int, broken_assignment_count: int, stale_in_progress_count: int}
*/
public function summaryCounts(): array
{
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
return [
'unique_issue_count' => 0,
'broken_assignment_count' => 0,
'stale_in_progress_count' => 0,
];
}
return $this->hygieneService()->summary(
$workspace,
$user,
$this->currentTenantFilterId(),
);
}
/**
* @return array<string, mixed>
*/
public function emptyState(): array
{
if ($this->tenantFilterAloneExcludesRows()) {
return [
'title' => 'No hygiene issues match this tenant scope',
'body' => 'Your current tenant filter is hiding hygiene issues that are still visible elsewhere in this workspace.',
'icon' => 'heroicon-o-funnel',
'action_name' => 'clear_tenant_filter_empty',
'action_label' => 'Clear tenant filter',
'action_kind' => 'clear_tenant_filter',
];
}
if ($this->reasonFilterAloneExcludesRows()) {
return [
'title' => 'No findings match this hygiene reason',
'body' => 'The current fixed reason view is narrower than the visible issue set in this workspace.',
'icon' => 'heroicon-o-adjustments-horizontal',
];
}
return [
'title' => 'No visible hygiene issues right now',
'body' => 'Visible broken assignments and stale in-progress work are currently calm across the entitled tenant scope.',
'icon' => 'heroicon-o-wrench-screwdriver',
];
}
public function updatedTableFilters(): void
{
$this->normalizeTenantFilterState();
}
public function clearTenantFilter(): void
{
$this->removeTableFilter('tenant_id');
$this->resetTable();
}
/**
* @return array<int, Tenant>
*/
public function visibleTenants(): array
{
if ($this->visibleTenants !== null) {
return $this->visibleTenants;
}
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
return $this->visibleTenants = [];
}
return $this->visibleTenants = $this->hygieneService()->visibleTenants($workspace, $user);
}
private function authorizePageAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
if (! app(WorkspaceCapabilityResolver::class)->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
}
private function workspace(): ?Workspace
{
if ($this->workspace instanceof Workspace) {
return $this->workspace;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
}
/**
* @return Builder<Finding>
*/
private function issueBaseQuery(): Builder
{
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
return Finding::query()->whereRaw('1 = 0');
}
return $this->hygieneService()->issueQuery(
$workspace,
$user,
tenantId: null,
reasonFilter: $this->currentReasonFilter(),
applyOrdering: true,
);
}
/**
* @return Builder<Finding>
*/
private function filteredIssueQuery(bool $includeTenantFilter = true, ?string $reasonFilter = null): Builder
{
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
return Finding::query()->whereRaw('1 = 0');
}
return $this->hygieneService()->issueQuery(
$workspace,
$user,
tenantId: $includeTenantFilter ? $this->currentTenantFilterId() : null,
reasonFilter: $reasonFilter ?? $this->currentReasonFilter(),
applyOrdering: true,
);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->visibleTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => (string) $tenant->name,
])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant');
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->visibleTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
}
private function normalizeTenantFilterState(): void
{
$configuredTenantFilter = data_get($this->currentFiltersState(), 'tenant_id.value');
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
return;
}
if ($this->currentTenantFilterId() !== null) {
return;
}
$this->removeTableFilter('tenant_id');
}
/**
* @return array<string, mixed>
*/
private function currentFiltersState(): array
{
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
return array_replace_recursive(
is_array($persisted) ? $persisted : [],
$this->tableFilters ?? [],
);
}
private function currentTenantFilterId(): ?int
{
$tenantFilter = data_get($this->currentFiltersState(), 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
return null;
}
$tenantId = (int) $tenantFilter;
foreach ($this->visibleTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenantId;
}
}
return null;
}
private function filteredTenant(): ?Tenant
{
$tenantId = $this->currentTenantFilterId();
if (! is_int($tenantId)) {
return null;
}
foreach ($this->visibleTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
private function activeVisibleTenant(): ?Tenant
{
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (! $activeTenant instanceof Tenant) {
return null;
}
foreach ($this->visibleTenants() as $tenant) {
if ($tenant->is($activeTenant)) {
return $tenant;
}
}
return null;
}
private function tenantPrefilterSource(): string
{
$tenant = $this->filteredTenant();
if (! $tenant instanceof Tenant) {
return 'none';
}
$activeTenant = $this->activeVisibleTenant();
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
return 'active_tenant_context';
}
return 'explicit_filter';
}
private function assigneeContext(Finding $record): ?string
{
if (! $this->hygieneService()->recordHasBrokenAssignment($record)) {
return null;
}
if ($record->assigneeUser?->trashed()) {
return 'Soft-deleted user';
}
return 'No current tenant membership';
}
private function tenantFilterAloneExcludesRows(): bool
{
if ($this->currentTenantFilterId() === null) {
return false;
}
if ((clone $this->filteredIssueQuery())->exists()) {
return false;
}
return (clone $this->filteredIssueQuery(includeTenantFilter: false))->exists();
}
private function reasonFilterAloneExcludesRows(): bool
{
if ($this->currentReasonFilter() === FindingAssignmentHygieneService::FILTER_ALL) {
return false;
}
if ((clone $this->filteredIssueQuery())->exists()) {
return false;
}
return (clone $this->filteredIssueQuery(includeTenantFilter: true, reasonFilter: FindingAssignmentHygieneService::FILTER_ALL))->exists();
}
private function findingDetailUrl(Finding $record): string
{
$tenant = $record->tenant;
if (! $tenant instanceof Tenant) {
return '#';
}
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
return $this->appendQuery($url, $this->navigationContext()->toQuery());
}
private function navigationContext(): CanonicalNavigationContext
{
return new CanonicalNavigationContext(
sourceSurface: 'findings.hygiene',
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->currentTenantFilterId(),
backLinkLabel: 'Back to findings hygiene',
backLinkUrl: $this->reportUrl(),
);
}
private function reportUrl(array $overrides = []): string
{
$resolvedTenant = array_key_exists('tenant', $overrides)
? $overrides['tenant']
: $this->filteredTenant()?->external_id;
$resolvedReason = array_key_exists('reason', $overrides)
? $overrides['reason']
: $this->currentReasonFilter();
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
'reason' => is_string($resolvedReason) && $resolvedReason !== FindingAssignmentHygieneService::FILTER_ALL
? $resolvedReason
: null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
private function resolveRequestedReasonFilter(): string
{
$requestedFilter = request()->query('reason');
$availableFilters = $this->hygieneService()->filterOptions();
return is_string($requestedFilter) && array_key_exists($requestedFilter, $availableFilters)
? $requestedFilter
: FindingAssignmentHygieneService::FILTER_ALL;
}
private function currentReasonFilter(): string
{
$availableFilters = $this->hygieneService()->filterOptions();
return array_key_exists($this->reasonFilter, $availableFilters)
? $this->reasonFilter
: FindingAssignmentHygieneService::FILTER_ALL;
}
/**
* @return array<int, Action>
*/
private function emptyStateActions(): array
{
$emptyState = $this->emptyState();
if (($emptyState['action_kind'] ?? null) !== 'clear_tenant_filter') {
return [];
}
return [
Action::make((string) $emptyState['action_name'])
->label((string) $emptyState['action_label'])
->icon('heroicon-o-arrow-right')
->color('gray')
->action(fn (): mixed => $this->clearTenantFilter()),
];
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
private function hygieneService(): FindingAssignmentHygieneService
{
return app(FindingAssignmentHygieneService::class);
}
}

View File

@ -0,0 +1,775 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Findings;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Findings\FindingWorkflowService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class FindingsIntakeQueue extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $title = 'Findings intake';
protected static ?string $slug = 'findings/intake';
protected string $view = 'filament.pages.findings.findings-intake-queue';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleTenants = null;
private ?Workspace $workspace = null;
public string $queueView = 'unassigned';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->withListRowPrimaryActionLimit(1)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the shared-unassigned scope fixed and expose only a tenant-prefilter clear action when needed.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The intake queue keeps Claim finding inline and does not render a secondary More menu on rows.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The intake queue does not expose bulk actions in v1.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state stays calm and offers exactly one recovery CTA per branch.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page while Claim finding remains the only inline safe shortcut.');
}
public function mount(): void
{
$this->queueView = $this->resolveRequestedQueueView();
$this->authorizePageAccess();
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
[],
request(),
);
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
$this->normalizeTenantFilterState();
}
protected function getHeaderActions(): array
{
return [
Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()),
];
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->queueViewQuery())
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->columns([
TextColumn::make('tenant.name')
->label('Tenant'),
TextColumn::make('subject_display_name')
->label('Finding')
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
->description(fn (Finding $record): ?string => $this->ownerContext($record))
->wrap(),
TextColumn::make('severity')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
->description(fn (Finding $record): ?string => $this->reopenedCue($record)),
TextColumn::make('due_at')
->label('Due')
->dateTime()
->placeholder('—')
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)),
TextColumn::make('intake_reason')
->label('Queue reason')
->badge()
->state(fn (Finding $record): string => $this->queueReason($record))
->color(fn (Finding $record): string => $this->queueReasonColor($record)),
])
->filters([
SelectFilter::make('tenant_id')
->label('Tenant')
->options(fn (): array => $this->tenantFilterOptions())
->searchable(),
])
->actions([
$this->claimAction(),
])
->bulkActions([])
->recordUrl(fn (Finding $record): string => $this->findingDetailUrl($record))
->emptyStateHeading(fn (): string => $this->emptyState()['title'])
->emptyStateDescription(fn (): string => $this->emptyState()['body'])
->emptyStateIcon(fn (): string => $this->emptyState()['icon'])
->emptyStateActions($this->emptyStateActions());
}
/**
* @return array<string, mixed>
*/
public function appliedScope(): array
{
$tenant = $this->filteredTenant();
$queueView = $this->currentQueueView();
return [
'workspace_scoped' => true,
'fixed_scope' => 'visible_unassigned_open_findings_only',
'queue_view' => $queueView,
'queue_view_label' => $this->queueViewLabel($queueView),
'tenant_prefilter_source' => $this->tenantPrefilterSource(),
'tenant_label' => $tenant?->name,
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function queueViews(): array
{
$queueView = $this->currentQueueView();
return [
[
'key' => 'unassigned',
'label' => 'Unassigned',
'fixed' => true,
'active' => $queueView === 'unassigned',
'badge_count' => (clone $this->filteredQueueQuery(queueView: 'unassigned', applyOrdering: false))->count(),
'url' => $this->queueUrl(['view' => null]),
],
[
'key' => 'needs_triage',
'label' => 'Needs triage',
'fixed' => true,
'active' => $queueView === 'needs_triage',
'badge_count' => (clone $this->filteredQueueQuery(queueView: 'needs_triage', applyOrdering: false))->count(),
'url' => $this->queueUrl(['view' => 'needs_triage']),
],
];
}
/**
* @return array{visible_unassigned: int, visible_needs_triage: int, visible_overdue: int}
*/
public function summaryCounts(): array
{
$visibleQuery = $this->filteredQueueQuery(queueView: 'unassigned', applyOrdering: false);
return [
'visible_unassigned' => (clone $visibleQuery)->count(),
'visible_needs_triage' => (clone $this->filteredQueueQuery(queueView: 'needs_triage', applyOrdering: false))->count(),
'visible_overdue' => (clone $visibleQuery)
->whereNotNull('due_at')
->where('due_at', '<', now())
->count(),
];
}
/**
* @return array<string, mixed>
*/
public function emptyState(): array
{
if ($this->tenantFilterAloneExcludesRows()) {
return [
'title' => 'No intake findings match this tenant scope',
'body' => 'Your current tenant filter is hiding shared intake work that is still visible elsewhere in this workspace.',
'icon' => 'heroicon-o-funnel',
'action_name' => 'clear_tenant_filter_empty',
'action_label' => 'Clear tenant filter',
'action_kind' => 'clear_tenant_filter',
];
}
return [
'title' => 'Shared intake is clear',
'body' => 'No visible unassigned findings currently need first routing across your entitled tenants. Open your personal queue if you want to continue with claimed work.',
'icon' => 'heroicon-o-inbox-stack',
'action_name' => 'open_my_findings_empty',
'action_label' => 'Open my findings',
'action_kind' => 'url',
'action_url' => MyFindingsInbox::getUrl(panel: 'admin'),
];
}
public function updatedTableFilters(): void
{
$this->normalizeTenantFilterState();
}
public function clearTenantFilter(): void
{
$this->removeTableFilter('tenant_id');
$this->resetTable();
}
/**
* @return array<int, Tenant>
*/
public function visibleTenants(): array
{
if ($this->visibleTenants !== null) {
return $this->visibleTenants;
}
$user = auth()->user();
$tenants = $this->authorizedTenants();
if (! $user instanceof User || $tenants === []) {
return $this->visibleTenants = [];
}
$resolver = app(CapabilityResolver::class);
$resolver->primeMemberships(
$user,
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
);
return $this->visibleTenants = array_values(array_filter(
$tenants,
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
));
}
private function claimAction(): Action
{
return UiEnforcement::forTableAction(
Action::make('claim')
->label('Claim finding')
->icon('heroicon-o-user-plus')
->color('gray')
->visible(fn (Finding $record): bool => $record->assignee_user_id === null && in_array((string) $record->status, Finding::openStatuses(), true))
->requiresConfirmation()
->modalHeading('Claim finding')
->modalDescription(function (?Finding $record = null): string {
$findingLabel = $record?->resolvedSubjectDisplayName() ?? ($record instanceof Finding ? 'Finding #'.$record->getKey() : 'this finding');
$tenantLabel = $record?->tenant?->name ?? 'this tenant';
return sprintf(
'Claim "%s" in %s? It will move into your personal queue, while the accountable owner and lifecycle state stay unchanged.',
$findingLabel,
$tenantLabel,
);
})
->modalSubmitActionLabel('Claim finding')
->action(function (Finding $record): void {
$tenant = $record->tenant;
$user = auth()->user();
if (! $tenant instanceof Tenant) {
throw new NotFoundHttpException;
}
if (! $user instanceof User) {
abort(403);
}
try {
$claimedFinding = app(FindingWorkflowService::class)->claim($record, $tenant, $user);
Notification::make()
->success()
->title('Finding claimed')
->body('The finding left shared intake and is now assigned to you.')
->actions([
Action::make('open_my_findings')
->label('Open my findings')
->url(MyFindingsInbox::getUrl(panel: 'admin')),
Action::make('open_finding')
->label('Open finding')
->url($this->findingDetailUrl($claimedFinding)),
])
->send();
} catch (ConflictHttpException) {
Notification::make()
->warning()
->title('Finding already claimed')
->body('Another operator claimed this finding first. The intake queue has been refreshed.')
->send();
}
$this->resetTable();
if (method_exists($this, 'unmountAction')) {
$this->unmountAction();
}
}),
fn () => null,
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
}
private function authorizePageAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
if ($this->visibleTenants() === []) {
abort(403);
}
}
/**
* @return array<int, Tenant>
*/
private function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->authorizedTenants = [];
}
return $this->authorizedTenants = $user->tenants()
->where('tenants.workspace_id', (int) $workspace->getKey())
->where('tenants.status', 'active')
->orderBy('tenants.name')
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
->all();
}
private function workspace(): ?Workspace
{
if ($this->workspace instanceof Workspace) {
return $this->workspace;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
}
private function queueBaseQuery(): Builder
{
$workspace = $this->workspace();
$tenantIds = array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$this->visibleTenants(),
);
if (! $workspace instanceof Workspace) {
return Finding::query()->whereRaw('1 = 0');
}
return Finding::query()
->with(['tenant', 'ownerUser', 'assigneeUser'])
->withSubjectDisplayName()
->where('workspace_id', (int) $workspace->getKey())
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->whereNull('assignee_user_id')
->whereIn('status', Finding::openStatuses());
}
private function queueViewQuery(): Builder
{
return $this->filteredQueueQuery(includeTenantFilter: false, queueView: $this->currentQueueView(), applyOrdering: true);
}
private function filteredQueueQuery(
bool $includeTenantFilter = true,
?string $queueView = null,
bool $applyOrdering = true,
): Builder {
$query = $this->queueBaseQuery();
$resolvedQueueView = $queueView ?? $this->queueView;
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterId()) !== null) {
$query->where('tenant_id', $tenantId);
}
if ($resolvedQueueView === 'needs_triage') {
$query->whereIn('status', [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
]);
}
if (! $applyOrdering) {
return $query;
}
return $query
->orderByRaw(
"case
when due_at is not null and due_at < ? then 0
when status = ? then 1
when status = ? then 2
else 3
end asc",
[now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW],
)
->orderByRaw('case when due_at is null then 1 else 0 end asc')
->orderBy('due_at')
->orderByDesc('id');
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->visibleTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => (string) $tenant->name,
])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant');
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->visibleTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
}
private function normalizeTenantFilterState(): void
{
$configuredTenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value');
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
return;
}
if ($this->currentTenantFilterId() !== null) {
return;
}
$this->removeTableFilter('tenant_id');
}
/**
* @return array<string, mixed>
*/
private function currentQueueFiltersState(): array
{
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
return array_replace_recursive(
is_array($persisted) ? $persisted : [],
$this->tableFilters ?? [],
);
}
private function currentTenantFilterId(): ?int
{
$tenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
return null;
}
$tenantId = (int) $tenantFilter;
foreach ($this->visibleTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenantId;
}
}
return null;
}
private function filteredTenant(): ?Tenant
{
$tenantId = $this->currentTenantFilterId();
if (! is_int($tenantId)) {
return null;
}
foreach ($this->visibleTenants() as $tenant) {
if ((int) $tenant->getKey() === $tenantId) {
return $tenant;
}
}
return null;
}
private function activeVisibleTenant(): ?Tenant
{
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (! $activeTenant instanceof Tenant) {
return null;
}
foreach ($this->visibleTenants() as $tenant) {
if ($tenant->is($activeTenant)) {
return $tenant;
}
}
return null;
}
private function tenantPrefilterSource(): string
{
$tenant = $this->filteredTenant();
if (! $tenant instanceof Tenant) {
return 'none';
}
$activeTenant = $this->activeVisibleTenant();
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
return 'active_tenant_context';
}
return 'explicit_filter';
}
private function ownerContext(Finding $record): ?string
{
if ($record->owner_user_id === null) {
return null;
}
return 'Owner: '.FindingResource::accountableOwnerDisplayFor($record);
}
private function reopenedCue(Finding $record): ?string
{
if ($record->reopened_at === null) {
return null;
}
return 'Reopened';
}
private function queueReason(Finding $record): string
{
return in_array((string) $record->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
], true)
? 'Needs triage'
: 'Unassigned';
}
private function queueReasonColor(Finding $record): string
{
return $this->queueReason($record) === 'Needs triage'
? 'warning'
: 'gray';
}
private function tenantFilterAloneExcludesRows(): bool
{
if ($this->currentTenantFilterId() === null) {
return false;
}
if ((clone $this->filteredQueueQuery())->exists()) {
return false;
}
return (clone $this->filteredQueueQuery(includeTenantFilter: false))->exists();
}
private function findingDetailUrl(Finding $record): string
{
$tenant = $record->tenant;
if (! $tenant instanceof Tenant) {
return '#';
}
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
return $this->appendQuery($url, $this->navigationContext()->toQuery());
}
private function navigationContext(): CanonicalNavigationContext
{
return new CanonicalNavigationContext(
sourceSurface: 'findings.intake',
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->currentTenantFilterId(),
backLinkLabel: 'Back to findings intake',
backLinkUrl: $this->queueUrl(),
);
}
private function queueUrl(array $overrides = []): string
{
$resolvedTenant = array_key_exists('tenant', $overrides)
? $overrides['tenant']
: $this->filteredTenant()?->external_id;
$resolvedView = array_key_exists('view', $overrides)
? $overrides['view']
: $this->currentQueueView();
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
'view' => $resolvedView === 'needs_triage' ? 'needs_triage' : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
private function resolveRequestedQueueView(): string
{
$requestedView = request()->query('view');
return $requestedView === 'needs_triage'
? 'needs_triage'
: 'unassigned';
}
private function currentQueueView(): string
{
return $this->queueView === 'needs_triage'
? 'needs_triage'
: 'unassigned';
}
private function queueViewLabel(string $queueView): string
{
return $queueView === 'needs_triage'
? 'Needs triage'
: 'Unassigned';
}
/**
* @return array<int, Action>
*/
private function emptyStateActions(): array
{
$emptyState = $this->emptyState();
$action = Action::make((string) $emptyState['action_name'])
->label((string) $emptyState['action_label'])
->icon('heroicon-o-arrow-right')
->color('gray');
if (($emptyState['action_kind'] ?? null) === 'clear_tenant_filter') {
return [
$action->action(fn (): mixed => $this->clearTenantFilter()),
];
}
return [
$action->url((string) $emptyState['action_url']),
];
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
}

View File

@ -74,6 +74,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the assigned-to-me scope fixed and expose only a tenant-prefilter clear action when needed.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The personal findings inbox exposes row click as the only inspect path and does not render a secondary More menu.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The personal findings inbox does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm, explains scope boundaries, and offers exactly one recovery CTA.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page.');

View File

@ -246,10 +246,22 @@ public function blockedExecutionBanner(): ?array
return null;
}
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'detail');
$body = 'This run was blocked before the artifact-producing work could finish. Review the summary below for the dominant blocker and next step.';
if ($reasonEnvelope !== null) {
$body = trim(sprintf(
'%s %s %s',
$body,
rtrim($reasonEnvelope->operatorLabel, '.'),
$reasonEnvelope->shortExplanation,
));
}
return [
'tone' => 'amber',
'title' => 'Blocked by prerequisite',
'body' => 'This run was blocked before the artifact-producing work could finish. Review the summary below for the dominant blocker and next step.',
'body' => $body,
];
}

View File

@ -234,7 +234,8 @@ public static function table(Table $table): Table
->searchable(),
TextColumn::make('event_type')
->label('Event')
->badge(),
->badge()
->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)),
TextColumn::make('severity')
->badge()
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))

View File

@ -380,6 +380,10 @@ public static function eventTypeOptions(): array
AlertRule::EVENT_SLA_DUE => 'SLA due',
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
];
}

View File

@ -764,7 +764,8 @@ public static function table(Table $table): Table
->dateTime()
->sortable()
->placeholder('—')
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabelFor($record)),
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabelFor($record))
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('ownerUser.name')
->label('Accountable owner')
->placeholder('—'),
@ -773,7 +774,10 @@ public static function table(Table $table): Table
->placeholder('—'),
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
Tables\Columns\TextColumn::make('created_at')
->since()
->label('Created')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\Filter::make('open')

View File

@ -9,6 +9,7 @@
use App\Models\OperationRun;
use App\Models\Workspace;
use App\Services\Alerts\AlertDispatchService;
use App\Services\Findings\FindingNotificationService;
use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver;
use App\Support\OperationRunOutcome;
@ -21,6 +22,7 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Throwable;
class EvaluateAlertsJob implements ShouldQueue
@ -32,7 +34,11 @@ public function __construct(
public ?int $operationRunId = null,
) {}
public function handle(AlertDispatchService $dispatchService, OperationRunService $operationRuns): void
public function handle(
AlertDispatchService $dispatchService,
OperationRunService $operationRuns,
FindingNotificationService $findingNotificationService,
): void
{
$workspace = Workspace::query()->whereKey($this->workspaceId)->first();
@ -67,6 +73,8 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
];
$dueSoonFindings = $this->dueSoonFindings((int) $workspace->getKey());
$overdueFindings = $this->overdueFindings((int) $workspace->getKey());
$createdDeliveries = 0;
@ -74,13 +82,33 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
$createdDeliveries += $dispatchService->dispatchEvent($workspace, $event);
}
foreach ($dueSoonFindings as $finding) {
$result = $findingNotificationService->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
$createdDeliveries += $result['external_delivery_count'];
if ($result['direct_delivery_status'] === 'sent') {
$createdDeliveries++;
}
}
foreach ($overdueFindings as $finding) {
$result = $findingNotificationService->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
$createdDeliveries += $result['external_delivery_count'];
if ($result['direct_delivery_status'] === 'sent') {
$createdDeliveries++;
}
}
$processedEventCount = count($events) + $dueSoonFindings->count() + $overdueFindings->count();
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
summaryCounts: [
'total' => count($events),
'processed' => count($events),
'total' => $processedEventCount,
'processed' => $processedEventCount,
'created' => $createdDeliveries,
],
);
@ -101,6 +129,45 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
}
}
/**
* @return Collection<int, Finding>
*/
private function dueSoonFindings(int $workspaceId): Collection
{
$now = CarbonImmutable::now('UTC');
return Finding::query()
->with('tenant')
->withSubjectDisplayName()
->where('workspace_id', $workspaceId)
->openWorkflow()
->whereNotNull('due_at')
->where('due_at', '>', $now)
->where('due_at', '<=', $now->addHours(24))
->orderBy('due_at')
->orderBy('id')
->get();
}
/**
* @return Collection<int, Finding>
*/
private function overdueFindings(int $workspaceId): Collection
{
$now = CarbonImmutable::now('UTC');
return Finding::query()
->with('tenant')
->withSubjectDisplayName()
->where('workspace_id', $workspaceId)
->openWorkflow()
->whereNotNull('due_at')
->where('due_at', '<', $now)
->orderBy('due_at')
->orderBy('id')
->get();
}
private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun
{
if (is_int($this->operationRunId) && $this->operationRunId > 0) {

View File

@ -28,6 +28,14 @@ class AlertRule extends Model
public const string EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
public const string EVENT_FINDINGS_ASSIGNED = 'findings.assigned';
public const string EVENT_FINDINGS_REOPENED = 'findings.reopened';
public const string EVENT_FINDINGS_DUE_SOON = 'findings.due_soon';
public const string EVENT_FINDINGS_OVERDUE = 'findings.overdue';
public const string TENANT_SCOPE_ALL = 'all';
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';

View File

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Notifications\Findings;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use Filament\Actions\Action;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
final class FindingEventNotification extends Notification
{
use Queueable;
/**
* @param array<string, mixed> $event
*/
public function __construct(
private readonly Finding $finding,
private readonly Tenant $tenant,
private readonly array $event,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* @return array<string, mixed>
*/
public function toDatabase(object $notifiable): array
{
$message = FilamentNotification::make()
->title($this->title())
->body($this->body())
->actions([
Action::make('open_finding')
->label('Open finding')
->url(FindingResource::getUrl(
'view',
['record' => $this->finding],
panel: 'tenant',
tenant: $this->tenant,
)),
])
->getDatabaseMessage();
$message['finding_event'] = [
'event_type' => (string) ($this->event['event_type'] ?? ''),
'finding_id' => (int) $this->finding->getKey(),
'recipient_reason' => data_get($this->event, 'metadata.recipient_reason'),
'fingerprint_key' => (string) ($this->event['fingerprint_key'] ?? ''),
'due_cycle_key' => $this->event['due_cycle_key'] ?? null,
'tenant_name' => $this->tenant->getFilamentName(),
'severity' => (string) ($this->event['severity'] ?? ''),
];
return $message;
}
private function title(): string
{
$title = trim((string) ($this->event['title'] ?? 'Finding update'));
return $title !== '' ? $title : 'Finding update';
}
private function body(): string
{
$body = trim((string) ($this->event['body'] ?? 'A finding needs follow-up.'));
$recipientReason = $this->recipientReasonCopy((string) data_get($this->event, 'metadata.recipient_reason', ''));
return trim($body.' '.$recipientReason);
}
private function recipientReasonCopy(string $reason): string
{
return match ($reason) {
'new_assignee' => 'You are the new assignee.',
'current_assignee' => 'You are the current assignee.',
'current_owner' => 'You are the accountable owner.',
default => '',
};
}
}

View File

@ -5,6 +5,8 @@
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
@ -177,6 +179,8 @@ public function panel(Panel $panel): Panel
InventoryCoverage::class,
TenantRequiredPermissions::class,
WorkspaceSettings::class,
FindingsHygieneReport::class,
FindingsIntakeQueue::class,
MyFindingsInbox::class,
FindingExceptionsQueue::class,
ReviewRegister::class,

View File

@ -186,6 +186,8 @@ private function buildPayload(array $event): array
return [
'title' => $title,
'body' => $body,
'event_type' => trim((string) ($event['event_type'] ?? '')),
'fingerprint_key' => trim((string) ($event['fingerprint_key'] ?? '')),
'metadata' => $metadata,
];
}

View File

@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
namespace App\Services\Findings;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
final class FindingAssignmentHygieneService
{
public const string FILTER_ALL = 'all';
public const string REASON_BROKEN_ASSIGNMENT = 'broken_assignment';
public const string REASON_STALE_IN_PROGRESS = 'stale_in_progress';
private const string HYGIENE_BASELINE_TIMESTAMP = '1970-01-01 00:00:00';
private const int STALE_IN_PROGRESS_WINDOW_DAYS = 7;
public function __construct(
private readonly CapabilityResolver $capabilityResolver,
private readonly FindingWorkflowService $findingWorkflowService,
) {}
/**
* @return array<int, Tenant>
*/
public function visibleTenants(Workspace $workspace, User $user): array
{
$authorizedTenants = $user->tenants()
->where('tenants.workspace_id', (int) $workspace->getKey())
->where('tenants.status', 'active')
->orderBy('tenants.name')
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
->all();
if ($authorizedTenants === []) {
return [];
}
$this->capabilityResolver->primeMemberships(
$user,
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $authorizedTenants),
);
return array_values(array_filter(
$authorizedTenants,
fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
));
}
/**
* @return Builder<Finding>
*/
public function issueQuery(
Workspace $workspace,
User $user,
?int $tenantId = null,
string $reasonFilter = self::FILTER_ALL,
bool $applyOrdering = true,
): Builder {
$visibleTenants = $this->visibleTenants($workspace, $user);
$visibleTenantIds = array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$visibleTenants,
);
if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) {
$visibleTenantIds = [];
} elseif ($tenantId !== null) {
$visibleTenantIds = [$tenantId];
}
$brokenAssignmentExpression = $this->brokenAssignmentExpression();
$lastWorkflowActivityExpression = $this->lastWorkflowActivityExpression();
$staleBindings = [$this->staleThreshold()->toDateTimeString()];
$staleInProgressExpression = $this->staleInProgressExpression($lastWorkflowActivityExpression);
$query = Finding::query()
->select('findings.*')
->selectRaw(
"case when {$brokenAssignmentExpression} then 1 else 0 end as hygiene_is_broken_assignment",
)
->selectRaw("{$lastWorkflowActivityExpression} as hygiene_last_workflow_activity_at")
->selectRaw(
"case when {$staleInProgressExpression} then 1 else 0 end as hygiene_is_stale_in_progress",
$staleBindings,
)
->selectRaw(
"(case when {$brokenAssignmentExpression} then 1 else 0 end + case when {$staleInProgressExpression} then 1 else 0 end) as hygiene_issue_count",
$staleBindings,
)
->with([
'tenant',
'ownerUser' => static fn ($relation) => $relation->withTrashed(),
'assigneeUser' => static fn ($relation) => $relation->withTrashed(),
])
->withSubjectDisplayName()
->join('tenants', 'tenants.id', '=', 'findings.tenant_id')
->leftJoin('users as hygiene_assignee_lookup', 'hygiene_assignee_lookup.id', '=', 'findings.assignee_user_id')
->leftJoin('tenant_memberships as hygiene_assignee_membership', function ($join): void {
$join
->on('hygiene_assignee_membership.tenant_id', '=', 'findings.tenant_id')
->on('hygiene_assignee_membership.user_id', '=', 'findings.assignee_user_id');
})
->leftJoinSub(
$this->latestMeaningfulWorkflowAuditSubquery(),
'hygiene_workflow_audit',
function ($join): void {
$join
->on('hygiene_workflow_audit.workspace_id', '=', 'findings.workspace_id')
->on('hygiene_workflow_audit.tenant_id', '=', 'findings.tenant_id')
->whereRaw('hygiene_workflow_audit.resource_id = '.$this->castFindingIdToAuditResourceId());
},
)
->where('findings.workspace_id', (int) $workspace->getKey())
->whereIn('findings.tenant_id', $visibleTenantIds === [] ? [-1] : $visibleTenantIds)
->whereIn('findings.status', Finding::openStatusesForQuery())
->where(function (Builder $builder) use ($brokenAssignmentExpression, $staleInProgressExpression, $staleBindings): void {
$builder
->whereRaw($brokenAssignmentExpression)
->orWhereRaw($staleInProgressExpression, $staleBindings);
});
$this->applyReasonFilter($query, $reasonFilter, $brokenAssignmentExpression, $staleInProgressExpression, $staleBindings);
if (! $applyOrdering) {
return $query;
}
return $query
->orderByRaw(
"case when {$brokenAssignmentExpression} then 0 when {$staleInProgressExpression} then 1 else 2 end asc",
$staleBindings,
)
->orderByRaw("case when {$lastWorkflowActivityExpression} is null then 1 else 0 end asc")
->orderByRaw("{$lastWorkflowActivityExpression} asc")
->orderByRaw('case when findings.due_at is null then 1 else 0 end asc')
->orderBy('findings.due_at')
->orderBy('tenants.name')
->orderByDesc('findings.id');
}
/**
* @return array{unique_issue_count: int, broken_assignment_count: int, stale_in_progress_count: int}
*/
public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array
{
$allIssues = $this->issueQuery($workspace, $user, $tenantId, self::FILTER_ALL, applyOrdering: false);
$brokenAssignments = $this->issueQuery($workspace, $user, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false);
$staleInProgress = $this->issueQuery($workspace, $user, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false);
return [
'unique_issue_count' => (clone $allIssues)->count(),
'broken_assignment_count' => (clone $brokenAssignments)->count(),
'stale_in_progress_count' => (clone $staleInProgress)->count(),
];
}
/**
* @return array<string, string>
*/
public function filterOptions(): array
{
return [
self::FILTER_ALL => 'All issues',
self::REASON_BROKEN_ASSIGNMENT => 'Broken assignment',
self::REASON_STALE_IN_PROGRESS => 'Stale in progress',
];
}
public function filterLabel(string $filter): string
{
return $this->filterOptions()[$filter] ?? $this->filterOptions()[self::FILTER_ALL];
}
/**
* @return list<string>
*/
public function reasonLabelsFor(Finding $finding): array
{
$labels = [];
if ($this->recordHasBrokenAssignment($finding)) {
$labels[] = 'Broken assignment';
}
if ($this->recordHasStaleInProgress($finding)) {
$labels[] = 'Stale in progress';
}
return $labels;
}
public function lastWorkflowActivityAt(Finding $finding): ?CarbonImmutable
{
return $this->findingWorkflowService->lastMeaningfulActivityAt(
$finding,
$finding->getAttribute('hygiene_last_workflow_activity_at'),
);
}
public function recordHasBrokenAssignment(Finding $finding): bool
{
return (int) ($finding->getAttribute('hygiene_is_broken_assignment') ?? 0) === 1;
}
public function recordHasStaleInProgress(Finding $finding): bool
{
return (int) ($finding->getAttribute('hygiene_is_stale_in_progress') ?? 0) === 1;
}
private function applyReasonFilter(
Builder $query,
string $reasonFilter,
string $brokenAssignmentExpression,
string $staleInProgressExpression,
array $staleBindings,
): void {
$resolvedFilter = array_key_exists($reasonFilter, $this->filterOptions())
? $reasonFilter
: self::FILTER_ALL;
if ($resolvedFilter === self::REASON_BROKEN_ASSIGNMENT) {
$query->whereRaw($brokenAssignmentExpression);
return;
}
if ($resolvedFilter === self::REASON_STALE_IN_PROGRESS) {
$query->whereRaw($staleInProgressExpression, $staleBindings);
}
}
/**
* @return Builder<AuditLog>
*/
private function latestMeaningfulWorkflowAuditSubquery(): Builder
{
return AuditLog::query()
->selectRaw('workspace_id, tenant_id, resource_id, max(recorded_at) as latest_workflow_activity_at')
->where('resource_type', 'finding')
->whereIn('action', FindingWorkflowService::meaningfulActivityActionValues())
->groupBy('workspace_id', 'tenant_id', 'resource_id');
}
private function brokenAssignmentExpression(): string
{
return '(findings.assignee_user_id is not null and ((hygiene_assignee_lookup.id is not null and hygiene_assignee_lookup.deleted_at is not null) or hygiene_assignee_membership.id is null))';
}
private function staleInProgressExpression(string $lastWorkflowActivityExpression): string
{
return sprintf(
"(findings.status = '%s' and %s is not null and %s < ?)",
Finding::STATUS_IN_PROGRESS,
$lastWorkflowActivityExpression,
$lastWorkflowActivityExpression,
);
}
private function lastWorkflowActivityExpression(): string
{
$baseline = "'".self::HYGIENE_BASELINE_TIMESTAMP."'";
$greatestExpression = match ($this->connectionDriver()) {
'pgsql', 'mysql' => sprintf(
'greatest(coalesce(findings.in_progress_at, %1$s), coalesce(findings.reopened_at, %1$s), coalesce(hygiene_workflow_audit.latest_workflow_activity_at, %1$s))',
$baseline,
),
default => sprintf(
'max(coalesce(findings.in_progress_at, %1$s), coalesce(findings.reopened_at, %1$s), coalesce(hygiene_workflow_audit.latest_workflow_activity_at, %1$s))',
$baseline,
),
};
return sprintf('nullif(%s, %s)', $greatestExpression, $baseline);
}
private function castFindingIdToAuditResourceId(): string
{
return match ($this->connectionDriver()) {
'pgsql' => 'findings.id::text',
'mysql' => 'cast(findings.id as char)',
default => 'cast(findings.id as text)',
};
}
private function connectionDriver(): string
{
return Finding::query()->getConnection()->getDriverName();
}
private function staleThreshold(): CarbonImmutable
{
return CarbonImmutable::now()->subDays(self::STALE_IN_PROGRESS_WINDOW_DAYS);
}
}

View File

@ -0,0 +1,389 @@
<?php
declare(strict_types=1);
namespace App\Services\Findings;
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Notifications\Findings\FindingEventNotification;
use App\Services\Alerts\AlertDispatchService;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Carbon\CarbonInterface;
use InvalidArgumentException;
final class FindingNotificationService
{
public function __construct(
private readonly AlertDispatchService $alertDispatchService,
private readonly CapabilityResolver $capabilityResolver,
) {}
/**
* @param array<string, mixed> $context
* @return array{
* event_type: string,
* fingerprint_key: string,
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
* external_delivery_count: int
* }
*/
public function dispatch(Finding $finding, string $eventType, array $context = []): array
{
$finding = $this->reloadFinding($finding);
$tenant = $finding->tenant;
if (! $tenant instanceof Tenant) {
return $this->dispatchResult(
eventType: $eventType,
fingerprintKey: '',
directDeliveryStatus: 'no_recipient',
externalDeliveryCount: 0,
);
}
if ($this->shouldSuppressEvent($finding, $eventType, $context)) {
return $this->dispatchResult(
eventType: $eventType,
fingerprintKey: $this->fingerprintFor($finding, $eventType, $context),
directDeliveryStatus: 'suppressed',
externalDeliveryCount: 0,
);
}
$resolution = $this->resolveRecipient($finding, $eventType, $context);
$event = $this->buildEventEnvelope($finding, $tenant, $eventType, $resolution['reason'], $context);
$directDeliveryStatus = $this->dispatchDirectNotification($finding, $tenant, $event, $resolution['user_id']);
$externalDeliveryCount = $this->dispatchExternalCopies($finding, $event);
return $this->dispatchResult(
eventType: $eventType,
fingerprintKey: (string) $event['fingerprint_key'],
directDeliveryStatus: $directDeliveryStatus,
externalDeliveryCount: $externalDeliveryCount,
);
}
/**
* @param array<string, mixed> $context
* @return array{user_id: ?int, reason: ?string}
*/
private function resolveRecipient(Finding $finding, string $eventType, array $context): array
{
return match ($eventType) {
AlertRule::EVENT_FINDINGS_ASSIGNED => [
'user_id' => $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id),
'reason' => 'new_assignee',
],
AlertRule::EVENT_FINDINGS_REOPENED => $this->preferredRecipient(
preferredUserId: $this->normalizeId($finding->assignee_user_id),
preferredReason: 'current_assignee',
fallbackUserId: $this->normalizeId($finding->owner_user_id),
fallbackReason: 'current_owner',
),
AlertRule::EVENT_FINDINGS_DUE_SOON => $this->preferredRecipient(
preferredUserId: $this->normalizeId($finding->assignee_user_id),
preferredReason: 'current_assignee',
fallbackUserId: $this->normalizeId($finding->owner_user_id),
fallbackReason: 'current_owner',
),
AlertRule::EVENT_FINDINGS_OVERDUE => $this->preferredRecipient(
preferredUserId: $this->normalizeId($finding->owner_user_id),
preferredReason: 'current_owner',
fallbackUserId: $this->normalizeId($finding->assignee_user_id),
fallbackReason: 'current_assignee',
),
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
};
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function buildEventEnvelope(
Finding $finding,
Tenant $tenant,
string $eventType,
?string $recipientReason,
array $context,
): array {
$severity = strtolower(trim((string) $finding->severity));
$summary = $finding->resolvedSubjectDisplayName() ?? 'Finding #'.(int) $finding->getKey();
$title = $this->eventLabel($eventType);
$fingerprintKey = $this->fingerprintFor($finding, $eventType, $context);
$dueCycleKey = $this->dueCycleKey($finding, $eventType);
return [
'event_type' => $eventType,
'workspace_id' => (int) $finding->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
'severity' => $severity,
'title' => $title,
'body' => $this->eventBody($eventType, $tenant, $summary, ucfirst($severity)),
'fingerprint_key' => $fingerprintKey,
'due_cycle_key' => $dueCycleKey,
'metadata' => [
'tenant_name' => $tenant->getFilamentName(),
'summary' => $summary,
'recipient_reason' => $recipientReason,
'owner_user_id' => $this->normalizeId($finding->owner_user_id),
'assignee_user_id' => $this->normalizeId($finding->assignee_user_id),
'due_at' => $this->optionalIso8601($finding->due_at),
'reopened_at' => $this->optionalIso8601($finding->reopened_at),
'severity_label' => ucfirst($severity),
],
];
}
/**
* @param array<string, mixed> $event
*/
private function dispatchDirectNotification(Finding $finding, Tenant $tenant, array $event, ?int $userId): string
{
if (! is_int($userId) || $userId <= 0) {
return 'no_recipient';
}
$user = User::query()->find($userId);
if (! $user instanceof User) {
return 'no_recipient';
}
if (! $user->canAccessTenant($tenant)) {
return 'suppressed';
}
if (! $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)) {
return 'suppressed';
}
if ($this->alreadySentDirectNotification($user, (string) $event['fingerprint_key'])) {
return 'deduped';
}
$user->notify(new FindingEventNotification($finding, $tenant, $event));
return 'sent';
}
/**
* @param array<string, mixed> $event
*/
private function dispatchExternalCopies(Finding $finding, array $event): int
{
$workspace = Workspace::query()->whereKey((int) $finding->workspace_id)->first();
if (! $workspace instanceof Workspace) {
return 0;
}
return $this->alertDispatchService->dispatchEvent($workspace, $event);
}
private function alreadySentDirectNotification(User $user, string $fingerprintKey): bool
{
if ($fingerprintKey === '') {
return false;
}
return $user->notifications()
->where('type', FindingEventNotification::class)
->where('data->finding_event->fingerprint_key', $fingerprintKey)
->exists();
}
private function reloadFinding(Finding $finding): Finding
{
$fresh = Finding::query()
->with('tenant')
->withSubjectDisplayName()
->find($finding->getKey());
if ($fresh instanceof Finding) {
return $fresh;
}
$finding->loadMissing('tenant');
return $finding;
}
/**
* @param array<string, mixed> $context
*/
private function shouldSuppressEvent(Finding $finding, string $eventType, array $context): bool
{
return match ($eventType) {
AlertRule::EVENT_FINDINGS_ASSIGNED => ! $finding->hasOpenStatus()
|| $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) === null,
AlertRule::EVENT_FINDINGS_DUE_SOON, AlertRule::EVENT_FINDINGS_OVERDUE => ! $finding->hasOpenStatus()
|| ! $finding->due_at instanceof CarbonInterface,
default => false,
};
}
/**
* @param array<string, mixed> $context
*/
private function fingerprintFor(Finding $finding, string $eventType, array $context): string
{
$findingId = (int) $finding->getKey();
return match ($eventType) {
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
'finding:%d:%s:assignee:%d:updated:%s',
$findingId,
$eventType,
$this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) ?? 0,
$this->optionalIso8601($finding->updated_at) ?? 'none',
),
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
'finding:%d:%s:reopened:%s',
$findingId,
$eventType,
$this->optionalIso8601($finding->reopened_at) ?? 'none',
),
AlertRule::EVENT_FINDINGS_DUE_SOON,
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
'finding:%d:%s:due:%s',
$findingId,
$eventType,
$this->dueCycleKey($finding, $eventType) ?? 'none',
),
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
};
}
private function dueCycleKey(Finding $finding, string $eventType): ?string
{
if (! in_array($eventType, [
AlertRule::EVENT_FINDINGS_DUE_SOON,
AlertRule::EVENT_FINDINGS_OVERDUE,
], true)) {
return null;
}
return $this->optionalIso8601($finding->due_at);
}
private function eventLabel(string $eventType): string
{
return match ($eventType) {
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
};
}
private function eventBody(string $eventType, Tenant $tenant, string $summary, string $severityLabel): string
{
return match ($eventType) {
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
'%s in %s was assigned. %s severity.',
$summary,
$tenant->getFilamentName(),
$severityLabel,
),
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
'%s in %s reopened and needs follow-up. %s severity.',
$summary,
$tenant->getFilamentName(),
$severityLabel,
),
AlertRule::EVENT_FINDINGS_DUE_SOON => sprintf(
'%s in %s is due within 24 hours. %s severity.',
$summary,
$tenant->getFilamentName(),
$severityLabel,
),
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
'%s in %s is overdue. %s severity.',
$summary,
$tenant->getFilamentName(),
$severityLabel,
),
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
};
}
/**
* @return array{user_id: ?int, reason: ?string}
*/
private function preferredRecipient(
?int $preferredUserId,
string $preferredReason,
?int $fallbackUserId,
string $fallbackReason,
): array {
if (is_int($preferredUserId) && $preferredUserId > 0) {
return [
'user_id' => $preferredUserId,
'reason' => $preferredReason,
];
}
if (is_int($fallbackUserId) && $fallbackUserId > 0) {
return [
'user_id' => $fallbackUserId,
'reason' => $fallbackReason,
];
}
return [
'user_id' => null,
'reason' => null,
];
}
private function normalizeId(mixed $value): ?int
{
if (! is_numeric($value)) {
return null;
}
$normalized = (int) $value;
return $normalized > 0 ? $normalized : null;
}
private function optionalIso8601(mixed $value): ?string
{
if (! $value instanceof CarbonInterface) {
return null;
}
return $value->toIso8601String();
}
/**
* @return array{
* event_type: string,
* fingerprint_key: string,
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
* external_delivery_count: int
* }
*/
private function dispatchResult(
string $eventType,
string $fingerprintKey,
string $directDeliveryStatus,
int $externalDeliveryCount,
): array {
return [
'event_type' => $eventType,
'fingerprint_key' => $fingerprintKey,
'direct_delivery_status' => $directDeliveryStatus,
'external_delivery_count' => $externalDeliveryCount,
];
}
}

View File

@ -5,6 +5,7 @@
namespace App\Services\Findings;
use App\Models\Finding;
use App\Models\AlertRule;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
@ -17,6 +18,7 @@
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class FindingWorkflowService
@ -25,8 +27,21 @@ public function __construct(
private readonly FindingSlaPolicy $slaPolicy,
private readonly AuditLogger $auditLogger,
private readonly CapabilityResolver $capabilityResolver,
private readonly FindingNotificationService $findingNotificationService,
) {}
/**
* @return array<int, string>
*/
public static function meaningfulActivityActionValues(): array
{
return [
AuditActionId::FindingAssigned->value,
AuditActionId::FindingInProgress->value,
AuditActionId::FindingReopened->value,
];
}
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
{
$this->authorize($finding, $tenant, $actor, [
@ -107,6 +122,7 @@ public function assign(
throw new InvalidArgumentException('Only open findings can be assigned.');
}
$beforeAssigneeUserId = is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null;
$this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id');
$this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id');
@ -123,7 +139,7 @@ public function assign(
afterAssigneeUserId: $assigneeUserId,
);
return $this->mutateAndAudit(
$updatedFinding = $this->mutateAndAudit(
finding: $finding,
tenant: $tenant,
actor: $actor,
@ -141,6 +157,63 @@ public function assign(
$record->owner_user_id = $ownerUserId;
},
);
if ($assigneeUserId !== null && $assigneeUserId !== $beforeAssigneeUserId) {
$this->findingNotificationService->dispatch(
$updatedFinding,
AlertRule::EVENT_FINDINGS_ASSIGNED,
['assignee_user_id' => $assigneeUserId],
);
}
return $updatedFinding;
}
public function claim(Finding $finding, Tenant $tenant, User $actor): Finding
{
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_ASSIGN]);
$assigneeUserId = (int) $actor->getKey();
$ownerUserId = is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null;
$changeClassification = $this->responsibilityChangeClassification(
beforeOwnerUserId: $ownerUserId,
beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
afterOwnerUserId: $ownerUserId,
afterAssigneeUserId: $assigneeUserId,
);
$changeSummary = $this->responsibilityChangeSummary(
beforeOwnerUserId: $ownerUserId,
beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
afterOwnerUserId: $ownerUserId,
afterAssigneeUserId: $assigneeUserId,
);
return $this->mutateAndAudit(
finding: $finding,
tenant: $tenant,
actor: $actor,
action: AuditActionId::FindingAssigned,
context: [
'metadata' => [
'assignee_user_id' => $assigneeUserId,
'owner_user_id' => $ownerUserId,
'responsibility_change_classification' => $changeClassification,
'responsibility_change_summary' => $changeSummary,
'claim_self_service' => true,
],
],
mutate: function (Finding $record) use ($assigneeUserId): void {
if (! in_array((string) $record->status, Finding::openStatuses(), true)) {
throw new ConflictHttpException('Finding is no longer claimable.');
}
if ($record->assignee_user_id !== null) {
throw new ConflictHttpException('Finding is already assigned.');
}
$record->assignee_user_id = $assigneeUserId;
},
);
}
public function responsibilityChangeClassification(
@ -393,7 +466,7 @@ public function reopenBySystem(
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
return $this->mutateAndAudit(
$reopenedFinding = $this->mutateAndAudit(
finding: $finding,
tenant: $tenant,
actor: null,
@ -424,6 +497,30 @@ public function reopenBySystem(
actorType: AuditActorType::System,
operationRunId: $operationRunId,
);
$this->findingNotificationService->dispatch($reopenedFinding, AlertRule::EVENT_FINDINGS_REOPENED);
return $reopenedFinding;
}
public function lastMeaningfulActivityAt(Finding $finding, mixed $latestWorkflowAuditAt = null): ?CarbonImmutable
{
$timestamps = array_filter([
$this->normalizeActivityTimestamp($finding->in_progress_at),
$this->normalizeActivityTimestamp($finding->reopened_at),
$this->normalizeActivityTimestamp($latestWorkflowAuditAt),
]);
if ($timestamps === []) {
return null;
}
usort(
$timestamps,
static fn (CarbonImmutable $left, CarbonImmutable $right): int => $left->greaterThan($right) ? -1 : ($left->equalTo($right) ? 0 : 1),
);
return $timestamps[0];
}
/**
@ -492,6 +589,27 @@ private function validatedReason(string $reason, string $field): string
return $reason;
}
private function normalizeActivityTimestamp(mixed $value): ?CarbonImmutable
{
if ($value instanceof CarbonImmutable) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return CarbonImmutable::instance($value);
}
if (! is_string($value) || trim($value) === '') {
return null;
}
try {
return CarbonImmutable::parse($value);
} catch (\Throwable) {
return null;
}
}
/**
* @param array<string, mixed> $context
*/

View File

@ -94,6 +94,7 @@ public static function forTenant(?Tenant $tenant): self
}
$assignment = BaselineTenantAssignment::query()
->with('baselineProfile')
->where('tenant_id', $tenant->getKey())
->first();

View File

@ -76,7 +76,7 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
if ($path === '/admin/findings/my-work') {
if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true)) {
$this->configureNavigationForRequest($panel);
return $next($request);
@ -119,7 +119,7 @@ public function handle(Request $request, Closure $next): Response
str_starts_with($path, '/admin/w/')
|| str_starts_with($path, '/admin/workspaces')
|| str_starts_with($path, '/admin/operations')
|| in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace', '/admin/findings/my-work'], true)
|| in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace', '/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true)
) {
$this->configureNavigationForRequest($panel);
@ -261,6 +261,14 @@ private function adminPathRequiresTenantSelection(string $path): bool
return false;
}
if (str_starts_with($path, '/admin/findings/intake')) {
return false;
}
if (str_starts_with($path, '/admin/findings/hygiene')) {
return false;
}
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
}
}

View File

@ -294,6 +294,8 @@ private static function operationAliases(): array
new OperationTypeAlias('policy_version.force_delete', 'policy_version.force_delete', 'canonical', true),
new OperationTypeAlias('alerts.evaluate', 'alerts.evaluate', 'canonical', true),
new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true),
new OperationTypeAlias('baseline.capture', 'baseline.capture', 'canonical', true),
new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true),
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', true, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', true, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', true, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),

View File

@ -6,6 +6,7 @@
use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\FindingResource;
@ -20,6 +21,7 @@
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Findings\FindingAssignmentHygieneService;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
@ -49,6 +51,7 @@ final class WorkspaceOverviewBuilder
public function __construct(
private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
private CapabilityResolver $capabilityResolver,
private FindingAssignmentHygieneService $findingAssignmentHygieneService,
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
private TenantBackupHealthResolver $tenantBackupHealthResolver,
private RestoreSafetyResolver $restoreSafetyResolver,
@ -134,6 +137,7 @@ public function build(Workspace $workspace, User $user): array
];
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user);
$findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $user);
$zeroTenantState = null;
@ -174,6 +178,7 @@ public function build(Workspace $workspace, User $user): array
'workspace_name' => (string) $workspace->name,
'accessible_tenant_count' => $accessibleTenants->count(),
'my_findings_signal' => $myFindingsSignal,
'findings_hygiene_signal' => $findingsHygieneSignal,
'summary_metrics' => $summaryMetrics,
'triage_review_progress' => $triageReviewProgress['families'],
'attention_items' => $attentionItems,
@ -217,29 +222,26 @@ private function myFindingsSignal(int $workspaceId, Collection $accessibleTenant
->values()
->all();
$openAssignedCount = $visibleTenantIds === []
? 0
: (int) $this->scopeToVisibleTenants(
$assignedCounts = $visibleTenantIds === []
? null
: $this->scopeToVisibleTenants(
Finding::query(),
$workspaceId,
$visibleTenantIds,
)
->where('assignee_user_id', (int) $user->getKey())
->whereIn('status', Finding::openStatusesForQuery())
->count();
->selectRaw('count(*) as open_assigned_count')
->selectRaw('sum(case when due_at is not null and due_at < ? then 1 else 0 end) as overdue_assigned_count', [now()])
->first();
$overdueAssignedCount = $visibleTenantIds === []
? 0
: (int) $this->scopeToVisibleTenants(
Finding::query(),
$workspaceId,
$visibleTenantIds,
)
->where('assignee_user_id', (int) $user->getKey())
->whereIn('status', Finding::openStatusesForQuery())
->whereNotNull('due_at')
->where('due_at', '<', now())
->count();
$openAssignedCount = is_numeric($assignedCounts?->open_assigned_count)
? (int) $assignedCounts->open_assigned_count
: 0;
$overdueAssignedCount = is_numeric($assignedCounts?->overdue_assigned_count)
? (int) $assignedCounts->overdue_assigned_count
: 0;
$isCalm = $openAssignedCount === 0;
@ -266,6 +268,66 @@ private function myFindingsSignal(int $workspaceId, Collection $accessibleTenant
];
}
/**
* @return array<string, mixed>
*/
private function findingsHygieneSignal(Workspace $workspace, User $user): array
{
$summary = $this->findingAssignmentHygieneService->summary($workspace, $user);
$uniqueIssueCount = $summary['unique_issue_count'];
$brokenAssignmentCount = $summary['broken_assignment_count'];
$staleInProgressCount = $summary['stale_in_progress_count'];
$isCalm = $uniqueIssueCount === 0;
return [
'headline' => $isCalm
? 'Findings hygiene is calm'
: sprintf(
'%d visible hygiene %s need follow-up',
$uniqueIssueCount,
Str::plural('issue', $uniqueIssueCount),
),
'description' => $this->findingsHygieneDescription($brokenAssignmentCount, $staleInProgressCount),
'unique_issue_count' => $uniqueIssueCount,
'broken_assignment_count' => $brokenAssignmentCount,
'stale_in_progress_count' => $staleInProgressCount,
'is_calm' => $isCalm,
'cta_label' => 'Open hygiene report',
'cta_url' => FindingsHygieneReport::getUrl(panel: 'admin'),
];
}
private function findingsHygieneDescription(int $brokenAssignmentCount, int $staleInProgressCount): string
{
if ($brokenAssignmentCount === 0 && $staleInProgressCount === 0) {
return 'No broken assignments or stale in-progress work are visible across your entitled tenants.';
}
if ($brokenAssignmentCount > 0 && $staleInProgressCount > 0) {
return sprintf(
'%d broken %s and %d stale in-progress %s need repair.',
$brokenAssignmentCount,
Str::plural('assignment', $brokenAssignmentCount),
$staleInProgressCount,
Str::plural('finding', $staleInProgressCount),
);
}
if ($brokenAssignmentCount > 0) {
return sprintf(
'%d broken %s need repair before work can continue.',
$brokenAssignmentCount,
Str::plural('assignment', $brokenAssignmentCount),
);
}
return sprintf(
'%d stale in-progress %s need follow-up.',
$staleInProgressCount,
Str::plural('finding', $staleInProgressCount),
);
}
/**
* @param Collection<int, Tenant> $accessibleTenants
* @return list<array<string, mixed>>
@ -1434,10 +1496,9 @@ private function canManageWorkspaces(Workspace $workspace, User $user): bool
}
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
$role = $this->workspaceCapabilityResolver->getRole($user, $workspace);
return $user->workspaceMemberships()
->whereIn('role', $roles)
->exists();
return $role !== null && in_array($role->value, $roles, true);
}
private function tenantRouteKey(Tenant $tenant): string

View File

@ -140,6 +140,34 @@ public function reopened(): static
]);
}
public function ownedBy(?int $userId): static
{
return $this->state(fn (array $attributes): array => [
'owner_user_id' => $userId,
]);
}
public function assignedTo(?int $userId): static
{
return $this->state(fn (array $attributes): array => [
'assignee_user_id' => $userId,
]);
}
public function dueWithinHours(int $hours): static
{
return $this->state(fn (array $attributes): array => [
'due_at' => now()->addHours($hours),
]);
}
public function overdueByHours(int $hours = 1): static
{
return $this->state(fn (array $attributes): array => [
'due_at' => now()->subHours($hours),
]);
}
/**
* State for closed findings.
*/

View File

@ -0,0 +1,103 @@
<x-filament-panels::page>
@php($scope = $this->appliedScope())
@php($summary = $this->summaryCounts())
@php($reasonFilters = $this->availableReasonFilters())
<div class="space-y-6">
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="space-y-2">
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-danger-200 bg-danger-50 px-3 py-1 text-xs font-medium text-danger-700 dark:border-danger-700/60 dark:bg-danger-950/40 dark:text-danger-200">
<x-filament::icon icon="heroicon-o-wrench-screwdriver" class="h-3.5 w-3.5" />
Findings hygiene
</div>
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
Findings hygiene report
</h1>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
Review visible broken assignments and stale in-progress work across entitled tenants in one read-first repair queue. Existing finding detail stays the only place where reassignment or lifecycle repair happens.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
Visible issues
</div>
<div class="mt-2 text-3xl font-semibold text-gray-950 dark:text-white">
{{ $summary['unique_issue_count'] }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
One row per visible finding, even when multiple hygiene reasons apply.
</div>
</div>
<div class="rounded-2xl border border-danger-200 bg-danger-50/70 p-4 shadow-sm dark:border-danger-700/50 dark:bg-danger-950/30">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-danger-700 dark:text-danger-200">
Broken assignments
</div>
<div class="mt-2 text-3xl font-semibold text-danger-950 dark:text-danger-100">
{{ $summary['broken_assignment_count'] }}
</div>
<div class="mt-1 text-sm text-danger-800 dark:text-danger-200">
Assignees who can no longer act on the finding.
</div>
</div>
<div class="rounded-2xl border border-warning-200 bg-warning-50/70 p-4 shadow-sm dark:border-warning-700/50 dark:bg-warning-950/30">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-warning-700 dark:text-warning-200">
Stale in progress
</div>
<div class="mt-2 text-3xl font-semibold text-warning-950 dark:text-warning-100">
{{ $summary['stale_in_progress_count'] }}
</div>
<div class="mt-1 text-sm text-warning-800 dark:text-warning-200">
In-progress findings with no meaningful workflow movement for seven days.
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
Applied scope
</div>
<div class="mt-2 text-sm font-semibold text-gray-950 dark:text-white">
{{ $scope['reason_filter_label'] }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
@if (($scope['tenant_prefilter_source'] ?? 'none') === 'active_tenant_context')
Tenant prefilter from active context:
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
@elseif (($scope['tenant_prefilter_source'] ?? 'none') === 'explicit_filter')
Tenant filter applied:
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
@else
All visible tenants are currently included.
@endif
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
@foreach ($reasonFilters as $reasonFilter)
<a
href="{{ $reasonFilter['url'] }}"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $reasonFilter['active'] ? 'border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:text-gray-950 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:border-white/20 dark:hover:text-white' }}"
>
<span>{{ $reasonFilter['label'] }}</span>
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs font-semibold dark:bg-white/10">
{{ $reasonFilter['badge_count'] }}
</span>
<span class="text-[11px] uppercase tracking-[0.12em] opacity-70">Fixed</span>
</a>
@endforeach
</div>
</div>
</x-filament::section>
{{ $this->table }}
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,103 @@
<x-filament-panels::page>
@php($scope = $this->appliedScope())
@php($summary = $this->summaryCounts())
@php($queueViews = $this->queueViews())
<div class="space-y-6">
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="space-y-2">
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-warning-200 bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:border-warning-700/60 dark:bg-warning-950/40 dark:text-warning-300">
<x-filament::icon icon="heroicon-o-inbox-stack" class="h-3.5 w-3.5" />
Shared unassigned work
</div>
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
Findings intake
</h1>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
Review visible unassigned open findings across entitled tenants in one queue. Tenant context can narrow the view, but the intake scope stays fixed.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
Visible unassigned
</div>
<div class="mt-2 text-3xl font-semibold text-gray-950 dark:text-white">
{{ $summary['visible_unassigned'] }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Visible unassigned intake rows after the current tenant scope.
</div>
</div>
<div class="rounded-2xl border border-warning-200 bg-warning-50/70 p-4 shadow-sm dark:border-warning-700/50 dark:bg-warning-950/30">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-warning-700 dark:text-warning-200">
Needs triage
</div>
<div class="mt-2 text-3xl font-semibold text-warning-950 dark:text-warning-100">
{{ $summary['visible_needs_triage'] }}
</div>
<div class="mt-1 text-sm text-warning-800 dark:text-warning-200">
Visible `new` and `reopened` intake rows that still need first routing.
</div>
</div>
<div class="rounded-2xl border border-danger-200 bg-danger-50/70 p-4 shadow-sm dark:border-danger-700/50 dark:bg-danger-950/30">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-danger-700 dark:text-danger-200">
Overdue
</div>
<div class="mt-2 text-3xl font-semibold text-danger-950 dark:text-danger-100">
{{ $summary['visible_overdue'] }}
</div>
<div class="mt-1 text-sm text-danger-800 dark:text-danger-200">
Intake rows that are already past due.
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
Applied scope
</div>
<div class="mt-2 text-sm font-semibold text-gray-950 dark:text-white">
{{ $scope['queue_view_label'] }}
</div>
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
@if (($scope['tenant_prefilter_source'] ?? 'none') === 'active_tenant_context')
Tenant prefilter from active context:
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
@elseif (($scope['tenant_prefilter_source'] ?? 'none') === 'explicit_filter')
Tenant filter applied:
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
@else
All visible tenants are currently included.
@endif
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
@foreach ($queueViews as $queueView)
<a
href="{{ $queueView['url'] }}"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $queueView['active'] ? 'border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:text-gray-950 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:border-white/20 dark:hover:text-white' }}"
>
<span>{{ $queueView['label'] }}</span>
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs font-semibold dark:bg-white/10">
{{ $queueView['badge_count'] }}
</span>
<span class="text-[11px] uppercase tracking-[0.12em] opacity-70">Fixed</span>
</a>
@endforeach
</div>
</div>
</x-filament::section>
{{ $this->table }}
</div>
</x-filament-panels::page>

View File

@ -3,6 +3,7 @@
$workspace = $overview['workspace'] ?? ['name' => 'Workspace'];
$quickActions = $overview['quick_actions'] ?? [];
$myFindingsSignal = $overview['my_findings_signal'] ?? null;
$findingsHygieneSignal = $overview['findings_hygiene_signal'] ?? null;
$zeroTenantState = $overview['zero_tenant_state'] ?? null;
@endphp
@ -101,6 +102,52 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
</section>
@endif
@if (is_array($findingsHygieneSignal))
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-3">
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-danger-200 bg-danger-50 px-3 py-1 text-xs font-medium text-danger-700 dark:border-danger-700/60 dark:bg-danger-950/40 dark:text-danger-200">
<x-filament::icon icon="heroicon-o-wrench-screwdriver" class="h-3.5 w-3.5" />
Findings hygiene
</div>
<div class="space-y-1">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
{{ $findingsHygieneSignal['headline'] }}
</h2>
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ $findingsHygieneSignal['description'] }}
</p>
</div>
<div class="flex flex-wrap gap-2 text-xs">
<span class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-3 py-1 font-medium text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
Unique issues: {{ $findingsHygieneSignal['unique_issue_count'] }}
</span>
<span class="inline-flex items-center rounded-full border border-danger-200 bg-danger-50 px-3 py-1 font-medium text-danger-700 dark:border-danger-700/50 dark:bg-danger-950/30 dark:text-danger-200">
Broken assignments: {{ $findingsHygieneSignal['broken_assignment_count'] }}
</span>
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-3 py-1 font-medium text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200">
Stale in progress: {{ $findingsHygieneSignal['stale_in_progress_count'] }}
</span>
<span class="inline-flex items-center rounded-full border px-3 py-1 font-medium {{ ($findingsHygieneSignal['is_calm'] ?? false) ? 'border-success-200 bg-success-50 text-success-700 dark:border-success-700/50 dark:bg-success-950/30 dark:text-success-200' : 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200' }}">
{{ ($findingsHygieneSignal['is_calm'] ?? false) ? 'Calm' : 'Needs repair' }}
</span>
</div>
</div>
<x-filament::button
tag="a"
color="danger"
:href="$findingsHygieneSignal['cta_url']"
icon="heroicon-o-arrow-right"
>
{{ $findingsHygieneSignal['cta_label'] }}
</x-filament::button>
</div>
</section>
@endif
@if (is_array($zeroTenantState))
<section class="rounded-2xl border border-warning-200 bg-warning-50 p-5 shadow-sm dark:border-warning-700/50 dark:bg-warning-950/30">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">

View File

@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
use App\Filament\Resources\AlertRuleResource;
use App\Models\AlertDelivery;
use App\Models\AlertDestination;
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Notifications\Findings\FindingEventNotification;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Findings\FindingNotificationService;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('exposes the four finding notification events in the existing alert rule options', function (): void {
expect(AlertRuleResource::eventTypeOptions())->toMatchArray([
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
]);
});
it('delivers a direct finding notification without requiring a matching alert rule', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
]);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
expect($result['direct_delivery_status'])->toBe('sent')
->and($result['external_delivery_count'])->toBe(0)
->and($assignee->notifications()->where('type', FindingEventNotification::class)->count())->toBe(1)
->and(AlertDelivery::query()->count())->toBe(0);
});
it('fans out matching external copies through the existing alert delivery pipeline', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->workspace_id;
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
'minimum_severity' => Finding::SEVERITY_MEDIUM,
'is_enabled' => true,
'cooldown_seconds' => 0,
]);
$rule->destinations()->attach($destination->getKey(), [
'workspace_id' => $workspaceId,
]);
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => $workspaceId,
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => null,
'severity' => Finding::SEVERITY_HIGH,
'due_at' => now()->subHour(),
]);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
$delivery = AlertDelivery::query()->latest('id')->first();
expect($result['direct_delivery_status'])->toBe('sent')
->and($result['external_delivery_count'])->toBe(1)
->and($delivery)->not->toBeNull()
->and($delivery?->event_type)->toBe(AlertRule::EVENT_FINDINGS_OVERDUE)
->and(data_get($delivery?->payload, 'title'))->toBe('Finding overdue');
});
it('inherits minimum severity tenant scoping and cooldown suppression for finding alert copies', function (): void {
[$ownerA, $tenantA] = createUserWithTenant(role: 'owner');
$workspaceId = (int) $tenantA->workspace_id;
$tenantB = Tenant::factory()->create([
'workspace_id' => $workspaceId,
]);
[$ownerB] = createUserWithTenant(tenant: $tenantB, role: 'owner');
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
'minimum_severity' => Finding::SEVERITY_HIGH,
'tenant_scope_mode' => AlertRule::TENANT_SCOPE_ALLOWLIST,
'tenant_allowlist' => [(int) $tenantA->getKey()],
'is_enabled' => true,
'cooldown_seconds' => 3600,
]);
$rule->destinations()->attach($destination->getKey(), [
'workspace_id' => $workspaceId,
]);
$mediumFinding = Finding::factory()->for($tenantA)->create([
'workspace_id' => $workspaceId,
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => (int) $ownerA->getKey(),
'severity' => Finding::SEVERITY_MEDIUM,
'due_at' => now()->subHour(),
]);
$scopedOutFinding = Finding::factory()->for($tenantB)->create([
'workspace_id' => $workspaceId,
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => (int) $ownerB->getKey(),
'severity' => Finding::SEVERITY_CRITICAL,
'due_at' => now()->subHour(),
]);
$trackedFinding = Finding::factory()->for($tenantA)->create([
'workspace_id' => $workspaceId,
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => (int) $ownerA->getKey(),
'severity' => Finding::SEVERITY_HIGH,
'due_at' => now()->subHour(),
]);
expect(app(FindingNotificationService::class)->dispatch($mediumFinding, AlertRule::EVENT_FINDINGS_OVERDUE)['external_delivery_count'])->toBe(0)
->and(app(FindingNotificationService::class)->dispatch($scopedOutFinding, AlertRule::EVENT_FINDINGS_OVERDUE)['external_delivery_count'])->toBe(0);
app(FindingNotificationService::class)->dispatch($trackedFinding, AlertRule::EVENT_FINDINGS_OVERDUE);
app(FindingNotificationService::class)->dispatch($trackedFinding->fresh(), AlertRule::EVENT_FINDINGS_OVERDUE);
$deliveries = AlertDelivery::query()
->where('workspace_id', $workspaceId)
->where('event_type', AlertRule::EVENT_FINDINGS_OVERDUE)
->orderBy('id')
->get();
expect($deliveries)->toHaveCount(2)
->and($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED)
->and($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
});
it('inherits quiet hours deferral for finding alert copies', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->workspace_id;
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
'event_type' => AlertRule::EVENT_FINDINGS_ASSIGNED,
'minimum_severity' => Finding::SEVERITY_LOW,
'is_enabled' => true,
'cooldown_seconds' => 0,
'quiet_hours_enabled' => true,
'quiet_hours_start' => '00:00',
'quiet_hours_end' => '23:59',
'quiet_hours_timezone' => 'UTC',
]);
$rule->destinations()->attach($destination->getKey(), [
'workspace_id' => $workspaceId,
]);
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => $workspaceId,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $owner->getKey(),
'severity' => Finding::SEVERITY_LOW,
]);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
$delivery = AlertDelivery::query()->latest('id')->first();
expect($result['external_delivery_count'])->toBe(1)
->and($delivery)->not->toBeNull()
->and($delivery?->status)->toBe(AlertDelivery::STATUS_DEFERRED);
});
it('renders finding event labels and filters in the existing alert deliveries viewer', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$workspaceId = (int) $tenant->workspace_id;
$rule = AlertRule::factory()->create([
'workspace_id' => $workspaceId,
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
]);
$destination = AlertDestination::factory()->create([
'workspace_id' => $workspaceId,
'is_enabled' => true,
]);
$delivery = AlertDelivery::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => (int) $tenant->getKey(),
'alert_rule_id' => (int) $rule->getKey(),
'alert_destination_id' => (int) $destination->getKey(),
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
'payload' => [
'title' => 'Finding overdue',
'body' => 'A finding is overdue and needs follow-up.',
],
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListAlertDeliveries::class)
->filterTable('event_type', AlertRule::EVENT_FINDINGS_OVERDUE)
->assertCanSeeTableRecords([$delivery])
->assertSee('Finding overdue');
expect(AlertRuleResource::eventTypeLabel(AlertRule::EVENT_FINDINGS_OVERDUE))->toBe('Finding overdue');
});
it('preserves alerts read and mutation boundaries for the existing admin surfaces', function (): void {
$workspace = Workspace::factory()->create();
$viewer = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $viewer->getKey(),
'role' => 'readonly',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($viewer)
->get(AlertRuleResource::getUrl(panel: 'admin'))
->assertOk();
$this->actingAs($viewer)
->get(AlertDeliveryResource::getUrl(panel: 'admin'))
->assertOk();
$this->actingAs($viewer)
->get(AlertRuleResource::getUrl('create', panel: 'admin'))
->assertForbidden();
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')->andReturnFalse();
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
$this->actingAs($viewer)
->get(AlertRuleResource::getUrl(panel: 'admin'))
->assertForbidden();
$this->actingAs($viewer)
->get(AlertDeliveryResource::getUrl(panel: 'admin'))
->assertForbidden();
$outsider = User::factory()->create();
app()->forgetInstance(WorkspaceCapabilityResolver::class);
$this->actingAs($outsider)
->get(AlertRuleResource::getUrl(panel: 'admin'))
->assertNotFound();
});

View File

@ -194,3 +194,41 @@ function invokeSlaDueEvents(int $workspaceId, CarbonImmutable $windowStart): arr
->and($first[0]['fingerprint_key'])->toBe($second[0]['fingerprint_key'])
->and($first[0]['fingerprint_key'])->not->toBe($third[0]['fingerprint_key']);
});
it('keeps aggregate sla due alerts separate from finding-level due soon reminders', function (): void {
$now = CarbonImmutable::parse('2026-04-22T12:00:00Z');
CarbonImmutable::setTestNow($now);
[$user, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
Finding::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => $tenant->getKey(),
'status' => Finding::STATUS_TRIAGED,
'severity' => Finding::SEVERITY_HIGH,
'due_at' => $now->subHour(),
]);
Finding::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => $tenant->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'severity' => Finding::SEVERITY_CRITICAL,
'due_at' => $now->addHours(6),
]);
$events = invokeSlaDueEvents($workspaceId, $now->subDay());
expect($events)->toHaveCount(1)
->and($events[0]['event_type'])->toBe(AlertRule::EVENT_SLA_DUE)
->and($events[0]['metadata'])->toMatchArray([
'overdue_total' => 1,
'overdue_by_severity' => [
'critical' => 0,
'high' => 1,
'medium' => 0,
'low' => 0,
],
]);
});

View File

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('redirects intake visits without workspace context into the existing workspace chooser flow', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->get(FindingsIntakeQueue::getUrl(panel: 'admin'))
->assertRedirect('/admin/choose-workspace');
});
it('returns 404 for users outside the active workspace on the intake route', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(FindingsIntakeQueue::getUrl(panel: 'admin'))
->assertNotFound();
});
it('returns 403 for workspace members with no currently viewable findings scope anywhere', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => 'active',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(FindingsIntakeQueue::getUrl(panel: 'admin'))
->assertForbidden();
});
it('suppresses hidden-tenant findings and keeps their detail route not found', function (): void {
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
]);
$visibleFinding = Finding::factory()->for($visibleTenant)->create([
'workspace_id' => (int) $visibleTenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'assignee_user_id' => null,
]);
$hiddenFinding = Finding::factory()->for($hiddenTenant)->create([
'workspace_id' => (int) $hiddenTenant->workspace_id,
'status' => Finding::STATUS_NEW,
'assignee_user_id' => null,
]);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $visibleTenant->workspace_id);
Livewire::actingAs($user)
->test(FindingsIntakeQueue::class)
->assertCanSeeTableRecords([$visibleFinding])
->assertCanNotSeeTableRecords([$hiddenFinding]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
->get(FindingResource::getUrl('view', ['record' => $hiddenFinding], panel: 'tenant', tenant: $hiddenTenant))
->assertNotFound();
});
it('keeps inspect access while disabling claim for members without assign capability', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_NEW,
'assignee_user_id' => null,
]);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(FindingsIntakeQueue::class)
->assertCanSeeTableRecords([$finding])
->assertTableActionVisible('claim', $finding)
->assertTableActionDisabled('claim', $finding)
->callTableAction('claim', $finding);
expect($finding->refresh()->assignee_user_id)->toBeNull();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
->assertOk();
});

View File

@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Findings\FindingAssignmentHygieneService;
use App\Support\Audit\AuditActionId;
use Carbon\CarbonImmutable;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function assignmentHygieneServiceContext(string $role = 'readonly', string $workspaceRole = 'readonly'): array
{
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
return [
app(FindingAssignmentHygieneService::class),
$user,
$tenant->workspace()->firstOrFail(),
$tenant,
];
}
function assignmentHygieneFinding(Tenant $tenant, array $attributes = []): Finding
{
return Finding::factory()->for($tenant)->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'subject_external_id' => fake()->uuid(),
], $attributes));
}
function recordAssignmentHygieneWorkflowAudit(Finding $finding, string $action, CarbonImmutable $recordedAt): AuditLog
{
return AuditLog::query()->create([
'workspace_id' => (int) $finding->workspace_id,
'tenant_id' => (int) $finding->tenant_id,
'action' => $action,
'status' => 'success',
'resource_type' => 'finding',
'resource_id' => (string) $finding->getKey(),
'summary' => 'Test workflow activity',
'recorded_at' => $recordedAt,
]);
}
it('classifies broken assignments from current tenant entitlement loss and soft-deleted assignees', function (): void {
[$service, $viewer, $workspace, $tenant] = assignmentHygieneServiceContext();
$lostMember = User::factory()->create(['name' => 'Lost Member']);
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
$softDeletedAssignee = User::factory()->create(['name' => 'Deleted Member']);
createUserWithTenant($tenant, $softDeletedAssignee, role: 'readonly', workspaceRole: 'readonly');
$softDeletedAssignee->delete();
$healthyAssignee = User::factory()->create(['name' => 'Healthy Assignee']);
createUserWithTenant($tenant, $healthyAssignee, role: 'readonly', workspaceRole: 'readonly');
$brokenByMembership = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'status' => Finding::STATUS_TRIAGED,
'subject_external_id' => 'broken-membership',
]);
$brokenBySoftDelete = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $softDeletedAssignee->getKey(),
'status' => Finding::STATUS_NEW,
'subject_external_id' => 'broken-soft-delete',
]);
$healthyAssigned = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $healthyAssignee->getKey(),
'status' => Finding::STATUS_NEW,
'subject_external_id' => 'healthy-assigned',
]);
$ordinaryIntake = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => null,
'status' => Finding::STATUS_NEW,
'subject_external_id' => 'ordinary-intake',
]);
$issues = $service->issueQuery($workspace, $viewer)->get()->keyBy('id');
$summary = $service->summary($workspace, $viewer);
expect($issues->keys()->all())
->toContain((int) $brokenByMembership->getKey(), (int) $brokenBySoftDelete->getKey())
->not->toContain((int) $healthyAssigned->getKey(), (int) $ordinaryIntake->getKey())
->and($service->reasonLabelsFor($issues[$brokenByMembership->getKey()]))
->toBe(['Broken assignment'])
->and($service->reasonLabelsFor($issues[$brokenBySoftDelete->getKey()]))
->toBe(['Broken assignment'])
->and($issues[$brokenBySoftDelete->getKey()]->assigneeUser?->name)
->toBe('Deleted Member')
->and($summary)
->toBe([
'unique_issue_count' => 2,
'broken_assignment_count' => 2,
'stale_in_progress_count' => 0,
]);
});
it('classifies stale in-progress work from meaningful workflow activity and excludes recently advanced or merely overdue work', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
[$service, $viewer, $workspace, $tenant] = assignmentHygieneServiceContext();
$staleFinding = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'subject_external_id' => 'stale-finding',
]);
recordAssignmentHygieneWorkflowAudit($staleFinding, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(10));
$recentlyAssigned = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'subject_external_id' => 'recently-assigned',
]);
recordAssignmentHygieneWorkflowAudit($recentlyAssigned, AuditActionId::FindingAssigned->value, CarbonImmutable::now()->subDays(2));
$recentlyReopened = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'reopened_at' => now()->subDay(),
'subject_external_id' => 'recently-reopened',
]);
recordAssignmentHygieneWorkflowAudit($recentlyReopened, AuditActionId::FindingReopened->value, CarbonImmutable::now()->subDay());
$overdueButActive = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'due_at' => now()->subDay(),
'subject_external_id' => 'overdue-but-active',
]);
recordAssignmentHygieneWorkflowAudit($overdueButActive, AuditActionId::FindingAssigned->value, CarbonImmutable::now()->subHours(12));
$issues = $service->issueQuery(
$workspace,
$viewer,
reasonFilter: FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS,
)->get();
$summary = $service->summary($workspace, $viewer);
expect($issues->pluck('id')->all())
->toBe([(int) $staleFinding->getKey()])
->and($service->reasonLabelsFor($issues->firstOrFail()))
->toBe(['Stale in progress'])
->and($service->lastWorkflowActivityAt($issues->firstOrFail())?->toIso8601String())
->toBe(CarbonImmutable::now()->subDays(10)->toIso8601String())
->and($summary)
->toBe([
'unique_issue_count' => 1,
'broken_assignment_count' => 0,
'stale_in_progress_count' => 1,
]);
});
it('counts multi-reason findings once while excluding healthy assigned work and ordinary intake backlog', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
[$service, $viewer, $workspace, $tenant] = assignmentHygieneServiceContext();
$lostMember = User::factory()->create(['name' => 'Lost Worker']);
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
$brokenAndStale = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(8),
'subject_external_id' => 'broken-and-stale',
]);
recordAssignmentHygieneWorkflowAudit($brokenAndStale, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(8));
assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
'status' => Finding::STATUS_TRIAGED,
'subject_external_id' => 'healthy-assigned',
]);
assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => null,
'status' => Finding::STATUS_NEW,
'subject_external_id' => 'ordinary-intake',
]);
$issues = $service->issueQuery($workspace, $viewer)->get();
$summary = $service->summary($workspace, $viewer);
expect($issues)->toHaveCount(1)
->and((int) $issues->firstOrFail()->getKey())->toBe((int) $brokenAndStale->getKey())
->and($service->reasonLabelsFor($issues->firstOrFail()))
->toBe(['Broken assignment', 'Stale in progress'])
->and($summary)
->toBe([
'unique_issue_count' => 1,
'broken_assignment_count' => 1,
'stale_in_progress_count' => 1,
]);
});

View File

@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceOverviewBuilder;
use Carbon\CarbonImmutable;
use function Pest\Laravel\mock;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function findingsHygieneOverviewContext(string $role = 'readonly', string $workspaceRole = 'readonly'): array
{
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
return [$user, $tenant, $tenant->workspace()->firstOrFail()];
}
function makeFindingsHygieneOverviewFinding(Tenant $tenant, array $attributes = []): Finding
{
$subjectDisplayName = $attributes['subject_display_name'] ?? null;
unset($attributes['subject_display_name']);
if (is_string($subjectDisplayName) && $subjectDisplayName !== '') {
$attributes['evidence_jsonb'] = array_merge(
is_array($attributes['evidence_jsonb'] ?? null) ? $attributes['evidence_jsonb'] : [],
['display_name' => $subjectDisplayName],
);
}
return Finding::factory()->for($tenant)->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => fake()->uuid(),
'status' => Finding::STATUS_TRIAGED,
], $attributes));
}
function recordFindingsHygieneOverviewAudit(Finding $finding, string $action, CarbonImmutable $recordedAt): AuditLog
{
return AuditLog::query()->create([
'workspace_id' => (int) $finding->workspace_id,
'tenant_id' => (int) $finding->tenant_id,
'action' => $action,
'status' => 'success',
'resource_type' => 'finding',
'resource_id' => (string) $finding->getKey(),
'summary' => 'Test workflow activity',
'recorded_at' => $recordedAt,
]);
}
it('adds a findings hygiene signal to the workspace overview and renders the report CTA', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
[$user, $tenant, $workspace] = findingsHygieneOverviewContext();
$lostMember = User::factory()->create(['name' => 'Lost Member']);
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
makeFindingsHygieneOverviewFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'subject_display_name' => 'Broken Assignment',
]);
$staleInProgress = makeFindingsHygieneOverviewFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(8),
'subject_display_name' => 'Stale In Progress',
]);
recordFindingsHygieneOverviewAudit($staleInProgress, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(8));
$signal = app(WorkspaceOverviewBuilder::class)->build($workspace, $user)['findings_hygiene_signal'];
expect($signal)
->toBe([
'headline' => '2 visible hygiene issues need follow-up',
'description' => '1 broken assignment and 1 stale in-progress finding need repair.',
'unique_issue_count' => 2,
'broken_assignment_count' => 1,
'stale_in_progress_count' => 1,
'is_calm' => false,
'cta_label' => 'Open hygiene report',
'cta_url' => FindingsHygieneReport::getUrl(panel: 'admin'),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin')
->assertOk()
->assertSee('Findings hygiene')
->assertSee('Unique issues: 2')
->assertSee('Broken assignments: 1')
->assertSee('Stale in progress: 1')
->assertSee('Open hygiene report');
});
it('keeps the overview signal calm and suppresses hidden-tenant hygiene issues from counts and copy', function (): void {
[$user, $visibleTenant, $workspace] = findingsHygieneOverviewContext();
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
'name' => 'Hidden Tenant',
]);
createUserWithTenant($hiddenTenant, $user, role: 'readonly', workspaceRole: 'readonly');
$lostMember = User::factory()->create(['name' => 'Hidden Lost Member']);
createUserWithTenant($hiddenTenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $hiddenTenant->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
makeFindingsHygieneOverviewFinding($hiddenTenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'subject_display_name' => 'Hidden Hygiene Issue',
]);
mock(CapabilityResolver::class, function ($mock) use ($visibleTenant, $hiddenTenant): void {
$mock->shouldReceive('primeMemberships')->atLeast()->once();
$mock->shouldReceive('isMember')
->andReturnUsing(static function (User $user, Tenant $tenant) use ($visibleTenant, $hiddenTenant): bool {
expect([(int) $visibleTenant->getKey(), (int) $hiddenTenant->getKey()])
->toContain((int) $tenant->getKey());
return true;
});
$mock->shouldReceive('can')
->andReturnUsing(static function (User $user, Tenant $tenant, string $capability) use ($visibleTenant, $hiddenTenant): bool {
expect([(int) $visibleTenant->getKey(), (int) $hiddenTenant->getKey()])
->toContain((int) $tenant->getKey());
return $capability === Capabilities::TENANT_FINDINGS_VIEW
&& (int) $tenant->getKey() === (int) $visibleTenant->getKey();
});
});
$signal = app(WorkspaceOverviewBuilder::class)->build($workspace, $user)['findings_hygiene_signal'];
expect($signal['unique_issue_count'])->toBe(0)
->and($signal['broken_assignment_count'])->toBe(0)
->and($signal['stale_in_progress_count'])->toBe(0)
->and($signal['is_calm'])->toBeTrue()
->and($signal['headline'])->toBe('Findings hygiene is calm')
->and($signal['description'])->toContain('No broken assignments or stale in-progress work are visible');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin')
->assertOk()
->assertSee('Findings hygiene is calm')
->assertSee('Unique issues: 0')
->assertSee('Calm')
->assertDontSee('Hidden Hygiene Issue');
});

View File

@ -0,0 +1,399 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Carbon\CarbonImmutable;
use Livewire\Livewire;
use function Pest\Laravel\mock;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function findingsHygieneActingUser(string $role = 'readonly', string $workspaceRole = 'readonly'): array
{
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
test()->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
return [$user, $tenant];
}
function findingsHygienePage(?User $user = null, array $query = [])
{
if ($user instanceof User) {
test()->actingAs($user);
}
setAdminPanelContext();
$factory = $query === []
? Livewire::actingAs(auth()->user())
: Livewire::withQueryParams($query)->actingAs(auth()->user());
return $factory->test(FindingsHygieneReport::class);
}
function makeFindingsHygieneFinding(Tenant $tenant, array $attributes = []): Finding
{
$subjectDisplayName = $attributes['subject_display_name'] ?? null;
unset($attributes['subject_display_name']);
if (is_string($subjectDisplayName) && $subjectDisplayName !== '') {
$attributes['evidence_jsonb'] = array_merge(
is_array($attributes['evidence_jsonb'] ?? null) ? $attributes['evidence_jsonb'] : [],
['display_name' => $subjectDisplayName],
);
}
return Finding::factory()->for($tenant)->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'subject_external_id' => fake()->uuid(),
'status' => Finding::STATUS_TRIAGED,
], $attributes));
}
function recordFindingsHygieneAudit(Finding $finding, string $action, CarbonImmutable $recordedAt): AuditLog
{
return AuditLog::query()->create([
'workspace_id' => (int) $finding->workspace_id,
'tenant_id' => (int) $finding->tenant_id,
'action' => $action,
'status' => 'success',
'resource_type' => 'finding',
'resource_id' => (string) $finding->getKey(),
'summary' => 'Test workflow activity',
'recorded_at' => $recordedAt,
]);
}
it('redirects hygiene report visits without workspace context into the existing workspace chooser flow', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
->assertRedirect('/admin/choose-workspace');
});
it('returns 404 for users outside the active workspace on the hygiene report route', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
->assertNotFound();
});
it('keeps the hygiene report accessible and calm for workspace members with zero visible hygiene scope', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['name' => 'Calm Workspace']);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'readonly',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Findings hygiene report')
->assertSee('No visible hygiene issues right now');
});
it('shows visible hygiene findings with reason labels, last activity, and row drilldown into tenant finding detail', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
[$user, $tenant] = findingsHygieneActingUser();
$lostMember = User::factory()->create(['name' => 'Lost Member']);
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
$brokenAssignment = makeFindingsHygieneFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'status' => Finding::STATUS_TRIAGED,
'subject_display_name' => 'Broken Assignment Finding',
]);
$staleInProgress = makeFindingsHygieneFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'subject_display_name' => 'Stale Progress Finding',
]);
recordFindingsHygieneAudit($staleInProgress, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(10));
findingsHygienePage($user)
->assertCanSeeTableRecords([$brokenAssignment, $staleInProgress])
->assertSee('Broken assignment')
->assertSee('Stale in progress')
->assertSee('Lost Member')
->assertSee('No current tenant membership');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Broken Assignment Finding')
->assertSee('/findings/'.$brokenAssignment->getKey(), false);
});
it('suppresses hidden-tenant rows, counts, and tenant filter values inside an otherwise available report', function (): void {
$visibleTenant = Tenant::factory()->create(['status' => 'active', 'name' => 'Visible Tenant']);
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
'name' => 'Hidden Tenant',
]);
createUserWithTenant($hiddenTenant, $user, role: 'readonly', workspaceRole: 'readonly');
$visibleAssignee = User::factory()->create(['name' => 'Visible Assignee']);
createUserWithTenant($visibleTenant, $visibleAssignee, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $visibleTenant->getKey())
->where('user_id', (int) $visibleAssignee->getKey())
->delete();
$hiddenAssignee = User::factory()->create(['name' => 'Hidden Assignee']);
createUserWithTenant($hiddenTenant, $hiddenAssignee, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $hiddenTenant->getKey())
->where('user_id', (int) $hiddenAssignee->getKey())
->delete();
$visibleFinding = makeFindingsHygieneFinding($visibleTenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $visibleAssignee->getKey(),
'subject_display_name' => 'Visible Hygiene Finding',
]);
$hiddenFinding = makeFindingsHygieneFinding($hiddenTenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $hiddenAssignee->getKey(),
'subject_display_name' => 'Hidden Hygiene Finding',
]);
mock(CapabilityResolver::class, function ($mock) use ($visibleTenant, $hiddenTenant): void {
$mock->shouldReceive('primeMemberships')->atLeast()->once();
$mock->shouldReceive('can')
->andReturnUsing(static function (User $user, Tenant $tenant, string $capability) use ($visibleTenant, $hiddenTenant): bool {
expect([(int) $visibleTenant->getKey(), (int) $hiddenTenant->getKey()])
->toContain((int) $tenant->getKey());
return $capability === Capabilities::TENANT_FINDINGS_VIEW
&& (int) $tenant->getKey() === (int) $visibleTenant->getKey();
});
});
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $visibleTenant->workspace_id);
$component = findingsHygienePage($user)
->assertCanSeeTableRecords([$visibleFinding])
->assertCanNotSeeTableRecords([$hiddenFinding])
->assertDontSee('Hidden Tenant');
expect($component->instance()->summaryCounts())
->toBe([
'unique_issue_count' => 1,
'broken_assignment_count' => 1,
'stale_in_progress_count' => 0,
])
->and($component->instance()->availableFilters())
->toBe([
[
'key' => 'hygiene_scope',
'label' => 'Findings hygiene only',
'fixed' => true,
'options' => [],
],
[
'key' => 'tenant',
'label' => 'Tenant',
'fixed' => false,
'options' => [
['value' => (string) $visibleTenant->getKey(), 'label' => $visibleTenant->name],
],
],
]);
});
it('supports fixed reason filters without duplicating a multi-reason finding in the all-issues view', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
[$user, $tenant] = findingsHygieneActingUser();
$lostMember = User::factory()->create(['name' => 'Lost Worker']);
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
$brokenOnly = makeFindingsHygieneFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'status' => Finding::STATUS_TRIAGED,
'subject_display_name' => 'Broken Only',
]);
$staleOnly = makeFindingsHygieneFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $user->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(9),
'subject_display_name' => 'Stale Only',
]);
recordFindingsHygieneAudit($staleOnly, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(9));
$brokenAndStale = makeFindingsHygieneFinding($tenant, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'subject_display_name' => 'Broken And Stale',
]);
recordFindingsHygieneAudit($brokenAndStale, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(10));
$allIssues = findingsHygienePage($user);
$brokenAssignments = findingsHygienePage($user, ['reason' => 'broken_assignment']);
$staleInProgress = findingsHygienePage($user, ['reason' => 'stale_in_progress']);
$allIssues
->assertCanSeeTableRecords([$brokenOnly, $staleOnly, $brokenAndStale])
->assertSee('Broken And Stale');
$brokenAssignments
->assertCanSeeTableRecords([$brokenOnly, $brokenAndStale])
->assertCanNotSeeTableRecords([$staleOnly]);
$staleInProgress
->assertCanSeeTableRecords([$staleOnly, $brokenAndStale])
->assertCanNotSeeTableRecords([$brokenOnly]);
expect($allIssues->instance()->summaryCounts())
->toBe([
'unique_issue_count' => 3,
'broken_assignment_count' => 2,
'stale_in_progress_count' => 2,
])
->and($allIssues->instance()->availableReasonFilters())
->toBe([
[
'key' => 'all',
'label' => 'All issues',
'active' => true,
'badge_count' => 3,
'url' => FindingsHygieneReport::getUrl(panel: 'admin'),
],
[
'key' => 'broken_assignment',
'label' => 'Broken assignment',
'active' => false,
'badge_count' => 2,
'url' => FindingsHygieneReport::getUrl(panel: 'admin', parameters: ['reason' => 'broken_assignment']),
],
[
'key' => 'stale_in_progress',
'label' => 'Stale in progress',
'active' => false,
'badge_count' => 2,
'url' => FindingsHygieneReport::getUrl(panel: 'admin', parameters: ['reason' => 'stale_in_progress']),
],
]);
});
it('explains when the active tenant prefilter hides otherwise visible hygiene issues and clears it in place', function (): void {
[$user, $tenantA] = findingsHygieneActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$lostMember = User::factory()->create(['name' => 'Lost Member']);
createUserWithTenant($tenantA, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $tenantA->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
$tenantAIssue = makeFindingsHygieneFinding($tenantA, [
'owner_user_id' => (int) $user->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'subject_display_name' => 'Tenant A Issue',
]);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
]);
$component = findingsHygienePage($user)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertCanNotSeeTableRecords([$tenantAIssue])
->assertSee('No hygiene issues match this tenant scope')
->assertActionVisible('clear_tenant_filter');
$component->callAction('clear_tenant_filter')
->assertCanSeeTableRecords([$tenantAIssue]);
expect($component->instance()->appliedScope())
->toBe([
'workspace_scoped' => true,
'fixed_scope' => 'visible_findings_hygiene_only',
'reason_filter' => 'all',
'reason_filter_label' => 'All issues',
'tenant_prefilter_source' => 'none',
'tenant_label' => null,
]);
});

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingWorkflowService;
use App\Support\Audit\AuditActionId;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('mounts the claim confirmation and moves the finding into my findings without changing owner or lifecycle', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'manager', workspaceRole: 'manager');
$owner = User::factory()->create();
createUserWithTenant($tenant, $owner, role: 'owner', workspaceRole: 'manager');
$finding = Finding::factory()->reopened()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => null,
'subject_external_id' => 'claimable',
]);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(FindingsIntakeQueue::class)
->assertTableActionVisible('claim', $finding)
->mountTableAction('claim', $finding)
->callMountedTableAction()
->assertCanNotSeeTableRecords([$finding]);
$finding->refresh();
expect((int) $finding->assignee_user_id)->toBe((int) $user->getKey())
->and((int) $finding->owner_user_id)->toBe((int) $owner->getKey())
->and($finding->status)->toBe(Finding::STATUS_REOPENED);
$audit = AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->where('resource_type', 'finding')
->where('resource_id', (string) $finding->getKey())
->where('action', AuditActionId::FindingAssigned->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and(data_get($audit?->metadata ?? [], 'assignee_user_id'))->toBe((int) $user->getKey())
->and(data_get($audit?->metadata ?? [], 'owner_user_id'))->toBe((int) $owner->getKey());
Livewire::actingAs($user)
->test(MyFindingsInbox::class)
->assertCanSeeTableRecords([$finding]);
});
it('refuses a stale claim after another operator already claimed the finding first', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$operatorA, $tenant] = createUserWithTenant($tenant, role: 'manager', workspaceRole: 'manager');
$operatorB = User::factory()->create();
createUserWithTenant($tenant, $operatorB, role: 'manager', workspaceRole: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_NEW,
'assignee_user_id' => null,
]);
$this->actingAs($operatorA);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$component = Livewire::actingAs($operatorA)
->test(FindingsIntakeQueue::class)
->mountTableAction('claim', $finding);
app(FindingWorkflowService::class)->claim($finding, $tenant, $operatorB);
$component
->callMountedTableAction();
expect((int) $finding->refresh()->assignee_user_id)->toBe((int) $operatorB->getKey());
Livewire::actingAs($operatorA)
->test(FindingsIntakeQueue::class)
->assertCanNotSeeTableRecords([$finding]);
expect(
AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->where('resource_type', 'finding')
->where('resource_id', (string) $finding->getKey())
->where('action', AuditActionId::FindingAssigned->value)
->count()
)->toBe(1);
});

View File

@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
function findingsIntakeActingUser(string $role = 'owner', string $workspaceRole = 'readonly'): array
{
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
test()->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
return [$user, $tenant];
}
function findingsIntakePage(?User $user = null, array $query = [])
{
if ($user instanceof User) {
test()->actingAs($user);
}
setAdminPanelContext();
$factory = $query === []
? Livewire::actingAs(auth()->user())
: Livewire::withQueryParams($query)->actingAs(auth()->user());
return $factory->test(FindingsIntakeQueue::class);
}
function makeIntakeFinding(Tenant $tenant, array $attributes = []): Finding
{
return Finding::factory()->for($tenant)->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'assignee_user_id' => null,
'owner_user_id' => null,
'subject_external_id' => fake()->uuid(),
], $attributes));
}
it('shows only visible unassigned open findings and exposes fixed queue view counts', function (): void {
[$user, $tenantA] = findingsIntakeActingUser();
$tenantA->forceFill(['name' => 'Alpha Tenant'])->save();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Tenant Bravo',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Hidden Tenant',
]);
$otherAssignee = User::factory()->create();
createUserWithTenant($tenantA, $otherAssignee, role: 'operator', workspaceRole: 'readonly');
$otherOwner = User::factory()->create();
createUserWithTenant($tenantB, $otherOwner, role: 'owner', workspaceRole: 'readonly');
$visibleNew = makeIntakeFinding($tenantA, [
'subject_external_id' => 'visible-new',
'severity' => Finding::SEVERITY_HIGH,
'status' => Finding::STATUS_NEW,
]);
$visibleReopened = makeIntakeFinding($tenantB, [
'subject_external_id' => 'visible-reopened',
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now()->subHours(6),
'owner_user_id' => (int) $otherOwner->getKey(),
]);
$visibleTriaged = makeIntakeFinding($tenantA, [
'subject_external_id' => 'visible-triaged',
'status' => Finding::STATUS_TRIAGED,
]);
$visibleInProgress = makeIntakeFinding($tenantB, [
'subject_external_id' => 'visible-progress',
'status' => Finding::STATUS_IN_PROGRESS,
'due_at' => now()->subDay(),
]);
$assignedOpen = makeIntakeFinding($tenantA, [
'subject_external_id' => 'assigned-open',
'assignee_user_id' => (int) $otherAssignee->getKey(),
]);
$acknowledged = Finding::factory()->for($tenantA)->acknowledged()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'assignee_user_id' => null,
'subject_external_id' => 'acknowledged',
]);
$terminal = Finding::factory()->for($tenantA)->resolved()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'assignee_user_id' => null,
'subject_external_id' => 'terminal',
]);
$hidden = makeIntakeFinding($hiddenTenant, [
'subject_external_id' => 'hidden-intake',
]);
$component = findingsIntakePage($user)
->assertCanSeeTableRecords([$visibleNew, $visibleReopened, $visibleTriaged, $visibleInProgress])
->assertCanNotSeeTableRecords([$assignedOpen, $acknowledged, $terminal, $hidden])
->assertSee('Owner: '.$otherOwner->name)
->assertSee('Needs triage')
->assertSee('Unassigned');
expect($component->instance()->summaryCounts())->toBe([
'visible_unassigned' => 4,
'visible_needs_triage' => 2,
'visible_overdue' => 1,
]);
$queueViews = collect($component->instance()->queueViews())->keyBy('key');
expect($queueViews['unassigned']['badge_count'])->toBe(4)
->and($queueViews['unassigned']['active'])->toBeTrue()
->and($queueViews['needs_triage']['badge_count'])->toBe(2)
->and($queueViews['needs_triage']['active'])->toBeFalse();
});
it('defaults to the active tenant prefilter and lets the operator clear it without dropping intake scope', function (): void {
[$user, $tenantA] = findingsIntakeActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$findingA = makeIntakeFinding($tenantA, [
'subject_external_id' => 'tenant-a',
'status' => Finding::STATUS_NEW,
]);
$findingB = makeIntakeFinding($tenantB, [
'subject_external_id' => 'tenant-b',
'status' => Finding::STATUS_TRIAGED,
]);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
]);
$component = findingsIntakePage($user)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertCanSeeTableRecords([$findingB])
->assertCanNotSeeTableRecords([$findingA])
->assertActionVisible('clear_tenant_filter');
expect($component->instance()->appliedScope())->toBe([
'workspace_scoped' => true,
'fixed_scope' => 'visible_unassigned_open_findings_only',
'queue_view' => 'unassigned',
'queue_view_label' => 'Unassigned',
'tenant_prefilter_source' => 'active_tenant_context',
'tenant_label' => $tenantB->name,
]);
$component->callAction('clear_tenant_filter')
->assertCanSeeTableRecords([$findingA, $findingB]);
expect($component->instance()->appliedScope())->toBe([
'workspace_scoped' => true,
'fixed_scope' => 'visible_unassigned_open_findings_only',
'queue_view' => 'unassigned',
'queue_view_label' => 'Unassigned',
'tenant_prefilter_source' => 'none',
'tenant_label' => null,
]);
});
it('keeps the needs triage view active when clearing the tenant prefilter', function (): void {
[$user, $tenantA] = findingsIntakeActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
$tenantATriage = makeIntakeFinding($tenantA, [
'subject_external_id' => 'tenant-a-triage',
'status' => Finding::STATUS_NEW,
]);
$tenantBTriage = makeIntakeFinding($tenantB, [
'subject_external_id' => 'tenant-b-triage',
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now()->subHour(),
]);
$tenantBBacklog = makeIntakeFinding($tenantB, [
'subject_external_id' => 'tenant-b-backlog',
'status' => Finding::STATUS_TRIAGED,
]);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
]);
$component = findingsIntakePage($user, ['view' => 'needs_triage'])
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->assertCanSeeTableRecords([$tenantBTriage])
->assertCanNotSeeTableRecords([$tenantATriage, $tenantBBacklog]);
expect($component->instance()->appliedScope())->toBe([
'workspace_scoped' => true,
'fixed_scope' => 'visible_unassigned_open_findings_only',
'queue_view' => 'needs_triage',
'queue_view_label' => 'Needs triage',
'tenant_prefilter_source' => 'active_tenant_context',
'tenant_label' => $tenantB->name,
]);
$component->callAction('clear_tenant_filter')
->assertCanSeeTableRecords([$tenantBTriage, $tenantATriage], inOrder: true)
->assertCanNotSeeTableRecords([$tenantBBacklog]);
expect($component->instance()->appliedScope())->toBe([
'workspace_scoped' => true,
'fixed_scope' => 'visible_unassigned_open_findings_only',
'queue_view' => 'needs_triage',
'queue_view_label' => 'Needs triage',
'tenant_prefilter_source' => 'none',
'tenant_label' => null,
]);
$queueViews = collect($component->instance()->queueViews())->keyBy('key');
expect($queueViews['unassigned']['active'])->toBeFalse()
->and($queueViews['needs_triage']['active'])->toBeTrue();
});
it('separates needs triage from the remaining backlog and keeps deterministic urgency ordering', function (): void {
[$user, $tenant] = findingsIntakeActingUser();
$overdue = makeIntakeFinding($tenant, [
'subject_external_id' => 'overdue',
'status' => Finding::STATUS_TRIAGED,
'due_at' => now()->subDay(),
]);
$reopened = makeIntakeFinding($tenant, [
'subject_external_id' => 'reopened',
'status' => Finding::STATUS_REOPENED,
'reopened_at' => now()->subHours(2),
'due_at' => now()->addDay(),
]);
$newFinding = makeIntakeFinding($tenant, [
'subject_external_id' => 'new-finding',
'status' => Finding::STATUS_NEW,
'due_at' => now()->addDays(2),
]);
$remainingBacklog = makeIntakeFinding($tenant, [
'subject_external_id' => 'remaining-backlog',
'status' => Finding::STATUS_TRIAGED,
'due_at' => now()->addHours(12),
]);
$undatedBacklog = makeIntakeFinding($tenant, [
'subject_external_id' => 'undated-backlog',
'status' => Finding::STATUS_IN_PROGRESS,
'due_at' => null,
]);
findingsIntakePage($user)
->assertCanSeeTableRecords([$overdue, $reopened, $newFinding, $remainingBacklog, $undatedBacklog], inOrder: true);
findingsIntakePage($user, ['view' => 'needs_triage'])
->assertCanSeeTableRecords([$reopened, $newFinding], inOrder: true)
->assertCanNotSeeTableRecords([$overdue, $remainingBacklog, $undatedBacklog]);
});
it('builds tenant detail drilldowns with intake continuity', function (): void {
[$user, $tenant] = findingsIntakeActingUser();
$finding = makeIntakeFinding($tenant, [
'subject_external_id' => 'continuity',
'status' => Finding::STATUS_NEW,
]);
$component = findingsIntakePage($user, [
'tenant' => (string) $tenant->external_id,
'view' => 'needs_triage',
]);
$detailUrl = $component->instance()->getTable()->getRecordUrl($finding);
expect($detailUrl)->toContain(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
->and($detailUrl)->toContain('nav%5Bback_label%5D=Back+to+findings+intake');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get($detailUrl)
->assertOk()
->assertSee('Back to findings intake');
});
it('renders both intake empty-state branches with the correct single recovery action', function (): void {
[$user, $tenantA] = findingsIntakeActingUser();
$tenantB = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Work Tenant',
]);
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
makeIntakeFinding($tenantB, [
'subject_external_id' => 'available-elsewhere',
]);
findingsIntakePage($user, [
'tenant' => (string) $tenantA->external_id,
])
->assertSee('No intake findings match this tenant scope')
->assertTableEmptyStateActionsExistInOrder(['clear_tenant_filter_empty']);
Finding::query()->delete();
session()->forget(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY);
Filament::setTenant(null, true);
findingsIntakePage($user)
->assertSee('Shared intake is clear')
->assertTableEmptyStateActionsExistInOrder(['open_my_findings_empty']);
});

View File

@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\User;
use App\Notifications\Findings\FindingEventNotification;
use App\Services\Findings\FindingNotificationService;
use App\Services\Findings\FindingWorkflowService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function latestFindingNotificationFor(User $user): ?\Illuminate\Notifications\DatabaseNotification
{
return $user->notifications()
->where('type', FindingEventNotification::class)
->latest('id')
->first();
}
function findingNotificationCountFor(User $user, string $eventType): int
{
return $user->notifications()
->where('type', FindingEventNotification::class)
->get()
->filter(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === $eventType)
->count();
}
function runEvaluateAlertsForWorkspace(int $workspaceId): void
{
$operationRun = OperationRun::factory()->create([
'workspace_id' => $workspaceId,
'tenant_id' => null,
'type' => 'alerts.evaluate',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
]);
$job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId, (int) $operationRun->getKey());
$job->handle(
app(\App\Services\Alerts\AlertDispatchService::class),
app(\App\Services\OperationRunService::class),
app(FindingNotificationService::class),
);
}
it('emits assignment notifications only when a new assignee is committed', function (): void {
[$owner, $tenant] = $this->actingAsFindingOperator();
$firstAssignee = User::factory()->create(['name' => 'First Assignee']);
createUserWithTenant(tenant: $tenant, user: $firstAssignee, role: 'operator');
$secondAssignee = User::factory()->create(['name' => 'Second Assignee']);
createUserWithTenant(tenant: $tenant, user: $secondAssignee, role: 'operator');
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_TRIAGED, [
'owner_user_id' => (int) $owner->getKey(),
]);
$workflow = app(FindingWorkflowService::class);
$workflow->assign(
finding: $finding,
tenant: $tenant,
actor: $owner,
assigneeUserId: (int) $firstAssignee->getKey(),
ownerUserId: (int) $owner->getKey(),
);
$firstNotification = latestFindingNotificationFor($firstAssignee);
expect($firstNotification)->not->toBeNull()
->and(data_get($firstNotification?->data, 'title'))->toBe('Finding assigned')
->and(data_get($firstNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED);
$workflow->assign(
finding: $finding->fresh(),
tenant: $tenant,
actor: $owner,
assigneeUserId: (int) $firstAssignee->getKey(),
ownerUserId: (int) $secondAssignee->getKey(),
);
$workflow->assign(
finding: $finding->fresh(),
tenant: $tenant,
actor: $owner,
assigneeUserId: null,
ownerUserId: (int) $secondAssignee->getKey(),
);
expect(findingNotificationCountFor($firstAssignee, AlertRule::EVENT_FINDINGS_ASSIGNED))->toBe(1);
$workflow->assign(
finding: $finding->fresh(),
tenant: $tenant,
actor: $owner,
assigneeUserId: (int) $secondAssignee->getKey(),
ownerUserId: (int) $secondAssignee->getKey(),
);
$secondNotification = latestFindingNotificationFor($secondAssignee);
expect($secondNotification)->not->toBeNull()
->and(data_get($secondNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
->and(findingNotificationCountFor($secondAssignee, AlertRule::EVENT_FINDINGS_ASSIGNED))->toBe(1);
});
it('dedupes repeated reopen dispatches for the same reopen occurrence', function (): void {
$now = CarbonImmutable::parse('2026-04-22T09:30:00Z');
CarbonImmutable::setTestNow($now);
[$owner, $tenant] = $this->actingAsFindingOperator();
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED, [
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
]);
$reopened = app(FindingWorkflowService::class)->reopenBySystem(
finding: $finding,
tenant: $tenant,
reopenedAt: $now,
);
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_REOPENED))->toBe(1);
app(FindingNotificationService::class)->dispatch($reopened->fresh(), AlertRule::EVENT_FINDINGS_REOPENED);
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_REOPENED))->toBe(1);
});
it('sends due soon and overdue notifications once per due cycle and resets when due_at changes', function (): void {
$now = CarbonImmutable::parse('2026-04-22T10:00:00Z');
CarbonImmutable::setTestNow($now);
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$workspaceId = (int) $tenant->workspace_id;
Filament::setTenant($tenant, true);
$this->actingAs($owner);
$assignee = User::factory()->create(['name' => 'Due Soon Operator']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$dueSoonFinding = Finding::factory()->for($tenant)->create([
'workspace_id' => $workspaceId,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
'due_at' => $now->addHours(6),
]);
$overdueFinding = Finding::factory()->for($tenant)->create([
'workspace_id' => $workspaceId,
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => null,
'due_at' => $now->subHours(2),
]);
$closedFinding = Finding::factory()->for($tenant)->closed()->create([
'workspace_id' => $workspaceId,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
'due_at' => $now->subHours(1),
]);
runEvaluateAlertsForWorkspace($workspaceId);
runEvaluateAlertsForWorkspace($workspaceId);
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(1)
->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1);
expect($assignee->notifications()
->where('type', FindingEventNotification::class)
->get()
->contains(fn ($notification): bool => (int) data_get($notification->data, 'finding_event.finding_id') === (int) $closedFinding->getKey()))
->toBeFalse();
$dueSoonFinding->forceFill([
'due_at' => $now->addHours(12),
])->save();
$overdueFinding->forceFill([
'due_at' => $now->addDay()->subHour(),
])->save();
runEvaluateAlertsForWorkspace($workspaceId);
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(2)
->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1);
$dueSoonFinding->forceFill([
'due_at' => $now->addDays(5),
])->save();
CarbonImmutable::setTestNow($now->addDays(2));
$overdueFinding->forceFill([
'due_at' => CarbonImmutable::now('UTC')->subHour(),
])->save();
runEvaluateAlertsForWorkspace($workspaceId);
expect(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(2);
});

View File

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\TenantMembership;
use App\Models\User;
use App\Notifications\Findings\FindingEventNotification;
use App\Services\Auth\CapabilityResolver;
use App\Services\Findings\FindingNotificationService;
use App\Services\Findings\FindingWorkflowService;
use Carbon\CarbonImmutable;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Collection
{
return $user->notifications()
->where('type', FindingEventNotification::class)
->orderBy('id')
->get();
}
it('uses the documented recipient precedence for assignment reopen due soon and overdue', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$service = app(FindingNotificationService::class);
$assignee = User::factory()->create(['name' => 'Assignee']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
'due_at' => now()->addHours(6),
]);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
expect(dispatchedFindingNotificationsFor($assignee)
->pluck('data.finding_event.event_type')
->all())
->toContain(AlertRule::EVENT_FINDINGS_ASSIGNED, AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
expect(dispatchedFindingNotificationsFor($owner)
->pluck('data.finding_event.event_type')
->all())
->toContain(AlertRule::EVENT_FINDINGS_OVERDUE);
$fallbackFinding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_REOPENED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => null,
'due_at' => now()->addHours(2),
]);
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_REOPENED);
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_DUE_SOON);
$ownerEventTypes = dispatchedFindingNotificationsFor($owner)
->pluck('data.finding_event.event_type')
->all();
expect($ownerEventTypes)->toContain(AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
});
it('suppresses direct delivery when the preferred recipient loses tenant access', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Removed Assignee']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_REOPENED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
]);
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $assignee->getKey())
->delete();
app(CapabilityResolver::class)->clearCache();
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
expect($result['direct_delivery_status'])->toBe('suppressed')
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
});
it('does not broaden delivery to the owner when the assignee is present but no longer entitled', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Current Assignee']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
'due_at' => now()->addHours(3),
]);
$resolver = \Mockery::mock(CapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')
->andReturnUsing(function (User $user): bool {
return $user->name !== 'Current Assignee';
});
app()->instance(CapabilityResolver::class, $resolver);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
expect($result['direct_delivery_status'])->toBe('suppressed')
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
});
it('suppresses owner-only assignment edits and assignee clears from creating direct notifications', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$replacementOwner = User::factory()->create(['name' => 'Replacement Owner']);
createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
]);
$workflow = app(FindingWorkflowService::class);
$workflow->assign(
finding: $finding,
tenant: $tenant,
actor: $owner,
assigneeUserId: (int) $assignee->getKey(),
ownerUserId: (int) $replacementOwner->getKey(),
);
$workflow->assign(
finding: $finding->fresh(),
tenant: $tenant,
actor: $owner,
assigneeUserId: null,
ownerUserId: (int) $replacementOwner->getKey(),
);
expect(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($replacementOwner))->toHaveCount(0);
});
it('suppresses due notifications for terminal findings', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->closed()->create([
'workspace_id' => (int) $tenant->workspace_id,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $owner->getKey(),
'due_at' => now()->subHour(),
]);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
expect($result['direct_delivery_status'])->toBe('suppressed')
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
});
it('sends one direct notification when owner and assignee are the same entitled user', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $owner->getKey(),
'due_at' => now()->subHour(),
]);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
expect($result['direct_delivery_status'])->toBe('sent')
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(1)
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.finding_event.recipient_reason'))->toBe('current_owner');
});

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\TenantMembership;
use App\Models\User;
use App\Notifications\Findings\FindingEventNotification;
use App\Services\Auth\CapabilityResolver;
use App\Services\Findings\FindingNotificationService;
use App\Support\Auth\Capabilities;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Gate;
it('stores a filament payload with one tenant finding deep link and recipient reason copy', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
]);
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
$notification = $assignee->notifications()
->where('type', FindingEventNotification::class)
->latest('id')
->first();
expect($notification)->not->toBeNull()
->and(data_get($notification?->data, 'format'))->toBe('filament')
->and(data_get($notification?->data, 'title'))->toBe('Finding assigned')
->and(data_get($notification?->data, 'actions.0.label'))->toBe('Open finding')
->and(data_get($notification?->data, 'actions.0.url'))
->toBe(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
->and(data_get($notification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
->and(data_get($notification?->data, 'finding_event.recipient_reason'))->toBe('new_assignee');
});
it('returns 404 when a finding notification link is opened after tenant access is removed', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Removed Operator']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
]);
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
$url = data_get($assignee->notifications()->latest('id')->first(), 'data.actions.0.url');
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $assignee->getKey())
->delete();
app(CapabilityResolver::class)->clearCache();
$this->actingAs($assignee)
->get($url)
->assertNotFound();
});
it('returns 403 when a finding notification link is opened by an in-scope member without findings view capability', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Scoped Operator']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
]);
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
$url = data_get($assignee->notifications()->latest('id')->first(), 'data.actions.0.url');
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false);
$this->actingAs($assignee);
Filament::setTenant($tenant, true);
$this->get($url)->assertForbidden();
});

View File

@ -1,11 +1,13 @@
import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import icon from 'astro-icon';
import { defineConfig } from 'astro/config';
const publicSiteUrl = process.env.PUBLIC_SITE_URL ?? 'https://tenantatlas.example';
export default defineConfig({
integrations: [icon()],
output: 'static',
site: publicSiteUrl,
server: {

View File

@ -14,7 +14,9 @@
"test:smoke": "playwright test"
},
"dependencies": {
"astro": "^6.0.0"
"@iconify-json/lucide": "^1.2.102",
"astro": "^6.0.0",
"astro-icon": "^1.1.5"
},
"devDependencies": {
"@playwright/test": "^1.59.1",

View File

@ -1,81 +1,70 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500">
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500" fill="none">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f8fafc;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e2e8f0;stop-opacity:1" />
<linearGradient id="bg" x1="60" y1="40" x2="740" y2="460" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#F8FAFC" />
<stop offset="1" stop-color="#E2E8F0" />
</linearGradient>
<linearGradient id="header" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#2f6fb7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#8eaed1;stop-opacity:1" />
<linearGradient id="topbar" x1="0" y1="0" x2="800" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1D4ED8" />
<stop offset="1" stop-color="#4F46E5" />
</linearGradient>
</defs>
<rect width="800" height="500" rx="16" fill="url(#bg)" stroke="#e2e8f0" stroke-width="2"/>
<!-- Header bar -->
<rect x="0" y="0" width="800" height="48" rx="16" fill="url(#header)"/>
<rect x="0" y="16" width="800" height="32" fill="url(#header)"/>
<text x="24" y="30" font-family="system-ui" font-size="14" fill="white" font-weight="600">TenantAtlas</text>
<!-- Sidebar -->
<rect x="0" y="48" width="180" height="452" fill="#f1f5f9"/>
<rect x="16" y="68" width="148" height="32" rx="8" fill="#2f6fb7" opacity="0.12"/>
<text x="28" y="89" font-family="system-ui" font-size="12" fill="#2f6fb7" font-weight="600">Inventory</text>
<rect x="16" y="112" width="148" height="28" rx="8" fill="transparent"/>
<text x="28" y="130" font-family="system-ui" font-size="12" fill="#64748b">Backup History</text>
<rect x="16" y="148" width="148" height="28" rx="8" fill="transparent"/>
<text x="28" y="166" font-family="system-ui" font-size="12" fill="#64748b">Restore</text>
<rect x="16" y="184" width="148" height="28" rx="8" fill="transparent"/>
<text x="28" y="202" font-family="system-ui" font-size="12" fill="#64748b">Drift Detection</text>
<rect x="16" y="220" width="148" height="28" rx="8" fill="transparent"/>
<text x="28" y="238" font-family="system-ui" font-size="12" fill="#64748b">Governance</text>
<!-- Main content area -->
<rect x="196" y="64" width="588" height="420" rx="12" fill="white" stroke="#e2e8f0"/>
<!-- Stats row -->
<rect x="212" y="80" width="130" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
<text x="228" y="104" font-family="system-ui" font-size="22" fill="#1e293b" font-weight="700">247</text>
<text x="228" y="130" font-family="system-ui" font-size="11" fill="#64748b">Policies tracked</text>
<rect x="358" y="80" width="130" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
<text x="374" y="104" font-family="system-ui" font-size="22" fill="#16a34a" font-weight="700">98.4%</text>
<text x="374" y="130" font-family="system-ui" font-size="11" fill="#64748b">Compliance rate</text>
<rect x="504" y="80" width="130" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
<text x="520" y="104" font-family="system-ui" font-size="22" fill="#2f6fb7" font-weight="700">12</text>
<text x="520" y="130" font-family="system-ui" font-size="11" fill="#64748b">Versions stored</text>
<rect x="650" y="80" width="118" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
<text x="666" y="104" font-family="system-ui" font-size="22" fill="#f59e0b" font-weight="700">3</text>
<text x="666" y="130" font-family="system-ui" font-size="11" fill="#64748b">Drift alerts</text>
<!-- Table header -->
<rect x="212" y="160" width="556" height="36" rx="0" fill="#f8fafc"/>
<text x="228" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">POLICY NAME</text>
<text x="440" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">TYPE</text>
<text x="560" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">STATUS</text>
<text x="670" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">LAST BACKUP</text>
<!-- Table rows -->
<line x1="212" y1="196" x2="768" y2="196" stroke="#e2e8f0"/>
<text x="228" y="220" font-family="system-ui" font-size="12" fill="#1e293b">Windows Compliance Baseline</text>
<text x="440" y="220" font-family="system-ui" font-size="12" fill="#64748b">Compliance</text>
<rect x="560" y="208" width="56" height="20" rx="10" fill="#dcfce7"/>
<text x="572" y="222" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
<text x="670" y="220" font-family="system-ui" font-size="12" fill="#64748b">2 min ago</text>
<line x1="212" y1="236" x2="768" y2="236" stroke="#f1f5f9"/>
<text x="228" y="260" font-family="system-ui" font-size="12" fill="#1e293b">BitLocker Encryption Policy</text>
<text x="440" y="260" font-family="system-ui" font-size="12" fill="#64748b">Config</text>
<rect x="560" y="248" width="56" height="20" rx="10" fill="#dcfce7"/>
<text x="572" y="262" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
<text x="670" y="260" font-family="system-ui" font-size="12" fill="#64748b">15 min ago</text>
<line x1="212" y1="276" x2="768" y2="276" stroke="#f1f5f9"/>
<text x="228" y="300" font-family="system-ui" font-size="12" fill="#1e293b">Conditional Access MFA</text>
<text x="440" y="300" font-family="system-ui" font-size="12" fill="#64748b">Access</text>
<rect x="560" y="288" width="48" height="20" rx="10" fill="#fef3c7"/>
<text x="569" y="302" font-family="system-ui" font-size="10" fill="#d97706" font-weight="500">Drift</text>
<text x="670" y="300" font-family="system-ui" font-size="12" fill="#64748b">1 hr ago</text>
<line x1="212" y1="316" x2="768" y2="316" stroke="#f1f5f9"/>
<text x="228" y="340" font-family="system-ui" font-size="12" fill="#1e293b">Autopilot Deployment Profile</text>
<text x="440" y="340" font-family="system-ui" font-size="12" fill="#64748b">Enrollment</text>
<rect x="560" y="328" width="56" height="20" rx="10" fill="#dcfce7"/>
<text x="572" y="342" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
<text x="670" y="340" font-family="system-ui" font-size="12" fill="#64748b">30 min ago</text>
<line x1="212" y1="356" x2="768" y2="356" stroke="#f1f5f9"/>
<text x="228" y="380" font-family="system-ui" font-size="12" fill="#1e293b">App Protection iOS Managed</text>
<text x="440" y="380" font-family="system-ui" font-size="12" fill="#64748b">Protection</text>
<rect x="560" y="368" width="56" height="20" rx="10" fill="#dcfce7"/>
<text x="572" y="382" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
<text x="670" y="380" font-family="system-ui" font-size="12" fill="#64748b">45 min ago</text>
<rect x="8" y="8" width="784" height="484" rx="20" fill="url(#bg)" stroke="#D7E2EE" stroke-width="2" />
<rect x="8" y="8" width="784" height="52" rx="20" fill="url(#topbar)" />
<rect x="8" y="30" width="784" height="30" fill="url(#topbar)" />
<text x="32" y="40" font-family="system-ui" font-size="15" font-weight="700" fill="#FFFFFF">TenantAtlas</text>
<circle cx="742" cy="34" r="6" fill="#FFFFFF" fill-opacity="0.7" />
<circle cx="762" cy="34" r="6" fill="#FFFFFF" fill-opacity="0.45" />
<rect x="24" y="76" width="168" height="396" rx="18" fill="#F3F6FB" stroke="#D7E2EE" />
<text x="44" y="108" font-family="system-ui" font-size="11" font-weight="700" fill="#64748B">WORKSPACE</text>
<rect x="36" y="128" width="144" height="34" rx="10" fill="#DBEAFE" />
<text x="52" y="149" font-family="system-ui" font-size="12" font-weight="700" fill="#1D4ED8">Change history</text>
<text x="52" y="194" font-family="system-ui" font-size="12" fill="#475569">Restore preview</text>
<text x="52" y="230" font-family="system-ui" font-size="12" fill="#475569">Review queue</text>
<text x="52" y="266" font-family="system-ui" font-size="12" fill="#475569">Evidence</text>
<text x="52" y="302" font-family="system-ui" font-size="12" fill="#475569">Assignments</text>
<rect x="36" y="344" width="144" height="96" rx="14" fill="#FFFFFF" stroke="#D7E2EE" />
<text x="52" y="370" font-family="system-ui" font-size="11" font-weight="700" fill="#334155">Current tenant</text>
<text x="52" y="394" font-family="system-ui" font-size="12" fill="#0F172A">Northwind Services</text>
<text x="52" y="418" font-family="system-ui" font-size="11" fill="#64748B">Inventory linked to reviewable history</text>
<rect x="212" y="76" width="564" height="396" rx="18" fill="#FFFFFF" stroke="#D7E2EE" />
<text x="236" y="108" font-family="system-ui" font-size="12" font-weight="700" fill="#334155">Recent tenant changes</text>
<rect x="236" y="124" width="90" height="24" rx="12" fill="#EFF6FF" />
<text x="252" y="140" font-family="system-ui" font-size="10" font-weight="700" fill="#2563EB">Policies</text>
<rect x="336" y="124" width="82" height="24" rx="12" fill="#F8FAFC" stroke="#D7E2EE" />
<text x="356" y="140" font-family="system-ui" font-size="10" font-weight="700" fill="#475569">Drift</text>
<rect x="428" y="124" width="108" height="24" rx="12" fill="#F8FAFC" stroke="#D7E2EE" />
<text x="448" y="140" font-family="system-ui" font-size="10" font-weight="700" fill="#475569">Assignments</text>
<rect x="236" y="164" width="320" height="236" rx="16" fill="#F8FAFC" stroke="#D7E2EE" />
<text x="256" y="192" font-family="system-ui" font-size="11" font-weight="700" fill="#64748B">CHANGE RECORD</text>
<line x1="256" y1="210" x2="536" y2="210" stroke="#E2E8F0" />
<text x="256" y="236" font-family="system-ui" font-size="13" font-weight="700" fill="#0F172A">Windows Compliance Baseline</text>
<text x="256" y="256" font-family="system-ui" font-size="11" fill="#475569">Version diff prepared for review</text>
<rect x="454" y="222" width="78" height="22" rx="11" fill="#DCFCE7" />
<text x="469" y="237" font-family="system-ui" font-size="10" font-weight="700" fill="#15803D">Ready</text>
<line x1="256" y1="274" x2="536" y2="274" stroke="#E2E8F0" />
<text x="256" y="300" font-family="system-ui" font-size="13" font-weight="700" fill="#0F172A">Conditional Access MFA</text>
<text x="256" y="320" font-family="system-ui" font-size="11" fill="#475569">Assignment drift surfaced before rollout</text>
<rect x="438" y="286" width="94" height="22" rx="11" fill="#FEF3C7" />
<text x="452" y="301" font-family="system-ui" font-size="10" font-weight="700" fill="#B45309">Needs review</text>
<line x1="256" y1="338" x2="536" y2="338" stroke="#E2E8F0" />
<text x="256" y="364" font-family="system-ui" font-size="13" font-weight="700" fill="#0F172A">BitLocker policy</text>
<text x="256" y="384" font-family="system-ui" font-size="11" fill="#475569">Restore candidate linked to prior snapshot</text>
<rect x="420" y="350" width="112" height="22" rx="11" fill="#DBEAFE" />
<text x="436" y="365" font-family="system-ui" font-size="10" font-weight="700" fill="#1D4ED8">Snapshot linked</text>
<rect x="576" y="164" width="176" height="112" rx="16" fill="#F8FAFC" stroke="#D7E2EE" />
<text x="596" y="192" font-family="system-ui" font-size="11" font-weight="700" fill="#64748B">RESTORE PREVIEW</text>
<text x="596" y="220" font-family="system-ui" font-size="12" fill="#0F172A">Scope validated</text>
<text x="596" y="242" font-family="system-ui" font-size="12" fill="#0F172A">Assignments included</text>
<text x="596" y="264" font-family="system-ui" font-size="12" fill="#0F172A">Confirmation required</text>
<circle cx="580" cy="217" r="4" fill="#16A34A" />
<circle cx="580" cy="239" r="4" fill="#16A34A" />
<circle cx="580" cy="261" r="4" fill="#F59E0B" />
<rect x="576" y="292" width="176" height="108" rx="16" fill="#F8FAFC" stroke="#D7E2EE" />
<text x="596" y="320" font-family="system-ui" font-size="11" font-weight="700" fill="#64748B">REVIEW QUEUE</text>
<text x="596" y="346" font-family="system-ui" font-size="12" fill="#0F172A">Conditional Access drift</text>
<text x="596" y="368" font-family="system-ui" font-size="12" fill="#0F172A">Restore plan awaiting approval</text>
<text x="596" y="390" font-family="system-ui" font-size="12" fill="#0F172A">Evidence attached to change record</text>
<rect x="236" y="420" width="516" height="28" rx="14" fill="#EEF2FF" />
<text x="256" y="438" font-family="system-ui" font-size="11" font-weight="700" fill="#4338CA">Change history, restore preview, and review queue stay connected on one screen.</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -11,9 +11,11 @@ interface Props {
const { content } = Astro.props;
const variant = content.tone === 'accent' ? 'accent' : content.tone === 'subtle' ? 'subtle' : 'default';
const barTone = content.tone === 'accent' ? undefined : content.tone === 'subtle' ? 'trust' : undefined;
---
<Card variant={variant}>
<Card variant={variant} hoverable>
<div class="callout-bar" data-bar-tone={barTone}>
{content.eyebrow && <Eyebrow>{content.eyebrow}</Eyebrow>}
<Headline as="h3" size="card" class="mt-4">
{content.title}
@ -21,4 +23,5 @@ const variant = content.tone === 'accent' ? 'accent' : content.tone === 'subtle'
<Lead class="mt-3" size="body">
{content.description}
</Lead>
</div>
</Card>

View File

@ -1,4 +1,5 @@
---
import { Icon } from 'astro-icon/components';
import Card from '@/components/primitives/Card.astro';
import Eyebrow from '@/components/content/Eyebrow.astro';
import Headline from '@/components/content/Headline.astro';
@ -10,18 +11,49 @@ interface Props {
}
const { item } = Astro.props;
const lucideMap: Record<string, string> = {
'shield': 'lucide:shield',
'database': 'lucide:database',
'refresh': 'lucide:refresh-cw',
'eye': 'lucide:eye',
'file-check': 'lucide:file-check',
'layers': 'lucide:layers',
'search': 'lucide:search',
'lock': 'lucide:lock',
'zap': 'lucide:zap',
'clipboard': 'lucide:clipboard-list',
'git-branch': 'lucide:git-branch',
'bar-chart': 'lucide:bar-chart-3',
'activity': 'lucide:activity',
'settings': 'lucide:settings',
'globe': 'lucide:globe',
'users': 'lucide:users',
'check-circle': 'lucide:check-circle',
'archive': 'lucide:archive',
'trending-up': 'lucide:trending-up',
'cpu': 'lucide:cpu',
};
const iconName = item.icon ? lucideMap[item.icon] : undefined;
---
<Card class="h-full">
<Card class="h-full" hoverable>
<div class="space-y-3">
{iconName && (
<div class="feature-icon">
<Icon name={iconName} size={20} />
</div>
)}
{item.eyebrow && <Eyebrow>{item.eyebrow}</Eyebrow>}
<Headline as="h3" size="card" class="mt-4">
<Headline as="h3" size="card">
{item.title}
</Headline>
<Lead class="mt-3" size="body">
<Lead size="body">
{item.description}
</Lead>
{(item.meta || item.href) && (
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm">
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm">
{item.meta && <span class="text-[var(--color-brand)]">{item.meta}</span>}
{item.href && (
<a class="text-link font-semibold" href={item.href}>
@ -30,4 +62,5 @@ const { item } = Astro.props;
)}
</div>
)}
</div>
</Card>

View File

@ -9,14 +9,14 @@ const { as = 'h2', class: className = '', size = 'section' } = Astro.props;
const Tag = as;
const sizeClasses = {
display:
'font-[var(--font-display)] text-[length:var(--type-display-size)] leading-[var(--line-display)] tracking-[var(--tracking-display)]',
page: 'font-[var(--font-display)] text-[length:var(--type-page-size)] leading-[var(--line-heading)] tracking-[var(--tracking-tight)]',
'font-[var(--font-display)] font-bold text-[length:var(--type-display-size)] leading-[var(--line-display)] tracking-[var(--tracking-display)]',
page: 'font-[var(--font-display)] font-semibold text-[length:var(--type-page-size)] leading-[var(--line-heading)] tracking-[var(--tracking-tight)]',
section:
'font-[var(--font-display)] text-[length:var(--type-section-size)] leading-[var(--line-heading)] tracking-[var(--tracking-tight)]',
card: 'font-semibold text-[length:var(--type-card-size)] leading-[1.12] tracking-[var(--tracking-tight)]',
'font-[var(--font-display)] font-semibold text-[length:var(--type-section-size)] leading-[var(--line-heading)] tracking-[var(--tracking-tight)]',
card: 'font-medium text-[length:var(--type-card-size)] leading-[1.18] tracking-[var(--tracking-tight)]',
};
---
<Tag class:list={['m-0 text-[var(--color-ink-900)]', sizeClasses[size], className]}>
<Tag class:list={['m-0 text-[var(--color-ink-900)] [&>.accent]:text-[var(--color-primary)]', sizeClasses[size], className]}>
<slot />
</Tag>

View File

@ -0,0 +1,467 @@
---
---
<div
class="hero-dashboard"
role="img"
aria-label="TenantAtlas — change history, restore preview, and a review queue with baseline drift and evidence links"
>
<div class="dashboard-chrome">
<div class="dashboard-titlebar">
<div class="flex items-center gap-2">
<div class="flex gap-1.5">
<span class="dot dot-red"></span>
<span class="dot dot-yellow"></span>
<span class="dot dot-green"></span>
</div>
<span class="titlebar-label">TenantAtlas — Governance Surface</span>
</div>
<div class="titlebar-url">
<span class="url-text">app.tenantatlas.com/admin/governance/change-history</span>
</div>
</div>
<div class="dashboard-body">
<div class="dashboard-sidebar">
<div class="sidebar-logo">TA</div>
<div class="sidebar-nav">
<div class="nav-item active">
<div class="nav-dot"></div>
<span>Change history</span>
</div>
<div class="nav-item">
<div class="nav-dot"></div>
<span>Restore preview</span>
</div>
<div class="nav-item">
<div class="nav-dot"></div>
<span>Review queue</span>
</div>
<div class="nav-item">
<div class="nav-dot"></div>
<span>Evidence</span>
</div>
<div class="nav-item">
<div class="nav-dot"></div>
<span>Assignments</span>
</div>
</div>
<div class="sidebar-divider"></div>
<div class="sidebar-nav">
<div class="nav-item">
<div class="nav-dot muted"></div>
<span>Settings</span>
</div>
<div class="nav-item">
<div class="nav-dot muted"></div>
<span>Audit Log</span>
</div>
</div>
</div>
<div class="dashboard-main">
<div class="stats-row">
<div class="stat-card">
<span class="stat-label">Baseline drift</span>
<span class="stat-value">2 items need review</span>
<span class="stat-change neutral">Windows baseline and Conditional Access</span>
</div>
<div class="stat-card">
<span class="stat-label">Restore preview</span>
<span class="stat-value">Scope and assignments validated</span>
<span class="stat-change positive">Confirmation required before execution</span>
</div>
<div class="stat-card">
<span class="stat-label">Evidence linked</span>
<span class="stat-value">3 change records attached</span>
<span class="stat-change neutral">Review context stays with the operator path</span>
</div>
</div>
<div class="activity-grid">
<div class="activity-section">
<div class="activity-header">
<span class="activity-title">Change record</span>
<span class="activity-badge">Review active</span>
</div>
<div class="activity-table">
<div class="table-row">
<span class="row-status warning"></span>
<span class="row-name">Windows Compliance Baseline</span>
<span class="row-type">Baseline drift</span>
<span class="row-time">Needs review</span>
</div>
<div class="table-row">
<span class="row-status success"></span>
<span class="row-name">BitLocker policy restore</span>
<span class="row-type">Restore preview</span>
<span class="row-time">Assignments in scope</span>
</div>
<div class="table-row">
<span class="row-status success"></span>
<span class="row-name">Conditional Access MFA</span>
<span class="row-type">Evidence linked</span>
<span class="row-time">Ready for approval</span>
</div>
</div>
</div>
<div class="queue-column">
<div class="queue-card">
<span class="queue-label">Restore preview</span>
<p class="queue-title">Scope and assignment edges stay visible before execution.</p>
<ul class="queue-list">
<li>Scope validated</li>
<li>Assignments included</li>
<li>Confirmation required</li>
</ul>
</div>
<div class="queue-card">
<span class="queue-label">Review queue</span>
<ul class="queue-list">
<li>Baseline drift awaiting reviewer</li>
<li>Restore plan queued for approval</li>
<li>Evidence pack linked to the change record</li>
</ul>
</div>
</div>
</div>
<div class="evidence-strip">Evidence stays linked to the change record before an operator acts.</div>
</div>
</div>
</div>
</div>
<style>
.hero-dashboard {
border-radius: 1rem;
overflow: hidden;
border: 1px solid rgba(20, 20, 20, 0.08);
box-shadow:
0 24px 80px rgba(20, 20, 20, 0.1),
0 8px 24px rgba(0, 0, 0, 0.05);
background: white;
}
.dashboard-chrome {
display: flex;
flex-direction: column;
}
.dashboard-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.65rem 1rem;
background: #fafafa;
border-bottom: 1px solid rgba(20, 20, 20, 0.06);
}
.dot {
display: block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.dot-red { background: #ff5f57; }
.dot-yellow { background: #febc2e; }
.dot-green { background: #28c840; }
.titlebar-label {
font-size: 0.7rem;
font-weight: 500;
color: rgba(20, 20, 20, 0.45);
margin-left: 0.5rem;
}
.titlebar-url {
font-size: 0.65rem;
color: rgba(20, 20, 20, 0.35);
background: rgba(20, 20, 20, 0.04);
padding: 0.2rem 0.6rem;
border-radius: 0.25rem;
}
.url-text {
font-family: var(--font-mono);
}
.dashboard-body {
display: grid;
grid-template-columns: 140px 1fr;
min-height: 260px;
}
.dashboard-sidebar {
padding: 0.75rem;
background: #f8f9fb;
border-right: 1px solid rgba(20, 20, 20, 0.05);
}
.sidebar-logo {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
background: var(--color-ink-900);
color: white;
font-size: 0.6rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.sidebar-divider {
height: 1px;
background: rgba(20, 20, 20, 0.06);
margin: 0.5rem 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.4rem;
font-size: 0.65rem;
color: rgba(20, 20, 20, 0.5);
border-radius: 0.3rem;
}
.nav-item.active {
background: rgba(40, 60, 120, 0.06);
color: var(--color-ink-900);
font-weight: 600;
}
.nav-dot {
width: 0.35rem;
height: 0.35rem;
border-radius: 50%;
background: var(--color-brand-500);
opacity: 0.6;
}
.nav-dot.muted {
background: rgba(20, 20, 20, 0.18);
}
.nav-item.active .nav-dot {
opacity: 1;
}
.dashboard-main {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.5rem 0.6rem;
border-radius: 0.5rem;
border: 1px solid rgba(20, 20, 20, 0.05);
background: #fbfbfd;
}
.stat-label {
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(20, 20, 20, 0.45);
font-weight: 500;
}
.stat-value {
font-size: 0.94rem;
font-weight: 700;
color: var(--color-ink-900);
letter-spacing: -0.02em;
line-height: 1.25;
}
.stat-change {
font-size: 0.55rem;
font-weight: 500;
line-height: 1.35;
}
.stat-change.positive {
color: var(--color-mint-700);
}
.stat-change.neutral {
color: rgba(20, 20, 20, 0.4);
}
.activity-section {
border: 1px solid rgba(20, 20, 20, 0.05);
border-radius: 0.5rem;
overflow: hidden;
}
.activity-grid {
display: grid;
grid-template-columns: minmax(0, 1.28fr) minmax(14rem, 0.92fr);
gap: 0.5rem;
}
.activity-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.45rem 0.6rem;
background: #fafafa;
border-bottom: 1px solid rgba(20, 20, 20, 0.05);
}
.activity-title {
font-size: 0.65rem;
font-weight: 600;
color: var(--color-ink-900);
}
.activity-badge {
font-size: 0.55rem;
font-weight: 600;
background: rgba(40, 60, 120, 0.07);
color: var(--color-brand-500);
padding: 0.1rem 0.4rem;
border-radius: 999px;
}
.activity-table {
display: flex;
flex-direction: column;
}
.queue-column {
display: grid;
gap: 0.5rem;
}
.queue-card {
border: 1px solid rgba(20, 20, 20, 0.05);
border-radius: 0.5rem;
background: #fbfbfd;
padding: 0.65rem 0.7rem;
}
.queue-label {
display: inline-flex;
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(20, 20, 20, 0.42);
}
.queue-title {
margin: 0.45rem 0 0;
font-size: 0.72rem;
font-weight: 600;
line-height: 1.45;
color: var(--color-ink-900);
}
.queue-list {
margin: 0.55rem 0 0;
padding-left: 1rem;
display: grid;
gap: 0.35rem;
font-size: 0.64rem;
line-height: 1.45;
color: rgba(20, 20, 20, 0.56);
}
.evidence-strip {
border-radius: 0.5rem;
background: rgba(40, 60, 120, 0.06);
color: var(--color-ink-800);
font-size: 0.64rem;
font-weight: 600;
line-height: 1.4;
padding: 0.5rem 0.65rem;
}
.table-row {
display: grid;
grid-template-columns: 0.5rem 1fr auto auto;
gap: 0.5rem;
align-items: center;
padding: 0.4rem 0.6rem;
font-size: 0.6rem;
border-bottom: 1px solid rgba(20, 20, 20, 0.04);
}
.table-row:last-child {
border-bottom: none;
}
.row-status {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
}
.row-status.success {
background: var(--color-mint-500);
}
.row-status.warning {
background: #febc2e;
}
.row-name {
font-weight: 500;
color: var(--color-ink-900);
}
.row-type {
color: rgba(20, 20, 20, 0.4);
font-weight: 500;
}
.row-time {
color: rgba(20, 20, 20, 0.35);
}
@media (max-width: 640px) {
.dashboard-body {
grid-template-columns: 1fr;
}
.dashboard-sidebar {
display: none;
}
.stats-row {
grid-template-columns: 1fr;
}
.titlebar-url {
display: none;
}
.activity-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -11,7 +11,8 @@ interface Props {
const { item } = Astro.props;
---
<Card class="h-full">
<Card class="h-full" hoverable>
<div class="callout-bar" data-bar-tone="trust">
<Headline as="h3" size="card">
{item.title}
</Headline>
@ -19,4 +20,5 @@ const { item } = Astro.props;
{item.description}
</Lead>
{item.note && <Lead class="mt-4 text-[var(--color-brand)]" size="small">{item.note}</Lead>}
</div>
</Card>

View File

@ -24,19 +24,19 @@ const {
} = Astro.props;
const baseClass =
'inline-flex items-center justify-center rounded-[var(--radius-pill)] border font-semibold tracking-[var(--tracking-tight)] transition duration-150';
'inline-flex items-center justify-center rounded-[var(--radius-pill)] border font-semibold tracking-[var(--tracking-tight)] transition-all duration-200 cursor-pointer';
const sizeClasses = {
sm: 'min-h-10 px-4 text-sm',
md: 'min-h-11 px-5 text-sm sm:text-[0.95rem]',
lg: 'min-h-12 px-6 text-[0.97rem]',
sm: 'min-h-10 px-5 text-sm',
md: 'min-h-12 px-6 text-[0.95rem]',
lg: 'min-h-14 px-8 text-base',
};
const variantClasses = {
primary:
'border-transparent bg-[var(--color-primary)] text-[var(--color-primary-foreground)] shadow-[var(--shadow-inline)] hover:bg-[var(--color-brand-700)]',
'border-transparent bg-[var(--color-ink-900)] text-white shadow-[0_2px_8px_rgba(0,0,0,0.12)] hover:bg-[var(--color-ink-800)] hover:shadow-[0_4px_16px_rgba(0,0,0,0.18)] active:scale-[0.98]',
secondary:
'border-[color:var(--color-border)] bg-[var(--color-secondary)] text-[var(--color-secondary-foreground)] hover:border-[var(--color-border-strong)] hover:bg-white',
'border-[color:var(--color-border)] bg-white text-[var(--color-secondary-foreground)] hover:border-[var(--color-border-strong)] hover:bg-[var(--surface-muted)] active:scale-[0.98]',
ghost: 'border-transparent bg-transparent text-[var(--color-ink-800)] hover:bg-white/70',
};

View File

@ -2,11 +2,12 @@
interface Props {
as?: keyof HTMLElementTagNameMap;
class?: string;
hoverable?: boolean;
variant?: 'accent' | 'default' | 'subtle';
[key: string]: unknown;
}
const { as = 'article', class: className = '', variant = 'default', ...rest } = Astro.props;
const { as = 'article', class: className = '', hoverable = false, variant = 'default', ...rest } = Astro.props;
const variantClasses = {
default: 'surface-card',
@ -18,7 +19,7 @@ const Tag = as;
---
<Tag
class:list={['rounded-[1.65rem] p-6 sm:p-7', variantClasses[variant], className]}
class:list={['rounded-[1.65rem] p-6 sm:p-7', variantClasses[variant], hoverable && 'card-hoverable', className]}
data-surface={variant}
{...rest}
>

View File

@ -5,7 +5,7 @@ interface Props {
density?: 'base' | 'compact' | 'spacious';
id?: string;
layer?: '1' | '2' | '3';
tone?: 'default' | 'emphasis' | 'muted';
tone?: 'default' | 'emphasis' | 'muted' | 'tinted' | 'warm';
[key: string]: unknown;
}
@ -28,6 +28,8 @@ const toneClasses = {
default: '',
muted: 'section-shell-muted px-3 sm:px-4',
emphasis: 'section-shell-emphasis px-3 sm:px-4',
tinted: 'section-tinted px-3 sm:px-4',
warm: 'section-warm px-3 sm:px-4',
};
---

View File

@ -9,6 +9,7 @@ interface Props {
description?: string;
eyebrow?: string;
title: string;
titleHtml?: string;
width?: 'default' | 'measure' | 'wide';
}
@ -18,6 +19,7 @@ const {
description,
eyebrow,
title,
titleHtml,
width = 'default',
} = Astro.props;
const widthClasses = {
@ -29,6 +31,6 @@ const widthClasses = {
<div class:list={[widthClasses[width], align === 'center' ? 'mx-auto text-center' : '', className]}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<Headline>{title}</Headline>
{titleHtml ? <Headline><Fragment set:html={titleHtml} /></Headline> : <Headline>{title}</Headline>}
{description && <Lead class="mt-4">{description}</Lead>}
</div>

View File

@ -14,18 +14,19 @@ interface Props {
eyebrow?: string;
items: CapabilityClusterContent[];
title: string;
titleHtml?: string;
}
const { description, eyebrow, items, title } = Astro.props;
const { description, eyebrow, items, title, titleHtml } = Astro.props;
---
<Section layer="2" data-section="capability">
<Section layer="2" tone="tinted" data-section="capability">
<Container width="wide">
<div class="space-y-8">
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
<SectionHeader eyebrow={eyebrow} title={title} titleHtml={titleHtml} description={description} />
<Grid cols="2" gap="lg">
{items.map((cluster) => (
<Card class="h-full">
<Card class="h-full" hoverable>
<div class="space-y-4">
<div class="flex items-start justify-between gap-3">
<Headline as="h3" size="card">

View File

@ -11,15 +11,17 @@ interface Props {
eyebrow?: string;
items: FeatureItemContent[];
title: string;
titleHtml?: string;
tone?: 'default' | 'tinted';
}
const { description, eyebrow, items, title } = Astro.props;
const { description, eyebrow, items, title, titleHtml, tone = 'default' } = Astro.props;
---
<Section layer="2">
<Section layer="2" tone={tone === 'tinted' ? 'tinted' : 'default'}>
<Container width="wide">
<div class="space-y-8">
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
<SectionHeader eyebrow={eyebrow} title={title} titleHtml={titleHtml} description={description} />
<Grid cols="3">
{items.map((item) => <FeatureItem item={item} />)}
</Grid>

View File

@ -10,9 +10,10 @@ import type { OutcomeSectionContent } from '@/types/site';
interface Props {
content: OutcomeSectionContent;
titleHtml?: string;
}
const { content } = Astro.props;
const { content, titleHtml } = Astro.props;
---
<Section layer="2" data-section="outcome">
@ -21,11 +22,12 @@ const { content } = Astro.props;
<SectionHeader
eyebrow="Why it matters"
title={content.title}
titleHtml={titleHtml}
description={content.description}
/>
<Grid cols="3">
{content.outcomes.map((outcome) => (
<Card class="h-full">
<Card class="h-full" hoverable>
<Headline as="h3" size="card">
{outcome.title}
</Headline>

View File

@ -4,6 +4,7 @@ import Card from '@/components/primitives/Card.astro';
import Cluster from '@/components/primitives/Cluster.astro';
import Container from '@/components/primitives/Container.astro';
import Headline from '@/components/content/Headline.astro';
import HeroDashboard from '@/components/content/HeroDashboard.astro';
import Lead from '@/components/content/Lead.astro';
import Metric from '@/components/content/Metric.astro';
import PrimaryCTA from '@/components/content/PrimaryCTA.astro';
@ -18,74 +19,205 @@ interface Props {
}
const { calloutDescription, calloutTitle, hero, metrics = [] } = Astro.props;
const isHomepageHero = Astro.url.pathname === '/';
const heroHeadlineSize = isHomepageHero ? 'page' : 'display';
const heroLeadSize = isHomepageHero ? 'body' : 'lead';
const heroPrimaryAnchor = hero.primaryAnchor ?? 'headline';
---
<section class="pt-8 sm:pt-10 lg:pt-14">
<section
class:list={[
isHomepageHero ? 'hero-gradient pt-2 sm:pt-8 lg:pt-14' : 'pt-8 sm:pt-10 lg:pt-14',
]}
data-hero-root
data-hero-surface={isHomepageHero ? 'homepage' : 'page'}
data-homepage-hero={isHomepageHero ? 'true' : undefined}
data-hero-primary-anchor={isHomepageHero && heroPrimaryAnchor === 'composition' ? 'composition' : undefined}
data-section={isHomepageHero ? 'hero' : undefined}
>
<Container width="wide">
<div class="grid gap-6 lg:grid-cols-[1.35fr,0.85fr]" data-disclosure-layer="1">
<Card class="motion-rise overflow-hidden">
<div class="space-y-6">
{isHomepageHero ? (
<div class="space-y-6 sm:space-y-8 lg:space-y-10" data-disclosure-layer="1" data-hero-layout>
<div class="grid gap-6 lg:grid-cols-[minmax(0,0.82fr)_minmax(22rem,1.18fr)] lg:items-start lg:gap-10">
<div class="motion-rise flex flex-col gap-5 sm:gap-6 lg:max-w-[40rem]" data-hero-panel="text">
<div class="space-y-5" data-hero-anchor-group>
<div data-hero-text-core>
<div data-hero-eyebrow data-hero-segment="eyebrow">
<Badge>{hero.eyebrow}</Badge>
<div class="space-y-4">
<Headline as="h1" size="display" class="max-w-4xl">
{hero.title}
</div>
<div class="mt-3 space-y-4 sm:mt-5 sm:space-y-5">
<div
data-hero-heading
data-hero-primary-anchor={heroPrimaryAnchor === 'headline' ? 'headline' : undefined}
data-hero-segment="headline"
>
<Headline
as="h1"
size="page"
class="max-w-[13ch] text-balance text-[length:clamp(2.7rem,4.6vw,4.8rem)] leading-[0.94] tracking-[-0.045em]"
>
{hero.titleHtml ? <Fragment set:html={hero.titleHtml} /> : hero.title}
</Headline>
<Lead class="max-w-3xl" size="lead">
</div>
<div
data-hero-copy-role="supporting"
data-hero-supporting-copy
data-hero-segment="supporting-copy"
>
<Lead class="max-w-[36rem] text-[1.02rem] leading-8 text-[var(--color-copy)] sm:text-[1.08rem]">
{hero.description}
</Lead>
</div>
</div>
</div>
{(hero.primaryCta || hero.secondaryCta) && (
<Cluster data-cta-cluster gap="md">
<div data-hero-cta-pair data-hero-segment="cta-pair">
<Cluster data-cta-cluster gap="sm" class="items-center sm:gap-[var(--space-cluster)]">
<PrimaryCTA cta={hero.primaryCta} size="lg" />
{hero.secondaryCta && (
<SecondaryCTA cta={hero.secondaryCta} />
)}
</Cluster>
</div>
)}
</div>
</div>
<div
class="motion-rise lg:pt-1"
style="animation-delay: 120ms;"
data-hero-panel="dashboard"
data-hero-primary-anchor={heroPrimaryAnchor === 'product-visual' ? 'product-visual' : undefined}
data-hero-visual
data-hero-visual-style="governance-surface"
data-hero-segment="product-near-visual"
>
<div class="overflow-hidden rounded-[2rem] border border-[color:var(--color-border)] bg-[linear-gradient(180deg,rgba(255,255,255,0.9),rgba(246,248,252,0.94))] p-3 shadow-[var(--shadow-panel-strong)] sm:p-4">
{hero.visualFocus && (
<div class="mb-3 rounded-[1.45rem] border border-[color:var(--color-border-subtle)] bg-white/88 px-4 py-4 sm:px-5">
<p class="m-0 text-[0.72rem] font-semibold uppercase tracking-[var(--tracking-eyebrow)] text-[var(--color-brand-500)]">
{hero.visualFocus.eyebrow}
</p>
<p class="mt-2 max-w-[42rem] text-sm font-semibold leading-6 text-[var(--color-ink-900)] sm:text-[0.98rem]">
{hero.visualFocus.title}
</p>
<ul class="mt-3 grid gap-2 p-0 sm:grid-cols-3">
{hero.visualFocus.points.map((point) => (
<li class="list-none rounded-[1rem] border border-[color:var(--color-border-subtle)] bg-[var(--surface-muted)] px-3 py-2 text-sm font-medium leading-5 text-[var(--color-ink-800)]">
{point}
</li>
))}
</ul>
</div>
)}
<HeroDashboard />
</div>
</div>
</div>
{hero.trustSubclaims && hero.trustSubclaims.length > 0 && (
<div class="motion-rise space-y-2" data-hero-segment="trust-subclaims" data-hero-trust-signals>
<ul class="flex flex-wrap gap-3 p-0">
{hero.trustSubclaims.map((claim) => (
<li class="list-none rounded-full border border-[color:var(--color-border)] bg-white/80 px-4 py-1.5 text-sm font-medium text-[var(--color-ink-800)]">
{claim}
</li>
))}
</ul>
</div>
)}
</div>
) : (
<!-- Subpage hero: card-based 2-col layout -->
<div
class="grid gap-5 sm:gap-6 lg:grid-cols-[minmax(0,1.08fr)_minmax(20rem,0.92fr)] lg:items-start"
data-disclosure-layer="1"
data-hero-layout
>
<Card class="motion-rise overflow-hidden" data-hero-panel="text">
<div class="space-y-4 sm:space-y-6">
<div data-hero-text-core>
<div data-hero-eyebrow data-hero-segment="eyebrow">
<Badge>{hero.eyebrow}</Badge>
</div>
<div class="mt-3 space-y-4 sm:mt-4 sm:space-y-5">
<div data-hero-heading data-hero-segment="headline">
<Headline
as="h1"
size={heroHeadlineSize}
class="max-w-3xl text-balance"
>
{hero.titleHtml ? <Fragment set:html={hero.titleHtml} /> : hero.title}
</Headline>
</div>
<div data-hero-copy-role="supporting" data-hero-supporting-copy data-hero-segment="supporting-copy">
<Lead class="max-w-2xl" size={heroLeadSize}>
{hero.description}
</Lead>
</div>
</div>
</div>
{(hero.primaryCta || hero.secondaryCta) && (
<div data-hero-cta-pair data-hero-segment="cta-pair">
<Cluster data-cta-cluster gap="sm" class="sm:gap-[var(--space-cluster)]">
<PrimaryCTA cta={hero.primaryCta} />
{hero.secondaryCta && (
<SecondaryCTA cta={hero.secondaryCta} />
)}
</Cluster>
)}
{hero.trustSubclaims && hero.trustSubclaims.length > 0 && (
<ul class="grid gap-3 p-0 sm:grid-cols-3">
{
hero.trustSubclaims.map((claim) => (
<li class="list-none rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/70 px-4 py-3 text-sm font-medium text-[var(--color-ink-800)]">
{claim}
</li>
))
}
</ul>
</div>
)}
{hero.highlights && hero.highlights.length > 0 && !hero.trustSubclaims?.length && (
<ul class="grid gap-3 p-0 sm:grid-cols-3">
{
hero.highlights.map((highlight) => (
{hero.highlights.map((highlight) => (
<li class="list-none rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/70 px-4 py-3 text-sm font-medium text-[var(--color-ink-800)]">
{highlight}
</li>
))
}
))}
</ul>
)}
</div>
</Card>
<div class="grid gap-5">
<div class="grid gap-4 sm:gap-5" data-hero-panel="supporting">
{hero.productVisual && (
<Card variant="accent" class="motion-rise overflow-hidden" data-hero-visual>
<Card
variant="accent"
class="motion-rise overflow-hidden"
data-hero-segment="product-near-visual"
data-hero-visual
>
<img
src={hero.productVisual.src}
alt={hero.productVisual.alt}
class="w-full rounded-[var(--radius-lg)] object-cover"
class="max-h-[22rem] w-full rounded-[var(--radius-lg)] object-cover object-top"
loading="eager"
/>
</Card>
)}
{hero.trustSubclaims && hero.trustSubclaims.length > 0 && (
<Card variant="subtle" class="motion-rise" data-hero-segment="trust-subclaims">
<div class="space-y-3" data-hero-trust-signals>
<p class="m-0 text-[0.72rem] font-semibold uppercase tracking-[var(--tracking-eyebrow)] text-[var(--color-copy)]">
Early trust
</p>
<ul class="grid gap-2.5 p-0">
{hero.trustSubclaims.map((claim) => (
<li class="list-none rounded-[1rem] border border-[color:var(--color-line)] bg-white/82 px-4 py-3 text-sm font-medium text-[var(--color-ink-800)]">
{claim}
</li>
))}
</ul>
</div>
</Card>
)}
{!hero.productVisual && (calloutTitle || calloutDescription) && (
<Card variant="accent" class="motion-rise">
<p class="m-0 text-sm font-semibold uppercase tracking-[0.15em] text-[var(--color-brand)]">
Trust-first launch surface
</p>
{calloutTitle && (
<h2 class="mt-4 font-[var(--font-display)] text-3xl leading-tight text-[var(--color-ink-900)]">
<h2 class="mt-4 font-[var(--font-display)] text-3xl font-bold leading-tight text-[var(--color-ink-900)]">
{calloutTitle}
</h2>
)}
@ -102,5 +234,6 @@ const { calloutDescription, calloutTitle, hero, metrics = [] } = Astro.props;
)}
</div>
</div>
)}
</Container>
</section>

View File

@ -11,15 +11,16 @@ interface Props {
eyebrow?: string;
items: TrustPrincipleContent[];
title: string;
titleHtml?: string;
}
const { description, eyebrow, items, title } = Astro.props;
const { description, eyebrow, items, title, titleHtml } = Astro.props;
---
<Section layer="2">
<Section layer="2" tone="warm">
<Container width="wide">
<div class="space-y-8">
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
<SectionHeader eyebrow={eyebrow} title={title} titleHtml={titleHtml} description={description} />
<Grid cols="3">
{items.map((item) => <TrustPrincipleCard item={item} />)}
</Grid>

View File

@ -17,10 +17,10 @@ export const homeSeo: PageSeo = {
};
export const homeHero: HeroContent = {
eyebrow: 'Governance of record',
title: 'TenantAtlas keeps Microsoft tenant change history, restore posture, and review context inside one operating record.',
eyebrow: 'Microsoft tenant governance',
title: 'TenantAtlas gives Microsoft tenant teams one operating record for change history, drift review, and restore planning.',
description:
'MSP and enterprise teams use TenantAtlas to understand what changed, what drifted, what can be restored, and what needs review — without turning governance into disconnected screens.',
'Security, endpoint, and platform teams use TenantAtlas to see what changed, preview restores, and move reviews forward without stitching governance across exports and memory.',
primaryCta: {
href: '/contact',
label: 'Request a working session',
@ -32,12 +32,12 @@ export const homeHero: HeroContent = {
},
productVisual: {
src: '/images/hero-product-visual.svg',
alt: 'TenantAtlas governance dashboard showing tenant change history and restore posture',
alt: 'TenantAtlas screen showing change history, restore preview, and a review queue for Microsoft tenant policies',
},
trustSubclaims: [
'Tenant-isolated by design',
'Immutable change history',
'Bounded public claims',
'Tenant-scoped boundaries',
'Reviewable change history',
'Preview before restore',
],
};

View File

@ -50,36 +50,42 @@ export const productMetrics: MetricItem[] = [
export const productModelBlocks: FeatureItemContent[] = [
{
eyebrow: 'Inventory and drift',
icon: 'search',
title: 'Current-state inventory and drift visibility establish what the tenant actually looks like now.',
description:
'The product starts with last-observed tenant state so teams can compare real configuration truth instead of relying on partial memory or exported spreadsheets.',
},
{
eyebrow: 'Backup and versioning',
icon: 'database',
title: 'Snapshots and versions preserve immutable history without replacing present-tense truth.',
description:
'Backups and versions are explicit artifacts tied to tenant context, operators, and timing so the history remains reproducible and queryable.',
},
{
eyebrow: 'Restore safety',
icon: 'refresh',
title: 'Restore is handled as a governed operation, not as a blind push.',
description:
'Preview, validation, selective scope, and confirmation reduce the risk of turning a recovery step into a new incident.',
},
{
eyebrow: 'Audit and review',
icon: 'eye',
title: 'Differences become reviewable signals instead of noisy raw deltas.',
description:
'Human-readable summaries and structured differences help operators and reviewers decide what changed, who needs to know, and what deserves follow-up.',
},
{
eyebrow: 'Findings and evidence',
icon: 'file-check',
title: 'Findings, exceptions, and evidence stay anchored to operational truth.',
description:
'Governance discussions stay attached to the real object, version, and review context instead of drifting into separate manual trackers.',
},
{
eyebrow: 'Baselines and governance',
icon: 'shield',
title: 'Baselines, reviews, and operator safety belong to the same workflow.',
description:
'The product is built so teams can explain actions afterward, not just execute them quickly in the moment.',

View File

@ -41,6 +41,12 @@ const openGraphDescription = Astro.props.openGraphDescription ?? description;
<meta name="twitter:description" content={openGraphDescription} />
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>{title}</title>
</head>
<body>

View File

@ -38,11 +38,15 @@ const progressContent = {
items={homeEcosystem}
/>
<OutcomeSection content={homeOutcome} />
<OutcomeSection
content={homeOutcome}
titleHtml='Teams that <span class="accent">understand their tenant</span> make better decisions.'
/>
<CapabilityGrid
eyebrow="What TenantAtlas covers"
title="A connected product model, not a feature wall."
titleHtml='A <span class="accent">connected product model</span>, not a feature wall.'
description="TenantAtlas groups backup, restore, inventory, drift, and governance into connected clusters instead of listing isolated features."
items={homeCapabilities}
/>
@ -51,6 +55,7 @@ const progressContent = {
<TrustGrid
eyebrow="Trust posture"
title={homeTrustSignals.title}
titleHtml='Trust is a <span class="accent">first-read concern</span>, not a footnote.'
description={homeTrustSignals.description}
items={homeTrustSignals.signals}
/>

View File

@ -28,8 +28,10 @@ import {
<FeatureGrid
eyebrow="Connected governance model"
title="Explain what the product does before asking for buyer trust."
titleHtml='Explain what the product does before asking for <span class="accent">buyer trust</span>.'
description="This page should explain how the pieces fit together so visitors do not mistake the product for a loose collection of backup, reporting, and restore features."
items={productModelBlocks}
tone="tinted"
/>
<Section tone="muted" density="base" layer="3">

View File

@ -12,9 +12,11 @@ body {
font-family: var(--font-sans);
color: var(--color-foreground);
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 30%),
radial-gradient(circle at top left, rgba(255, 255, 255, 0.88), transparent 30%),
linear-gradient(180deg, var(--color-background) 0%, var(--color-background-elevated) 48%, var(--color-muted) 100%);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*,
@ -23,7 +25,7 @@ body {
box-sizing: border-box;
}
a {
a:not([data-button-variant]) {
color: inherit;
}
@ -37,12 +39,12 @@ code {
}
::selection {
background: rgba(47, 111, 183, 0.18);
background: rgba(40, 60, 120, 0.12);
color: var(--color-foreground);
}
:where(a, button, input, textarea, summary):focus-visible {
outline: 3px solid rgba(47, 111, 183, 0.32);
outline: 3px solid rgba(40, 60, 120, 0.22);
outline-offset: 4px;
}
@ -69,16 +71,16 @@ .foundation-page::before {
.foundation-page[data-shell-tone='brand']::before {
background:
radial-gradient(circle at 8% 0%, rgba(255, 255, 255, 0.82), transparent 30%),
radial-gradient(circle at 86% 0%, rgba(47, 111, 183, 0.18), transparent 28%),
radial-gradient(circle at 72% 22%, rgba(59, 139, 120, 0.12), transparent 26%),
radial-gradient(circle at 86% 0%, rgba(40, 60, 120, 0.04), transparent 28%),
radial-gradient(circle at 72% 22%, rgba(40, 60, 120, 0.03), transparent 26%),
linear-gradient(180deg, rgba(255, 255, 255, 0.62), transparent 18%);
}
.foundation-page[data-shell-tone='trust']::before {
background:
radial-gradient(circle at 10% 0%, rgba(255, 255, 255, 0.8), transparent 28%),
radial-gradient(circle at 82% 8%, rgba(59, 139, 120, 0.16), transparent 30%),
radial-gradient(circle at 74% 30%, rgba(175, 109, 67, 0.08), transparent 26%),
radial-gradient(circle at 82% 8%, rgba(40, 60, 120, 0.04), transparent 30%),
radial-gradient(circle at 74% 30%, rgba(100, 80, 140, 0.03), transparent 26%),
linear-gradient(180deg, rgba(255, 255, 255, 0.62), transparent 18%);
}
@ -170,7 +172,7 @@ .section-shell-muted {
.section-shell-emphasis {
border: 1px solid var(--color-border-strong);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.56), rgba(47, 111, 183, 0.05));
background: linear-gradient(180deg, rgba(255, 255, 255, 0.56), rgba(40, 60, 120, 0.03));
border-radius: calc(var(--radius-lg) + 0.15rem);
}
@ -186,7 +188,7 @@ .text-link {
.text-link:hover {
color: var(--color-primary);
text-decoration-color: rgba(47, 111, 183, 0.35);
text-decoration-color: rgba(40, 60, 120, 0.35);
}
.legal-prose p {
@ -240,3 +242,84 @@ @keyframes rise-in {
transform: translateY(0);
}
}
/* ── Section tint bands ── */
.section-tinted {
background: var(--surface-section-tinted);
border-radius: calc(var(--radius-lg) + 0.15rem);
}
.section-warm {
background: var(--surface-section-warm);
border-radius: calc(var(--radius-lg) + 0.15rem);
}
/* ── Hero gradient band ── */
.hero-gradient {
background:
radial-gradient(ellipse 80% 60% at 20% 40%, rgba(40, 60, 120, 0.02), transparent),
radial-gradient(ellipse 60% 50% at 80% 20%, rgba(40, 60, 120, 0.015), transparent);
padding-bottom: var(--space-section);
}
/* ── Card hover lift ── */
.surface-card,
.surface-card-muted,
.surface-card-accent {
transition:
box-shadow 220ms ease,
transform 220ms ease;
}
.card-hoverable:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
}
/* ── Callout accent bar ── */
.callout-bar {
position: relative;
padding-left: 1.25rem;
}
.callout-bar::before {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 3px;
border-radius: 3px;
background: var(--color-primary);
content: "";
}
.callout-bar[data-bar-tone="trust"]::before {
background: var(--color-success);
}
.callout-bar[data-bar-tone="warm"]::before {
background: var(--color-warning);
}
/* ── Accent text highlight ── */
.text-accent-word {
color: var(--color-primary);
}
/* ── Feature icon circle ── */
.feature-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 50%;
background: linear-gradient(135deg, var(--surface-accent), var(--surface-accent-strong));
color: var(--color-primary);
flex-shrink: 0;
}
.feature-icon svg {
width: 1.25rem;
height: 1.25rem;
}

View File

@ -1,23 +1,28 @@
@theme {
--font-sans: "Avenir Next", "Segoe UI", sans-serif;
--font-display: "Iowan Old Style", "Palatino Linotype", serif;
--font-sans: "Inter", "Avenir Next", "Segoe UI", sans-serif;
--font-display: "Inter", "Avenir Next", "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", "SFMono-Regular", monospace;
--color-stone-50: oklch(0.986 0.008 86);
--color-stone-100: oklch(0.974 0.012 84);
--color-stone-150: oklch(0.958 0.016 82);
--color-stone-200: oklch(0.936 0.018 79);
--color-stone-300: oklch(0.896 0.025 76);
--color-ink-700: oklch(0.4 0.04 244);
--color-ink-800: oklch(0.31 0.04 248);
--color-ink-900: oklch(0.23 0.038 252);
--color-brand-300: oklch(0.84 0.05 228);
--color-brand-400: oklch(0.76 0.09 214);
--color-brand-500: oklch(0.67 0.12 226);
--color-brand-700: oklch(0.48 0.1 232);
--color-mint-300: oklch(0.85 0.05 182);
--color-mint-500: oklch(0.72 0.07 186);
--color-mint-700: oklch(0.54 0.07 184);
/* Cool slate neutrals — shadcn / Apex direction */
--color-stone-50: oklch(0.985 0.002 250);
--color-stone-100: oklch(0.975 0.003 250);
--color-stone-150: oklch(0.962 0.004 248);
--color-stone-200: oklch(0.94 0.006 246);
--color-stone-300: oklch(0.90 0.008 244);
/* Ink: dark slate, very low chroma — near-black with cool undertone */
--color-ink-700: oklch(0.44 0.01 250);
--color-ink-800: oklch(0.30 0.008 250);
--color-ink-900: oklch(0.14 0.005 250);
/* Brand: dry slate-blue — enterprise, not flashy */
--color-brand-300: oklch(0.78 0.06 245);
--color-brand-400: oklch(0.64 0.09 245);
--color-brand-500: oklch(0.50 0.10 245);
--color-brand-700: oklch(0.38 0.09 248);
/* Mint → Steel: functional accent for success states */
--color-mint-300: oklch(0.84 0.05 175);
--color-mint-500: oklch(0.66 0.08 172);
--color-mint-700: oklch(0.48 0.08 170);
/* Amber: stays functional (warning states) */
--color-amber-300: oklch(0.88 0.045 73);
--color-amber-500: oklch(0.75 0.085 62);
--color-amber-700: oklch(0.56 0.09 54);
@ -32,18 +37,18 @@ :root {
--color-foreground: var(--color-ink-900);
--color-muted: var(--color-stone-150);
--color-muted-foreground: var(--color-ink-700);
--color-card: rgba(255, 255, 255, 0.9);
--color-card: rgba(255, 255, 255, 0.92);
--color-card-foreground: var(--color-ink-900);
--color-border: rgba(17, 36, 58, 0.12);
--color-border-strong: rgba(47, 111, 183, 0.22);
--color-border-subtle: rgba(17, 36, 58, 0.07);
--color-frame: rgba(17, 36, 58, 0.06);
--color-border: rgba(20, 20, 20, 0.08);
--color-border-strong: rgba(20, 20, 20, 0.15);
--color-border-subtle: rgba(20, 20, 20, 0.05);
--color-frame: rgba(20, 20, 20, 0.04);
--color-input: rgba(255, 255, 255, 0.94);
--color-primary: var(--color-brand-500);
--color-primary-foreground: #f9fbff;
--color-primary-foreground: #f8f9fb;
--color-secondary: rgba(255, 255, 255, 0.82);
--color-secondary-foreground: var(--color-ink-900);
--color-accent: rgba(47, 111, 183, 0.1);
--color-accent: rgba(40, 60, 120, 0.06);
--color-accent-foreground: var(--color-brand-700);
--color-success: var(--color-mint-700);
--color-warning: var(--color-amber-700);
@ -53,12 +58,14 @@ :root {
--surface-page: rgba(255, 255, 255, 0.34);
--surface-shell: rgba(255, 255, 255, 0.78);
--surface-shell-strong: rgba(255, 255, 255, 0.94);
--surface-card-soft: rgba(246, 248, 251, 0.82);
--surface-muted: rgba(243, 247, 251, 0.88);
--surface-muted-strong: rgba(247, 249, 252, 0.94);
--surface-accent: rgba(47, 111, 183, 0.1);
--surface-accent-strong: rgba(241, 246, 253, 0.98);
--surface-trust: rgba(59, 139, 120, 0.09);
--surface-card-soft: rgba(248, 249, 252, 0.82);
--surface-muted: rgba(246, 248, 252, 0.88);
--surface-muted-strong: rgba(250, 251, 254, 0.94);
--surface-accent: rgba(40, 60, 120, 0.04);
--surface-accent-strong: rgba(246, 248, 254, 0.98);
--surface-trust: rgba(40, 60, 120, 0.03);
--surface-section-tinted: rgba(246, 248, 252, 0.62);
--surface-section-warm: rgba(248, 249, 252, 0.52);
--radius-sm: 1rem;
--radius-md: 1.35rem;
@ -68,14 +75,15 @@ :root {
--shadow-panel-strong: 0 28px 90px rgba(17, 36, 58, 0.14);
--shadow-card: 0 20px 56px rgba(17, 36, 58, 0.1);
--shadow-card-hover: 0 24px 64px rgba(17, 36, 58, 0.15);
--shadow-soft: 0 12px 36px rgba(17, 36, 58, 0.08);
--shadow-inline: 0 10px 22px rgba(17, 36, 58, 0.08);
--space-page-x: clamp(1.25rem, 2vw, 2.5rem);
--space-page-y: clamp(4rem, 6vw, 6rem);
--space-section-compact: clamp(3rem, 4vw, 4.25rem);
--space-section: clamp(4rem, 6vw, 5.75rem);
--space-section-spacious: clamp(5rem, 7vw, 7rem);
--space-page-y: clamp(5rem, 8vw, 8rem);
--space-section-compact: clamp(3.5rem, 5vw, 5rem);
--space-section: clamp(5rem, 8vw, 7rem);
--space-section-spacious: clamp(6rem, 9vw, 9rem);
--space-cluster-sm: 0.75rem;
--space-cluster: 1rem;
--space-cluster-lg: 1.5rem;
@ -89,19 +97,19 @@ :root {
--wide-max-width: 84rem;
--reading-max-width: 68rem;
--type-display-size: clamp(3.3rem, 6vw, 5rem);
--type-page-size: clamp(2.75rem, 4.2vw, 3.75rem);
--type-section-size: clamp(2rem, 3.5vw, 3rem);
--type-card-size: clamp(1.35rem, 2vw, 1.85rem);
--type-body-size: 1.02rem;
--type-display-size: clamp(2.75rem, 5vw, 4.25rem);
--type-page-size: clamp(2.25rem, 3.8vw, 3.25rem);
--type-section-size: clamp(1.75rem, 2.8vw, 2.5rem);
--type-card-size: clamp(1.25rem, 1.8vw, 1.6rem);
--type-body-size: 1.05rem;
--type-small-size: 0.94rem;
--type-eyebrow-size: 0.74rem;
--type-helper-size: 0.82rem;
--tracking-display: -0.055em;
--tracking-tight: -0.03em;
--tracking-display: -0.04em;
--tracking-tight: -0.025em;
--tracking-eyebrow: 0.18em;
--line-display: 0.95;
--line-heading: 1.02;
--line-display: 0.96;
--line-heading: 1.04;
--line-body: 1.75;
--line-tight: 1.45;

View File

@ -98,11 +98,14 @@ export interface HeroContent {
description: string;
eyebrow: string;
highlights?: string[];
primaryAnchor?: HeroPrimaryAnchor;
primaryCta: CtaLink;
productVisual?: HeroVisualContent;
secondaryCta?: CtaLink;
title: string;
titleHtml?: string;
trustSubclaims?: string[];
visualFocus?: HeroVisualFocusContent;
}
export interface MetricItem {
@ -115,6 +118,7 @@ export interface FeatureItemContent {
description: string;
eyebrow?: string;
href?: string;
icon?: string;
meta?: string;
title: string;
}
@ -163,6 +167,14 @@ export interface HeroVisualContent {
src: string;
}
export type HeroPrimaryAnchor = 'headline' | 'product-visual' | 'composition';
export interface HeroVisualFocusContent {
eyebrow: string;
points: string[];
title: string;
}
export interface OutcomeItemContent {
description: string;
title: string;

View File

@ -4,6 +4,12 @@ import {
expectCtaHierarchy,
expectDisclosureLayer,
expectFooterLinks,
expectHomepageHeroCtaPair,
expectHomepageHeroOrder,
expectHomepageHeroRouteTargets,
expectHomepageHeroStructure,
expectHomepageHeroTrustSignals,
expectHomepageHeroVisibleOnMobile,
expectHomepageSectionOrder,
expectMobileReadability,
expectNavigationVsCtaDifferentiation,
@ -72,6 +78,41 @@ test('homepage shows explicit trust signals before the final CTA', async ({
await expectOnwardRouteReachable(page, ['/trust']);
});
test('homepage hero makes the product category, text core, and one CTA pair explicit on first read', async ({
page,
}) => {
await visitPage(page, '/');
await expectHomepageHeroStructure(page);
await expect(page.locator('[data-homepage-hero="true"] [data-hero-eyebrow]')).toContainText(/microsoft tenant governance/i);
await expect(
page.locator('[data-homepage-hero="true"] [data-hero-heading]').getByRole('heading', {
level: 1,
name: /one operating record for change history, drift review, and restore planning/i,
}),
).toBeVisible();
await expect(page.locator('[data-homepage-hero="true"] [data-hero-supporting-copy]')).toContainText(
/security, endpoint, and platform teams use TenantAtlas to see what changed, preview restores, and move reviews forward/i,
);
await expectHomepageHeroCtaPair(page, /working session/i, /product model/i);
});
test('homepage hero keeps product-near proof and bounded trust cues inside the hero itself', async ({
page,
}) => {
await visitPage(page, '/');
await expectProductNearVisual(page, /change history, restore preview, and a review queue/i);
await expectHomepageHeroTrustSignals(page);
await expect(page.locator('[data-homepage-hero="true"] [data-hero-trust-signals]')).toContainText(
/tenant-scoped boundaries/i,
);
await expect(page.locator('[data-homepage-hero="true"] [data-hero-trust-signals]')).toContainText(
/reviewable change history/i,
);
await expect(page.locator('[data-homepage-hero="true"] [data-hero-trust-signals]')).toContainText(
/preview before restore/i,
);
});
test('homepage shows dated progress signals before the final CTA', async ({
page,
}) => {
@ -97,6 +138,20 @@ test.describe('homepage mobile', () => {
await expect(page.locator('[data-section="capability"]')).toBeVisible();
await expect(page.locator('[data-section="trust"]')).toBeVisible();
});
test('homepage hero preserves meaning order and hero route intent on narrow screens', async ({ page }) => {
await visitPage(page, '/');
await expectHomepageHeroOrder(page, [
'eyebrow',
'headline',
'supporting-copy',
'cta-pair',
'product-near-visual',
'trust-subclaims',
]);
await expectHomepageHeroVisibleOnMobile(page);
await expectHomepageHeroRouteTargets(page, ['/contact', '/product']);
});
});
test('product keeps the connected operating model readable without collapsing into a feature list', async ({

View File

@ -87,6 +87,180 @@ export async function expectCtaHierarchy(
).toBeVisible();
}
export async function expectHomepageHeroStructure(page: Page): Promise<void> {
const hero = page.locator('[data-homepage-hero="true"]').first();
await expect(hero).toBeVisible();
await expect(hero.locator('[data-hero-text-core]').first()).toBeVisible();
await expect(hero.locator('[data-hero-eyebrow]').first()).toBeVisible();
await expect(hero.locator('[data-hero-heading]').getByRole('heading', { level: 1 })).toBeVisible();
await expect(hero.locator('[data-hero-supporting-copy]').first()).toBeVisible();
await expect(hero.locator('[data-hero-cta-pair]').first()).toBeVisible();
await expect(hero.locator('[data-cta-slot="primary"]')).toHaveCount(1);
await expect(hero.locator('[data-cta-slot="secondary"]')).toHaveCount(1);
await expect(hero.locator('[data-hero-visual]').first()).toBeVisible();
}
export async function expectHomepageHeroCtaPair(
page: Page,
primaryLabel: string | RegExp,
secondaryLabel: string | RegExp,
): Promise<void> {
const hero = page.locator('[data-homepage-hero="true"]').first();
await expect(hero.locator('[data-cta-weight="primary"]').filter({ hasText: primaryLabel }).first()).toBeVisible();
await expect(
hero.locator('[data-cta-weight="secondary"]').filter({ hasText: secondaryLabel }).first(),
).toBeVisible();
}
export async function expectHomepageHeroPrimaryAnchor(
page: Page,
anchor: 'headline' | 'product-visual' | 'composition',
): Promise<void> {
const hero = page.locator('[data-homepage-hero="true"]').first();
await expect(hero.locator(`[data-hero-primary-anchor="${anchor}"]`).first()).toBeVisible();
await expect(hero.locator('[data-hero-primary-anchor]')).toHaveCount(1);
}
export async function expectHomepageHeroSupportingCopySubordination(page: Page): Promise<void> {
const hero = page.locator('[data-homepage-hero="true"]').first();
const heading = hero.locator('[data-hero-heading]').getByRole('heading', { level: 1 }).first();
const supportingCopy = hero.locator('[data-hero-supporting-copy] p').first();
await expect(hero.locator('[data-hero-supporting-copy]').first()).toHaveAttribute('data-hero-copy-role', 'supporting');
const [headingFontSize, supportingCopyFontSize] = await Promise.all([
heading.evaluate((element) => Number.parseFloat(window.getComputedStyle(element).fontSize)),
supportingCopy.evaluate((element) => Number.parseFloat(window.getComputedStyle(element).fontSize)),
]);
expect(headingFontSize, 'Hero heading should remain larger than supporting copy').toBeGreaterThan(
supportingCopyFontSize,
);
}
export async function expectHomepageHeroAnchorCtaAlignment(page: Page): Promise<void> {
const hero = page.locator('[data-homepage-hero="true"]').first();
const anchorGroup = hero.locator('[data-hero-anchor-group]').first();
await expect(anchorGroup).toBeVisible();
await expect(anchorGroup.locator('[data-hero-heading]').first()).toBeVisible();
await expect(anchorGroup.locator('[data-hero-cta-pair]').first()).toBeVisible();
}
export async function expectHomepageHeroOrder(
page: Page,
segments: Array<'eyebrow' | 'headline' | 'supporting-copy' | 'cta-pair' | 'product-near-visual' | 'trust-subclaims'>,
): Promise<void> {
const hero = page.locator('[data-homepage-hero="true"]').first();
const actual = await hero.locator('[data-hero-segment]').evaluateAll((elements) =>
elements
.map((element) => element.getAttribute('data-hero-segment'))
.filter(Boolean),
);
for (let i = 0; i < segments.length; i++) {
expect(actual.indexOf(segments[i]), `Hero segment "${segments[i]}" should exist`).toBeGreaterThanOrEqual(0);
if (i > 0) {
expect(
actual.indexOf(segments[i]),
`Hero segment "${segments[i]}" should appear after "${segments[i - 1]}"`,
).toBeGreaterThan(actual.indexOf(segments[i - 1]));
}
}
}
export async function expectHomepageHeroTrustSignals(page: Page): Promise<void> {
const hero = page.locator('[data-homepage-hero="true"]').first();
const trustSignals = hero.locator('[data-hero-trust-signals] li');
const count = await trustSignals.count();
await expect(hero.locator('[data-hero-trust-signals]').first()).toBeVisible();
expect(count).toBeGreaterThan(0);
expect(count).toBeLessThanOrEqual(3);
}
export async function expectHomepageHeroVisualSemantics(
page: Page,
terms: Array<string | RegExp>,
): Promise<void> {
const hero = page.locator('[data-homepage-hero="true"]').first();
const visual = hero.locator('[data-hero-visual]').first();
await expect(visual).toBeVisible();
await expect(visual).toHaveAttribute('data-hero-visual-style', 'governance-surface');
for (const term of terms) {
await expect(visual).toContainText(term);
}
}
export async function expectHomepageHeroSplitLayout(page: Page): Promise<void> {
const hero = page.locator('[data-homepage-hero="true"]').first();
const textPanel = hero.locator('[data-hero-panel="text"]').first();
const visualPanel = hero.locator('[data-hero-panel="dashboard"]').first();
await expect(textPanel).toBeVisible();
await expect(visualPanel).toBeVisible();
const [textBox, visualBox] = await Promise.all([textPanel.boundingBox(), visualPanel.boundingBox()]);
expect(textBox, 'Hero text panel should have a bounding box').not.toBeNull();
expect(visualBox, 'Hero visual panel should have a bounding box').not.toBeNull();
if (!textBox || !visualBox) {
return;
}
expect(
textBox.x + textBox.width,
'Desktop hero text should end before the visual surface begins so both read as one split composition',
).toBeLessThanOrEqual(visualBox.x + 48);
expect(
Math.abs(textBox.y - visualBox.y),
'Desktop hero text and visual should share a horizontal composition instead of stacking far apart',
).toBeLessThan(140);
}
async function expectLocatorInInitialViewport(page: Page, selector: string, label: string): Promise<void> {
const locator = page.locator(selector).first();
const box = await locator.boundingBox();
const viewport = page.viewportSize();
await expect(locator).toBeVisible();
expect(box, `${label} should have a bounding box`).not.toBeNull();
expect(viewport, 'Viewport should be available').not.toBeNull();
if (!box || !viewport) {
return;
}
expect(box.y, `${label} should start within the initial viewport`).toBeLessThan(viewport.height);
expect(box.y + Math.min(box.height, 32), `${label} should remain on screen at first paint`).toBeGreaterThan(0);
}
export async function expectHomepageHeroVisibleOnMobile(page: Page): Promise<void> {
await expectLocatorInInitialViewport(
page,
'[data-homepage-hero="true"] [data-hero-primary-anchor]',
'Hero primary anchor',
);
await expectLocatorInInitialViewport(page, '[data-homepage-hero="true"] [data-hero-cta-pair]', 'Hero CTA pair');
await expect(page.locator('[data-homepage-hero="true"] [data-hero-visual]').first()).toBeVisible();
}
export async function expectHomepageHeroRouteTargets(page: Page, routes: string[]): Promise<void> {
const hero = page.locator('[data-homepage-hero="true"]').first();
for (const route of routes) {
await expect(hero.locator(`a[href="${route}"]`).first(), `Route "${route}" should be reachable from hero`).toBeVisible();
}
}
export async function expectNavigationVsCtaDifferentiation(page: Page): Promise<void> {
const header = page.getByRole('banner');
@ -120,10 +294,15 @@ export async function expectHomepageSectionOrder(page: Page, sections: string[])
}
}
export async function expectProductNearVisual(page: Page): Promise<void> {
export async function expectProductNearVisual(page: Page, alt?: string | RegExp): Promise<void> {
const main = page.getByRole('main');
const visual = main.locator('[data-hero-visual] img, [data-hero-visual]').first();
await expect(main.locator('[data-hero-visual] img, [data-hero-visual]').first()).toBeVisible();
await expect(visual).toBeVisible();
if (alt) {
await expect(main.getByRole('img', { name: alt }).first()).toBeVisible();
}
}
export async function expectMobileReadability(page: Page): Promise<void> {

View File

@ -13,11 +13,11 @@ test('representative pages route CTA, badge, surface, and input semantics throug
page,
}) => {
await visitPage(page, '/');
await expectShell(page, /TenantAtlas/);
await expectShell(page, /control surface/i);
await expectPageFamily(page, 'landing');
await expectPrimaryNavigation(page);
await expectNavigationVsCtaDifferentiation(page);
await expectCtaHierarchy(page, 'Request a working session', 'See the product model');
await expectCtaHierarchy(page, 'Request a working session', /product model/i);
await expect(page.locator('[data-interaction="button"]').filter({ hasText: 'Request a working session' }).first()).toBeVisible();
await expect(page.locator('[data-badge-tone]').first()).toBeVisible();

View File

@ -3,7 +3,7 @@ # Product Roadmap
> Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs.
**Last updated**: 2026-04-20
**Last updated**: 2026-04-22
---
@ -70,6 +70,17 @@ ### R1.9 Platform Localization v1 (DE/EN)
## Planned (Next Quarter)
### R2.0 Canonical Control Catalog Foundation
Framework-neutral canonical control core that bridges the shipped governance engine and later readiness or reporting overlays.
**Goal**: Give baselines, drift, findings, exceptions, evidence, and reports one shared control object before framework-specific mappings land.
- Framework-neutral canonical domains, subdomains, and control themes
- Detectability classes, evaluation strategies, evidence archetypes, and artifact suitability
- Microsoft subject and workload bindings for tenant-near technical controls
- Small seed catalog for v1 families such as strong authentication, conditional access, privileged access, endpoint baseline or hardening, sharing boundaries, mail protection, audit retention, and delegated admin boundaries
- Referenceable from Baseline Profiles, Compare and Drift, Findings, Exceptions, StoredReports, and EvidenceItems
- Foundation for later framework mappings, readiness views, and auditor packs
### R2 Completion — Evidence & Exception Workflows
- Review pack export (Spec 109 — done)
- Exception/risk-acceptance workflow for Findings → Spec 154 (draft)
@ -133,16 +144,23 @@ ### PSA / Ticketing Handoff
**Scope direction**: start with one-way handoff and internal visibility, not full bidirectional sync or full ITSM modeling.
### Compliance Readiness & Executive Review Packs
On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, and key security signals into one coherent deliverable. BSI-/NIS2-/CIS-oriented readiness views (without certification claims). Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs.
On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, and key security signals into one coherent deliverable. CIS-aligned baseline libraries plus NIS2-/BSI-oriented readiness views (without certification claims). Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs.
**Goal**: Make TenantPilot sellable as an MSP-facing governance and review platform for German midmarket and compliance-oriented customers who want structured tenant reviews and management-ready outputs on demand.
**Why it matters**: Turns existing governance data into a clear customer-facing value proposition. Strengthens MSP sales story beyond backup and restore. Creates a repeatable "review on demand" workflow for quarterly reviews, security health checks, and audit preparation.
**Depends on**: StoredReports / EvidenceItems foundation, Tenant Review runs, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity.
**Depends on**: Canonical Control Catalog Foundation, StoredReports / EvidenceItems foundation, Tenant Review runs, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity.
**Scope direction**: Start as compliance readiness and review packaging. Avoid formal certification language or promises. Position as governance evidence, management reporting, and audit preparation.
**Modeling principle**: Compliance and governance requirements are modeled as versioned control catalogs, TenantPilot technical interpretations, evidence mappings, evaluation rules, manual attestations, and customer/MSP profiles, not as hardcoded framework-specific rules. Readiness views, evidence packs, and auditor outputs are generated from that shared domain model.
**Modeling principle**: Compliance and governance requirements are modeled through a framework-neutral canonical control catalog plus technical interpretations and versioned framework overlays, not as separate technical object worlds per framework. Readiness views, evidence packs, baseline libraries, and auditor outputs are generated from that shared domain model.
- Separate framework source versions, TenantPilot interpretation versions, and customer/MSP profile versions
- Map controls to evidence sources, evaluation rules, and manual attestations when automation is partial
- Keep BSI / NIS2 / CIS views as reporting layers on top of the shared control model
**Layering**:
- **S1**: framework-neutral Canonical Control Catalog plus TenantPilot technical interpretations as the normative control core
- **S2**: CIS Baseline Library as a template and library layer built on top of the canonical catalog, not a separate control object model
- **S3**: NIS2 and BSI readiness views as mapping and readiness layers built on the same canonical catalog and evidence model
- ISO and COBIT belong primarily in governance, assurance, ISMS, and readiness overlays on top of the shared catalog, not as separate technical subject or control worlds
- Separate canonical control catalog versions, technical interpretation versions, framework overlay versions, and customer/MSP profile versions
- Map canonical controls to evidence sources, evaluation rules, and manual attestations when automation is partial
- Keep CIS baseline templates and NIS2 / BSI readiness views as downstream layers on top of the shared canonical control model
- Keep ISO / COBIT semantics in governance-assurance and ISMS-oriented overlays rather than introducing a second technical control universe
- Avoid framework-specific one-off reports that bypass the common evidence, findings, exception, and export pipeline
### Entra Role Governance

View File

@ -5,7 +5,7 @@ # Spec Candidates
>
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
**Last reviewed**: 2026-04-21 (added `My Work` candidate family and aligned it with existing promoted work)
**Last reviewed**: 2026-04-22 (promoted `Findings Notifications & Escalation v1` to Spec 224, added `Findings Notification Presentation Convergence`, added three architecture contract-enforcement candidates from the 2026-04-22 drift audit, added the repository cleanup strand from the strict read-only legacy audit, reframed the compliance-control foundation candidate into a framework-neutral canonical control catalog foundation, and aligned the control-library candidates to the S1/S2/S3 layering language)
---
@ -45,6 +45,8 @@ ## Promoted to Spec
- Finding Ownership Semantics Clarification → Spec 219 (`finding-ownership-semantics`)
- Humanized Diagnostic Summaries for Governance Operations → Spec 220 (`governance-run-summaries`)
- Findings Operator Inbox v1 → Spec 221 (`findings-operator-inbox`)
- Findings Intake & Team Queue v1 -> Spec 222 (`findings-intake-team-queue`)
- Findings Notifications & Escalation v1 → Spec 224 (`findings-notifications-escalation`)
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
@ -220,6 +222,75 @@ ### Operation Run Active-State Visibility & Stale Escalation
- **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language.
- **Priority**: high
> Architecture contract-enforcement cluster: these candidates come from the targeted repository drift audit on 2026-04-22. Most are intentionally narrower than naming, presentation, or IA cleanup work. The shared contracts already exist; the gap is that they are not yet treated as mandatory on every platform-owned path. The operation-type candidate is the deliberate exception because the audit found two active competing semantic contracts, not just a missing guardrail.
### Operation Run Link Contract Enforcement
- **Type**: hardening / contract enforcement
- **Source**: targeted repository architecture/pattern-drift audit 2026-04-22; canonical operation-link drift review
- **Problem**: TenantPilot already has a real canonical navigation contract in `OperationRunLinks` and `SystemOperationRunLinks`, but platform-owned UI and shared layers can still build `OperationRun` links through raw `route('admin.operations...')` calls. The same navigation class is therefore emitted through two parallel paths, including in shared navigation layers that otherwise already know the canonical link contract.
- **Why it matters**: This is not a missing-helper problem. It is a shared-contract bypass on a cross-cutting operator path. If it keeps spreading, tenant/workspace context, filter continuity, deep-link stability, and future operations IA changes all become more expensive because each surface can choose raw routes instead of the contract.
- **Proposed direction**:
- inventory platform-owned `OperationRun` collection/detail link producers and classify legitimate infrastructure exceptions
- move platform-owned UI and shared navigation layers to `OperationRunLinks` or `SystemOperationRunLinks`
- make collection/detail/context semantics part of the helper contract rather than repeated local route assembly
- add a lightweight guardrail that catches new raw `route('admin.operations...')` calls outside an explicit allowlist
- **Explicit non-goals**: Not a new operations IA, not a redesign of the operations pages, not a broad routing refactor for the whole repo, and not a change to `OperationRun` page content.
- **Boundary with Operation Run Active-State Visibility & Stale Escalation**: Active-state visibility owns lifecycle communication on compact and detail surfaces. This candidate owns canonical link generation and context continuity.
- **Boundary with Operator Presentation & Lifecycle Action Hardening**: Presentation hardening owns labels, badges, and action-visibility conventions. This candidate owns deep-link and collection-link contract enforcement.
- **Dependencies**: `OperationRunLinks`, `SystemOperationRunLinks`, canonical operations pages, and any repository signal or review guardrail infrastructure introduced by Spec 201.
- **Strategic sequencing**: First of this cluster. The leverage is high because the shared contract already exists and the surface area is concrete.
- **Priority**: high
### Canonical Operation Type Source of Truth
- **Type**: hardening / source-of-truth decision
- **Source**: strict read-only legacy / compatibility audit 2026-04-22; operation-type drift review
- **Problem**: The repo still carries two competing operation-type languages. `OperationCatalog` and several UI or read-model paths prefer canonical dotted names, while persistence, runtime writes, tests, registries, and supporting services still rely on historical underscore values. This is no longer just a thin alias shim; it is parallel semantic truth.
- **Why it matters**: This is now the strongest active compatibility debt in the repo. As long as underscore and dotted forms remain co-equal in specs, code, filters, registries, and tests, every new operation type or execution surface can reinforce drift instead of converging on one contract.
- **Goal**: Define one repo-wide normative operation-type language and make explicit which form is persisted, written, resolved at boundaries, and reflected in specs, resources, and tests.
- **In scope**:
- explicit decision between underscore and dotted operation-type language as repo-wide truth
- normative persistence, write, read, and resolution contract for operation types
- cleanup or narrowing of the current alias matrix in `OperationCatalog`
- convergence of `OperationCatalog`, `OperationRunType`, resources, supporting services, specs, and tests
- guardrails that prevent new dual-semantics operation types from being introduced without an explicit exit path
- **Out of scope**: cosmetic label-only renaming, generic repo-wide naming cleanup outside operation types, provider identity redesign, and Baseline Scope V2.
- **Key requirements**:
- exactly one normative operation-type language must exist
- the persisted and written truth must be explicit rather than inferred
- underscore and dotted forms must not remain permanent parallel truths
- any remaining compatibility boundary must be explicit, narrow, and exit-bounded
- specs, code, resources, and tests must converge on the same contract
- **Boundary with Operations Naming Harmonization Across Run Types, Catalog, UI, and Audit**: naming harmonization owns visible operator vocabulary and naming grammar. This candidate owns the underlying semantic and persistence contract for operation types.
- **Boundary with Operator Presentation & Lifecycle Action Hardening**: presentation hardening owns operator-facing labels and badges. This candidate owns the operation-type truth that capability, triage, provider, and related behavior decisions depend on.
- **Dependencies**: `OperationCatalog`, `OperationRunType`, capability/triage/provider decision points, operation resources and link helpers, and any repository guardrail infrastructure introduced by Spec 201.
- **Strategic sequencing**: Third step of the repository cleanup strand, after `Dead Transitional Residue Cleanup` and `Onboarding State Fallback Retirement`.
- **Priority**: high
### Platform Vocabulary Boundary Enforcement for Governed Subject Keys
- **Type**: hardening / platform-boundary clarification
- **Source**: targeted repository architecture/pattern-drift audit 2026-04-22; governed-subject vocabulary drift review
- **Problem**: The repo already treats `policy_type` as compatibility vocabulary rather than active platform language, yet platform-visible query keys, page state, filters, and read-model builders still expose `policy_type` alongside or instead of governed-subject terms. Legacy terminology therefore survives not just in storage or adapters, but in platform-visible boundaries.
- **Why it matters**: This undercuts the repo's own vocabulary migration contract. Contributors and operators continue to read an Intune-shaped key as active platform language even where the platform already has canonical governed-subject terms.
- **Proposed direction**:
- inventory platform-visible uses of `policy_type` and adjacent legacy keys in query/state/read-model boundaries
- distinguish allowed compatibility or storage boundaries from platform-visible vocabulary surfaces
- move platform-visible filter/state/query/read-model contracts to canonical governed-subject terminology
- preserve legacy input compatibility only through explicit normalizers or adapters
- add a guardrail that catches new platform-visible legacy key exposure outside allowed boundary zones
- **Explicit non-goals**: Not a full storage-column rename sweep, not a broad Intune debranding project, not a full governance-taxonomy redesign, and not a generic repo-wide terminology cleanup campaign.
- **Boundary with Spec 202 (Governance Subject Taxonomy)**: Spec 202 defines the taxonomy and canonical governed-subject vocabulary. This candidate enforces which keys are allowed to remain platform-visible at runtime boundaries.
- **Boundary with Spec 204 (Platform Core Vocabulary Hardening)**: If Spec 204 remains the active vocabulary-hardening vehicle, this candidate should be absorbed as the governed-subject boundary-enforcement slice rather than promoted as a second parallel vocabulary spec.
- **Dependencies**: Spec 202, `PlatformVocabularyGlossary`, `PlatformSubjectDescriptorNormalizer`, and the baseline/governance builder surfaces that currently expose platform-visible legacy keys.
- **Strategic sequencing**: Third of this cluster. It should follow the operations contract-enforcement work unless Spec 204 is revived sooner and absorbs it directly.
- **Priority**: high
> Recommended sequence for this cluster:
> 1. **Operation Run Link Contract Enforcement**
> 2. **Canonical Operation Type Source of Truth**
> 3. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
>
> If Spec 204 is reactivated as the live vocabulary-hardening vehicle, candidate 3 should fold into that spec instead of creating a competing parallel effort.
### Baseline Snapshot Fidelity Semantics
- **Type**: hardening
- **Source**: semantic clarity & operator-language audit 2026-03-21
@ -348,42 +419,18 @@ ### Tenant Operational Readiness & Status Truth Hierarchy
- AC5: Operator can tell within seconds whether tenant is usable / usable with follow-up / limited / blocked / in need of action
- AC6: Recent successful operations reinforce confidence where appropriate but do not silently overwrite explicit verification truth
- AC7: Primary tenant status communication suitable for MSP/enterprise use without requiring tribal knowledge to interpret contradictions
- **Boundary with Tenant App Status False-Truth Removal**: That candidate is a quick, bounded removal of the single most obvious legacy truth field (`Tenant.app_status`). This candidate defines the broader truth hierarchy and presentation model that decides how all tenant status domains interrelate. The false-truth removal is a subset action that can proceed independently as a quick win; this candidate provides the architectural direction that prevents future truth leakage.
- **Boundary with Dead Transitional Residue Cleanup**: That cleanup strand absorbs the earlier quick removal of the single most obvious legacy truth field (`Tenant.app_status`) plus adjacent dead-symbol residue. This candidate defines the broader truth hierarchy and presentation model that decides how all tenant status domains interrelate. The residue cleanup is a subset action that can proceed independently as a quick win; this candidate provides the architectural direction that prevents future truth leakage.
- **Boundary with Provider Connection Status Vocabulary Cutover**: Provider vocabulary cutover owns the `ProviderConnection` model-level status field cleanup (canonical enum sources replacing legacy varchar fields). This candidate owns how provider truth appears on tenant-level surfaces in relation to other readiness domains. The vocabulary cutover provides cleaner provider truth; this candidate defines where and how that truth is presented alongside lifecycle, verification, RBAC, and evidence.
- **Boundary with Inventory, Provider & Operability Semantics**: That candidate normalizes how inventory capture mode, provider health/prerequisites, and verification results are presented on their own surfaces. This candidate defines the tenant-level integration layer that synthesizes those domain truths into a single headline readiness answer. Complementary: domain-level semantic cleanup feeds into the readiness model.
- **Boundary with Spec 161 (Operator Explanation Layer)**: The explanation layer defines cross-cutting interpretation patterns for degraded/partial/suppressed results. This candidate defines the tenant-specific readiness truth hierarchy. The explanation layer may inform how degraded readiness states are explained, but the readiness model itself is tenant-domain-specific.
- **Boundary with Spec 214 (Governance Operator Outcome Compression)**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains.
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Tenant App Status False-Truth Removal (quick win that removes the most obvious legacy truth — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Tenant App Status False-Truth Removal, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Tenant App Status False-Truth Removal removes the most obvious legacy truth as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input.
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Dead Transitional Residue Cleanup (quick win that removes the most obvious legacy truth plus adjacent dead residue — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Dead Transitional Residue Cleanup, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Dead Transitional Residue Cleanup removes the most obvious legacy truth and adjacent dead residue as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input.
- **Priority**: high
> Findings execution layer cluster: complementary to existing Spec 154 (`finding-risk-acceptance`). Keep these split so prioritization can pull workflow semantics, operator work surfaces, alerts, external handoff, and later portfolio operating slices independently instead of collapsing them into one oversized "Findings v2" spec.
### Findings Intake & Team Queue v1
- **Type**: workflow execution / team operations
- **Source**: findings execution layer candidate pack 2026-04-17; missing intake surface before personal assignment
- **Problem**: A personal inbox does not solve how new or unassigned findings enter the workflow. Operators need an intake surface before work is personally assigned.
- **Why it matters**: Without intake, backlog triage stays hidden in general-purpose lists and unassigned work becomes easy to ignore or duplicate.
- **Proposed direction**: Introduce unassigned and needs-triage views, an optional claim action, and basic shared-worklist conventions; use filters or tabs that clearly separate intake from active execution; make the difference between unowned backlog and personally assigned work explicit.
- **Explicit non-goals**: Full team model, capacity planning, auto-routing, and load-balancing logic.
- **Dependencies**: Ownership semantics, findings filters/tabs, open-status definitions.
- **Roadmap fit**: Findings Workflow v2; prerequisite for a broader team operating model.
- **Strategic sequencing**: Third, after personal inbox foundations exist.
- **Priority**: high
### Findings Notifications & Escalation v1
- **Type**: alerts / workflow execution
- **Source**: findings execution layer candidate pack 2026-04-17; gap between assignment metadata and actionable control loop
- **Problem**: Assignment, reopen, due, and overdue states currently risk becoming silent metadata unless operators keep polling findings views.
- **Why it matters**: Due dates without reminders or escalation are visibility, not control. Existing alert foundations only create operator value if findings workflow emits actionable events.
- **Proposed direction**: Add notifications for assignment, system-driven reopen, due-soon, and overdue states; introduce minimal escalation to owner or a defined role; explicitly consume the existing alert and notification infrastructure rather than building a findings-specific delivery system.
- **Explicit non-goals**: Multi-stage escalation chains, a large notification-preference center, and bidirectional ticket synchronization.
- **Dependencies**: Ownership semantics, operator inbox/intake surfaces, due/SLA logic, alert plumbing.
- **Roadmap fit**: Findings workflow hardening on top of the existing alerting foundation.
- **Strategic sequencing**: After inbox and intake exist so notifications land on meaningful destinations.
- **Priority**: high
### Assignment Hygiene & Stale Work Detection
- **Type**: workflow hardening / operations hygiene
- **Source**: findings execution layer candidate pack 2026-04-17; assignment lifecycle hygiene gap analysis
@ -396,6 +443,25 @@ ### Assignment Hygiene & Stale Work Detection
- **Strategic sequencing**: Shortly after ownership semantics, ideally alongside or immediately after notifications.
- **Priority**: high
### Findings Notification Presentation Convergence
- **Type**: workflow hardening / cross-cutting presentation
- **Source**: Spec 224 follow-up review 2026-04-22; shared notification-pattern drift analysis
- **Problem**: Spec 224 closed the functional delivery gap for findings notifications, but the current in-app findings path appears to compose its presentation locally instead of fully extending the existing shared operator-facing notification presentation path. The result is not a second transport stack, but a second presentation path for the same interaction type.
- **Why it matters**: Notifications are part of TenantPilot's operator-facing decision system, not just incidental UI. If findings notifications keep a local presentation language while operation or run notifications follow a different shared path, the product accumulates UX drift, duplicated payload semantics, and a higher risk that future alerts, assignment reminders, risk-acceptance renewals, and later `My Work` entry surfaces will grow another parallel path instead of converging.
- **Proposed direction**:
- inventory the current in-app / database-notification presentation paths and explicitly separate delivery/routing, stored payload, presentation contract, and deep-link semantics
- define one repo-internal shared presentation contract for operator-facing database notifications that covers at least title, body, tone or status, icon, primary action, deep link, and optional supporting context
- align findings in-app notifications to that shared path without changing the delivery semantics, recipient resolution, dedupe or fingerprint logic, or optional external-copy behavior introduced by Spec 224
- add contract-level regression tests and guardrail notes so future notification types extend the shared presentation path instead of building local Filament payloads directly
- **Explicit non-goals**: Not a redesign of the alerting or routing system. Not a remodelling of external notification targets or alert rules. Not a full `My Work` or inbox implementation. Not an immediate full-sweep unification of every historical notification class in the repo. Not a rewrite of escalation rules or notification-content priority.
- **Dependencies**: Spec 224 (`findings-notifications-escalation`), existing operator-facing in-app notification paths (especially operation/run notifications), repo-wide cross-cutting presentation guardrails, and any current shared notification UX helpers or presenters.
- **Boundary with Spec 224**: Spec 224 owns who gets notified, when, by which event type, with what fingerprint and optional external copies. This candidate keeps that delivery path intact and converges only the in-app presentation path.
- **Boundary with Operator Presentation & Lifecycle Action Hardening**: Presentation hardening is the broader shared-convention candidate across many lifecycle-driven surfaces. This candidate is narrower: it uses in-app notifications as the first bounded convergence target for a shared operator-facing notification presentation contract.
- **Boundary with My Work — Actionable Alerts**: `My Work — Actionable Alerts` decides which alert-like items deserve admission into a personal work surface. This candidate decides how operator-facing in-app notifications should present themselves consistently before any future `My Work` routing consumes them.
- **Roadmap fit**: Findings Workflow v2 hardening plus cross-cutting operator-notification consistency.
- **Strategic sequencing**: Best tackled soon after Spec 224 while the findings notification path is still fresh and before more notification-bearing domains adopt the same local composition pattern.
- **Priority**: high
### Finding Outcome Taxonomy & Verification Semantics
- **Type**: workflow semantics / reporting hardening
- **Source**: findings execution layer candidate pack 2026-04-17; status/outcome reporting gap analysis
@ -441,58 +507,105 @@ ### Cross-Tenant Findings Workboard v1
- **Roadmap fit**: MSP portfolio and operations.
- **Priority**: medium-low
### Compliance Control Catalog & Interpretation Foundation
### Canonical Control Catalog Foundation
- **Type**: foundation
- **Source**: roadmap/principles alignment 2026-04-10, compliance modeling discussion, future framework-oriented readiness planning
- **Source**: governance-engine gap analysis 2026-04-22, roadmap/principles alignment 2026-04-10, compliance modeling discussion, future framework-oriented readiness planning
- **Vehicle**: new standalone candidate
- **Problem**: TenantPilot now has an explicit roadmap direction toward BSI-/NIS2-/CIS-oriented readiness views and executive review packs, but it still lacks the bounded domain foundation that those outputs should consume. There is no explicit, versioned model for external framework sources, TenantPilot's technical interpretation of those controls, customer/MSP profile variants, or the mapping from controls to evidence, evaluation rules, and manual attestations. Without that foundation, framework support will predictably drift into hardcoded report logic, one-off service rules, and special-case exports. The same underlying governance evidence will be translated differently per feature, and changes to framework source versions, TenantPilot interpretation logic, or customer profile overrides will become impossible to track independently.
- **Why it matters**: This is the difference between "framework-themed reports" and a sustainable compliance-readiness product. Enterprise and MSP buyers do not need TenantPilot to become a certification engine, but they do need repeatable, reviewable, version-aware mappings from governance evidence to framework-oriented control statements. A shared control-model foundation avoids three long-term failure modes: duplicated rule logic across multiple readiness/report features, inability to explain which product interpretation produced a given readiness result, and brittle customer-specific customizations that fork framework behavior instead of profiling it. If the product wants BSI/NIS2/CIS views later, it should first know which control source version, which TenantPilot interpretation version, and which customer profile produced each answer.
- **Layer position**: **S1** — normative control core
- **Problem**: TenantPilot already has a real governance engine across baseline profiles, baseline capture and compare, drift findings, findings workflow, exceptions, alerts, stored reports, evidence items, and tenant review packs, but it still lacks the shared canonical object those features should point at. Today the product risks modeling control meaning in three competing places: framework-specific overlays such as NIS2, BSI, ISO, or COBIT mappings; Microsoft service- or subject-specific lists such as Entra, Intune, Exchange, or Purview subjects; or feature-local assumptions embedded separately in baseline, drift, findings, evidence, and report logic. Without a framework-neutral canonical control catalog, the same technical control objective will be duplicated, evidence and control truth will blur together, and later readiness or reporting work will inherit inconsistent semantics.
- **Why it matters**: This is the missing structural bridge between the current governance engine and later compliance-readiness overlays. Operators, customers, and auditors need one stable answer to "what control is this about?" before the platform can credibly say which Microsoft subjects support it, which evidence proves it, which findings violate it, or which frameworks map to it. A canonical control layer prevents framework duplication, keeps control, evidence, finding, exception, and report semantics aligned, and lets the product communicate detectability honestly instead of over-claiming technical verification.
- **Proposed direction**:
- Introduce a bounded compliance domain model with explicit concepts for framework registry, framework versions, control catalog entries, TenantPilot interpretation records, customer/MSP profiles, profile overrides, control-to-evidence mappings, evaluation rules, and manual attestations
- Separate three independent version layers: framework source version, TenantPilot interpretation version, and customer/MSP profile version
- Allow each control to declare automation posture such as fully automatable, partially automatable, manual-attestation-required, or outside-product-scope
- Map one control to multiple evidence sources and evaluation rules, and allow one evidence source to support multiple controls
- Treat risk exceptions, findings, stored reports, and readiness views as downstream consumers of the control model rather than the place where framework logic lives
- Prefer pack/import-based control lifecycle management with preview, diff, activate, archive, and migration semantics over manual per-control CRUD as the primary maintenance path
- Start with lightweight, product-owned control metadata and interpretation summaries rather than assuming full storage of normative framework text inside the product
- Introduce a framework-neutral canonical control catalog centered on control themes and objectives rather than framework clauses or raw Microsoft API objects
- Define canonical domains and subdomains plus stable product-wide control keys that outlive individual APIs, workloads, or framework versions
- Classify each control by control class, detectability class, evaluation strategy, evidence archetypes, and artifact suitability for baseline, drift, findings, exceptions, reports, and evidence packs
- Add a Microsoft subject-binding layer that links one canonical control to supported subject families, workloads, and signals without collapsing the control model into service-specific schema mirrors
- Start with a deliberately small seed catalog of high-value tenant-near control families such as strong authentication, conditional access, privileged access exposure, guest or cross-tenant boundaries, endpoint compliance and hardening, sharing boundaries, mail protection, audit retention, data protection readiness, and delegated admin boundaries
- Make baseline profiles, compare and drift, findings, exceptions, stored reports, and evidence items able to reference a `canonical_control_key` or equivalent control-family contract instead of each feature inventing local control meaning
- Keep framework mappings as a later overlay: prepare mapping structure now if useful, but do not make NIS2, BSI, ISO, COBIT, or similar frameworks the primary shape of the foundation
- **Scope boundaries**:
- **In scope**: framework registry/version model, control catalog foundation, interpretation/profile/override model, evidence and evaluation mapping model, manual attestation linkage, framework-pack import and diff lifecycle, bounded admin/registry surfaces where required to manage activation state and profile variants
- **Out of scope**: formal certification claims, legal/compliance advice, full framework-text publishing, comprehensive support for every control in every standard, broad stakeholder-facing reporting UI, one-off PDF generation, posture scoring models, or replacing the evidence domain with a second artifact store
- **In scope**: canonical control vocabulary, domain and subdomain taxonomy, stable canonical keys, detectability and evaluation classifications, evidence archetypes, Microsoft subject binding model, a small seed catalog for priority control families, and integration contracts for baseline, findings, exceptions, evidence, and reports
- **Out of scope**: full framework catalogs, full NIS2, BSI, ISO, COBIT, or similar mappings, exhaustive Microsoft service coverage, giant control-library breadth, a full attestation engine, stakeholder-facing readiness or report UI, posture scoring models, or replacing the evidence domain with a second artifact store
- **Explicit non-goals**:
- Not a certification engine or legal interpretation layer
- Not a hardcoded per-framework report generator
- Not a framework-first registry where the same control is duplicated once per standard
- Not a mirror of raw Microsoft API payload shapes as the product's control model
- Not a CIS-specific baseline library or template pack layer; that belongs above the catalog, not inside it
- Not a requirement to ingest every framework in full before the first useful control family ships
- Not a promise that every control becomes fully automatable; manual attestation remains a first-class path
- Not a promise that every control becomes directly technically evaluable; indirect, attested, and external-evidence-only controls remain first-class
- **Acceptance points**:
- The system can represent and distinguish a framework source version, a TenantPilot interpretation version, and a customer/MSP profile version for the same control family
- A single control can map to multiple evidence requirements and evaluation rules, with optional manual attestation where automation is incomplete
- The model can express whether a control is fully automatable, partially automatable, manual-only, or outside current product scope
- A framework-pack update can preview new, changed, and retired controls before activation
- Framework-oriented readiness/reporting work can consume the shared model without introducing hardcoded BSI/NIS2/CIS rule paths in presentation features
- **Boundary with Evidence Domain Foundation**: Evidence Domain Foundation owns evidence capture, storage, completeness, and immutable artifacts. This candidate owns the normative control model, product interpretation layer, and mapping from controls to those evidence artifacts.
- **Boundary with Spec 154 (Finding Risk Acceptance Lifecycle)**: Risk Acceptance owns the lifecycle for documented deviations once a control gap or finding exists. This candidate owns how controls are modeled, interpreted, and linked to evidence before any exception is approved.
- **Boundary with Compliance Readiness & Executive Review Packs**: Compliance Readiness owns stakeholder-facing views, review-pack composition, and report delivery. This candidate owns the shared framework/control layer those views should consume so readiness output does not hardcode framework semantics locally.
- **Dependencies**: Spec 153 (evidence-domain-foundation) as a soft dependency for the final evidence-mapping contract, findings and exception workflow direction, StoredReports / review-pack export maturity for downstream consumers
- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 154 (finding-risk-acceptance), Spec 155 (tenant-review-layer), Compliance Readiness & Executive Review Packs, Security Posture Signals Foundation, Entra Role Governance, SharePoint Tenant-Level Sharing Governance
- **Strategic sequencing**: Best tackled before any substantial BSI/NIS2/CIS-oriented readiness views or auditor-pack expansion, and after or in parallel with Evidence Domain Foundation hardens the evidence side of the contract. This is not required to finish current R1/R2 governance hardening, but it should land before framework-facing readiness output becomes a real product lane.
- **Priority**: medium
- The platform can represent canonical domains, subdomains, and controls with stable keys independent of framework source versions
- Every seed control declares control class, detectability class, evaluation strategy, and at least one evidence archetype
- Every seed control can declare whether it is baseline-capable, drift-capable, finding-capable, exception-capable, and report or evidence-pack-capable
- The model can bind one canonical control to multiple Microsoft subject families or signal sources without redefining the control per workload
- Baselines, findings, evidence, exceptions, and later readiness or reporting work have a defined path to consume the canonical control layer instead of hardcoding local control semantics
- The foundation can explicitly represent controls that are direct-technical, indirect-technical, workflow-attested, or external-evidence-only without collapsing them into one false compliant/non-compliant path
- **Boundary with Spec 202 (Governance Subject Taxonomy and Baseline Scope V2)**: Spec 202 defines governed-subject vocabulary and baseline-scope input contracts. This candidate defines the higher-order canonical control objects that can bind to those subject families and later unify baseline, findings, evidence, and reporting semantics above raw governed-subject lists.
- **Boundary with Evidence Domain Foundation**: Evidence Domain Foundation owns evidence capture, completeness, freshness, and immutable artifacts. This candidate owns the canonical control definitions those evidence artifacts can support and the detectability and evaluation semantics that explain what the evidence means.
- **Boundary with Spec 154 (Finding Risk Acceptance Lifecycle)**: Risk Acceptance owns the lifecycle for approved deviations once a finding or control gap exists. This candidate owns the stable control object that exceptions and compensating-control semantics should refer to.
- **Boundary with CIS Baseline Library**: The CIS library owns reusable template packs and benchmark libraries built on top of canonical controls. This candidate owns the control ontology itself and must not absorb CIS-specific expected-state packs into the normative control core.
- **Boundary with Compliance Readiness & Executive Review Packs**: Compliance Readiness owns stakeholder-facing framework and readiness views plus report packaging. This candidate owns the framework-neutral control core those later views should map onto instead of inventing per-framework local logic.
- **Dependencies**: Spec 202 (governed-subject vocabulary), Spec 153 (evidence-domain-foundation) for evidence-contract alignment, Spec 154 (finding-risk-acceptance), baseline and drift foundations, and downstream stored-report or review-pack consumers
- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 154 (finding-risk-acceptance), Spec 155 (tenant-review-layer), Spec 202 (governance-subject-taxonomy), CIS Baseline Library, Compliance Readiness & Executive Review Packs, Security Posture Signals Foundation, Entra Role Governance, SharePoint Tenant-Level Sharing Governance
- **Strategic sequencing**: This is best treated as the bridge between the current governance engine and later framework-facing readiness work. It should land before substantial NIS2, BSI, ISO, COBIT, or similar mapping and auditor-pack expansion, and ideally before evidence or review surfaces hardcode control meaning locally.
- **Roadmap fit**: Early-R2 foundation layer between the shipped governance engine and later compliance-readiness overlays.
- **Priority**: high
### CIS Baseline Library
- **Type**: feature / library layer
- **Source**: roadmap layering alignment 2026-04-22, baseline-library planning, future benchmark/template packaging
- **Vehicle**: new standalone candidate
- **Layer position**: **S2** — catalog-based template and library layer
- **Problem**: Once TenantPilot has a framework-neutral canonical control catalog, it still needs a reusable library layer for widely recognized baseline packs such as CIS without turning CIS into the product's primary control ontology. Today that distinction does not exist explicitly in the candidate stack. Without a separate library-layer candidate, CIS guidance will tend to leak downward into the canonical catalog or upward into readiness views, blurring three different concerns: what a control is, what a reusable benchmark template recommends, and how a framework-specific readiness statement should be derived.
- **Why it matters**: CIS is valuable to TenantPilot as a reusable template and benchmark library, not as the platform's canonical control object model. MSPs and operators need versioned, explainable baseline packs they can adopt, compare against, and use as a curated starting point. Keeping CIS in a library layer preserves the framework-neutral core, makes benchmark evolution manageable, and avoids letting one external source define the entire product architecture.
- **Proposed direction**:
- Introduce versioned CIS-aligned template packs and baseline libraries that map onto canonical controls rather than redefining them
- Keep library-pack lifecycle explicit: import or activate, preview, diff, archive, and supersede without mutating the underlying control ontology
- Let one library item express expected-state guidance, applicability, severity or importance hints, and subject-level realization on top of the canonical control catalog
- Allow baseline profiles and later compare or reporting features to reference CIS library packs as curated starters or benchmark templates rather than a second control taxonomy
- Preserve room for future non-CIS libraries such as company standards, MSP reference packs, or vertical-specific benchmark packs built on the same catalog
- **Scope boundaries**:
- **In scope**: CIS-aligned library-pack model, versioning and lifecycle, mapping to canonical controls and governed subjects, baseline-template consumption paths, and bounded operator-visible library metadata
- **Out of scope**: replacing the canonical control catalog, full framework readiness mapping, certification semantics, stakeholder-facing readiness reporting, or a generic pack marketplace
- **Explicit non-goals**:
- Not a second control ontology beside the canonical catalog
- Not a readiness or evidence-mapping layer for NIS2, BSI, ISO, or COBIT
- Not a requirement that every canonical control must have a CIS template entry
- Not a forced replacement of operator-defined baseline profiles; library packs remain reusable starting points and references
- **Acceptance points**:
- The platform can represent a CIS library version independently from canonical catalog versions and framework-readiness overlays
- A CIS library entry can point to canonical controls and governed-subject realizations without redefining the control itself
- Baseline workflows can consume CIS library packs as reusable templates or benchmark references without collapsing the product into a CIS-first model
- Library-pack evolution can show added, changed, retired, or superseded guidance without changing historical control meaning
- Future company-standard or MSP-specific libraries can reuse the same template-layer mechanics without inventing another control taxonomy
- **Boundary with Canonical Control Catalog Foundation**: The canonical catalog defines what the control is. The CIS library defines one reusable benchmark or template expression built on top of that control.
- **Boundary with Compliance Readiness & Executive Review Packs**: Compliance Readiness owns mapping, evidence assembly, and readiness statements for frameworks or stakeholder views. The CIS library owns reusable benchmark packs and templates, not readiness scoring or framework interpretation.
- **Dependencies**: Canonical Control Catalog Foundation, Spec 202 (governed-subject vocabulary), baseline and drift foundations, and evidence alignment where benchmark reporting later consumes library references
- **Related specs / candidates**: Canonical Control Catalog Foundation, Compliance Readiness & Executive Review Packs, Spec 202 (governance-subject-taxonomy), Spec 203 (baseline-compare-strategy), company standards / policy quality work
- **Strategic sequencing**: Conceptually this is the S2 layer between the canonical control core and later framework-readiness overlays. It can ship after the control foundation once the catalog and governed-subject bindings are stable enough to host reusable benchmark templates.
- **Roadmap fit**: S2 library layer for reusable benchmark and baseline packs.
- **Priority**: medium-high
### Compliance Readiness & Executive Review Packs
- **Type**: feature
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance
- **Vehicle note**: Tenant review and publication-readiness semantics should extend existing Spec 155 (`tenant-review-layer`), not become a separate candidate. This candidate remains about broader management/stakeholder-facing readiness outputs beyond the current review-layer spec.
- **Layer position**: **S3** — mapping, evidence, and readiness layer
- **Problem**: TenantPilot is building a strong evidence/data foundation (Spec 153, StoredReports, review pack export via Spec 109, findings, baselines), but there is no product-level capability that assembles this data into management-ready, customer-facing, or auditor-oriented readiness views. Enterprise customers, MSP account managers, and CISOs need structured governance outputs for recurring tenant reviews, audit preparation, and compliance conversations — not raw artifact collections or manual export assembly. The gap is not data availability; it is the absence of a dedicated readiness presentation and packaging layer that turns existing governance evidence into actionable, consumable deliverables.
- **Why it matters**: This is a core product differentiator and revenue-relevant capability for the MSP and German midmarket audience. Without it, TenantPilot remains an operator tool — powerful but invisible to the stakeholders who sign off on governance, approve budgets, and evaluate vendor value. Structured readiness outputs (lightweight BSI/NIS2/CIS-oriented views, executive summaries, customer review packs) make TenantPilot sellable as a governance review platform, not just a backup and configuration tool. This directly strengthens the MSP sales story for quarterly reviews, security health checks, and audit preparation.
- **Why it matters**: This is a core product differentiator and revenue-relevant capability for the MSP and German midmarket audience. Without it, TenantPilot remains an operator tool — powerful but invisible to the stakeholders who sign off on governance, approve budgets, and evaluate vendor value. Structured readiness outputs for NIS2, BSI, executive summaries, customer review packs, and later governance-assurance overlays make TenantPilot sellable as a governance review platform, not just a backup and configuration tool. This directly strengthens the MSP sales story for quarterly reviews, security health checks, and audit preparation.
- **Proposed direction**:
- A dedicated readiness/review presentation layer that consumes evidence domain artifacts, findings summaries, baseline/drift posture, permission posture signals, and operational health data
- Management-ready output surfaces: executive summary views, customer-facing review dashboards, structured compliance readiness pages oriented toward frameworks such as BSI Grundschutz, NIS2, and CIS — in a lightweight, non-certification sense (governance evidence, not formal compliance claims)
- Management-ready output surfaces: executive summary views, customer-facing review dashboards, and structured readiness pages oriented toward frameworks such as BSI Grundschutz and NIS2 — in a lightweight, non-certification sense (governance evidence, not formal compliance claims)
- Exportable review packs that combine multiple evidence dimensions into a single coherent deliverable (PDF or structured export) for external stakeholders
- Tenant-scoped and workspace-scoped views — individual tenant reviews as well as portfolio-level readiness summaries
- Clear separation from the Evidence Domain Foundation: evidence foundation owns curation, completeness tracking, and artifact storage; compliance readiness owns presentation, assembly, and stakeholder-facing output
- Keep ISO and COBIT in governance-, assurance-, ISMS-, and readiness-oriented overlays rather than introducing them as a separate technical control library
- Readiness views should be composable: an operator selects which dimensions to include in a review pack (e.g. baseline posture + findings summary + permission evidence + operational health) rather than a monolithic fixed report
- **Explicit non-goals**: Not a formal certification engine — TenantPilot does not certify compliance or issue attestations. Not a legal or compliance advice system. Not a replacement for the Evidence Domain Foundation (which owns the data layer). Not a generic BI dashboard or data warehouse initiative. Not a PDF-only export task — the primary value is the structured readiness view, with export as a secondary delivery mechanism. Not a reimplementation of review pack export (Spec 109 handles CSV/ZIP). Not a customer-facing analytics suite.
- **Boundary with Evidence Domain Foundation**: Evidence Domain Foundation = curation, completeness tracking, artifact storage, immutable snapshots. Compliance Readiness = presentation, assembly, framework-oriented views, stakeholder-facing outputs. Evidence Foundation is a prerequisite; Compliance Readiness is a consumer.
- **Dependencies**: Evidence Domain Foundation (data layer), review pack export (Spec 109), findings workflow (Spec 111), baseline/drift engine (Specs 116119), permission posture (Specs 104/105), audit log foundation (Spec 134)
- **Boundary with Canonical Control Catalog Foundation**: Canonical Control Catalog Foundation = framework-neutral control core, detectability semantics, and control-to-subject or evidence alignment. Compliance Readiness = framework-aware presentation, rollup, and stakeholder-facing output built on top of that shared control layer.
- **Boundary with CIS Baseline Library**: The CIS library owns reusable template packs and benchmark baselines. Compliance Readiness owns NIS2, BSI, and later governance-assurance overlays that map evidence and control coverage into readiness statements.
- **Dependencies**: Canonical Control Catalog Foundation, Evidence Domain Foundation (data layer), review pack export (Spec 109), findings workflow (Spec 111), baseline/drift engine (Specs 116119), permission posture (Specs 104/105), audit log foundation (Spec 134)
- **Strategic sequencing**: This is the S3 layer. It should consume the canonical control core and evidence model, and it should remain separate from the CIS template-library layer so benchmark packs and readiness mappings do not collapse into the same object family.
- **Priority**: medium (high strategic value, but depends on evidence foundation maturity)
### Enterprise App / Service Principal Governance
@ -998,32 +1111,63 @@ ### Provider Connection Legacy Cleanup
- **Related specs**: Spec 081 (credential migration guardrails), Spec 088 (provider connection model), Spec 137 (data-layer provider prep)
- **Priority**: medium (deferred until normalization is complete)
### Tenant App Status False-Truth Removal
- **Type**: hardening
- **Source**: legacy / orphaned truth audit 2026-03-16
- **Classification**: quick removal
- **Problem**: `Tenant.app_status` is displayed in tenant UI as current operational truth even though production code no longer writes it. Operators can see a frozen "OK" or other stale badge that does not reflect the real provider connection state.
- **Why it matters**: This is misleading operator-facing truth, not just dead schema. It creates false confidence on a tier-1 admin surface.
- **Target model**: `Tenant`
- **Canonical source of truth**: `ProviderConnection.consent_status` and `ProviderConnection.verification_status`
- **Must stop being read**: `Tenant.app_status` in `TenantResource` table columns, infolist/details, filters, and badge-domain mapping.
- **Can be removed immediately**:
- TenantResource reads of `app_status`
- tenant app-status badge domain / badge mapping usage
- factory defaults that seed `app_status`
- **Remove only after cutover**:
- the `tenants.app_status` column itself, once all UI/report/export reads are confirmed gone
- **Migration / backfill**: No backfill. One cleanup migration to drop `app_status`. `app_notes` may be dropped in the same migration only if it does not broaden the spec beyond tenant stale app fields.
- **UI / resource / policy / test impact**:
- UI/resources: remove misleading badge and filter from tenant surfaces
- Policy: none
- Tests: update `TenantFactory`, remove assertions that treat `app_status` as live truth
- **Scope boundaries**:
- In scope: remove stale tenant app-status reads and schema field
- Out of scope: provider connection UX redesign, credential migration, broader tenant health redesign
- **Dependencies**: None required if the immediate operator-facing action is removal rather than replacement with a new tenant-level derived badge.
- **Risks**: Low rollout risk. Main risk is short-term operator confusion about where to view connection health after removal.
- **Why it should be its own spec**: This is the cleanest high-severity operator-trust fix in the repo. It is bounded, low-coupling, and should not wait for the larger provider cutover work.
> Repository cleanup strand from the strict read-only legacy audit 2026-04-22:
> 1. **Dead Transitional Residue Cleanup**
> 2. **Onboarding State Fallback Retirement**
> 3. **Canonical Operation Type Source of Truth**
>
> The first two candidates remove dead or weakly justified compatibility residue. The third resolves the remaining core semantic conflict that still spans persistence, registries, resources, specs, and tests.
### Dead Transitional Residue Cleanup
- **Type**: hardening / cleanup
- **Source**: strict read-only legacy / compatibility audit 2026-04-22; orphaned-truth residue review
- **Absorbs / broadens**: the earlier `Tenant App Status False-Truth Removal` slice plus adjacent dead-symbol cleanup
- **Problem**: The repo still contains smaller transitional residues that no longer carry active product semantics but still survive in code, badges, factories, fixtures, and tests. Confirmed examples include unused deprecated `BaselineProfile::STATUS_*` constants and orphaned tenant app-status residue that now mainly persists as badge, factory, fixture, and test conservat.
- **Why it matters**: Each residue is small, but together they blur the real domain language, preserve dead semantics in tests, and make later cleanup harder because it is no longer obvious which symbols are still authoritative.
- **Goal**: Remove dead transitional residue that no longer drives runtime, UI, filter, cast, or API behavior, and clean up associated tests, fixtures, and factories in the same change.
- **In scope**:
- remove unused deprecated `BaselineProfile::STATUS_*` constants
- remove orphaned tenant app-status badge, factory, fixture, and test residue
- verify that no hidden runtime, UI, filter, cast, or API dependency still exists before removal
- document the remaining active domain language after cleanup
- **Out of scope**: operation-type dual semantics, onboarding state fallbacks, provider identity or migration review, Baseline Scope V2, and spec-backed legacy redirect paths.
- **Key requirements**:
- dead deprecated constants must be removed when no productive reference remains
- orphaned badge, status, factory, and fixture residue must not survive as silent compatibility lore
- cleanup must include tests and fixtures in the same change
- removal must prove there is no hidden runtime, UI, filter, cast, or API dependency
- the remaining canonical domain language must be clearer after cleanup
- **Acceptance characteristics**:
- deprecated `BaselineProfile::STATUS_*` constants are gone
- tenant app-status residue is removed or reduced to explicitly justified boundary-only remnants
- no productive references to removed symbols remain
- tests no longer conserve dead semantics
- **Boundary with Provider Connection Legacy Cleanup**: provider connection cleanup owns still-legitimate or spec-bound provider transitional paths. This candidate only removes dead residue with no active product role.
- **Strategic sequencing**: first step of the repository cleanup strand.
- **Priority**: high
### Onboarding State Fallback Retirement
- **Type**: hardening / cleanup
- **Source**: strict read-only legacy / compatibility audit 2026-04-22; onboarding state-key audit
- **Problem**: Onboarding still carries mixed old and new state keys and service-level fallback reads between older fields and newer canonical fields. Some keys still have distinct roles, such as mutable selector state versus trusted persisted state, but others now appear to survive only as historical fallback.
- **Why it matters**: In a pre-production repo, silent fallback between state classes keeps semantic boundaries fuzzy and makes future trusted-state hardening harder. New work can accidentally bind to retired keys because the service layer still tolerates them.
- **Goal**: Retire pure onboarding fallback keys and make the remaining split between selector state and trusted persisted state explicit.
- **In scope**:
- audit and retire pure fallback keys such as `verification_run_id` and `bootstrap_run_ids` if no current contract still needs them
- remove corresponding fallback reads in onboarding services
- align contracts and tests to the remaining active key language
- document which onboarding keys remain active and why
- **Out of scope**: removing `selected_provider_connection_id` while it still has an active contract role, provider identity or migration review, and generic session or trusted-state architecture redesign.
- **Key requirements**:
- onboarding keys with no active contractual role must be removed when they survive only as fallback
- selector state and trusted state must be semantically separated
- silent fallback between semantically different state classes must not persist without an explicit current contract
- specs, contracts, and service read behavior must converge on the same remaining keys
- tests must stop conserving retired fallback fields
- **Risks / open questions**:
- `selected_provider_connection_id` still appears in current contracts and should not be treated as dead residue by default
- some onboarding keys may require contract cleanup before code cleanup can be completed cleanly
- **Strategic sequencing**: second step of the repository cleanup strand, after `Dead Transitional Residue Cleanup` and before `Canonical Operation Type Source of Truth`.
- **Priority**: high
### Provider Connection Status Vocabulary Cutover

View File

@ -43,9 +43,15 @@ importers:
apps/website:
dependencies:
'@iconify-json/lucide':
specifier: ^1.2.102
version: 1.2.102
astro:
specifier: ^6.0.0
version: 6.1.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)
astro-icon:
specifier: ^1.1.5
version: 1.1.5
devDependencies:
'@playwright/test':
specifier: ^1.59.1
@ -65,6 +71,12 @@ importers:
packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@antfu/utils@8.1.1':
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
'@astrojs/compiler@3.0.1':
resolution: {integrity: sha512-z97oYbdebO5aoWzuJ/8q5hLK232+17KcLZ7cJ8BCWk6+qNzVxn/gftC0KzMBUTD8WAaBkPpNSQK6PXLnNrZ0CA==}
@ -567,6 +579,18 @@ packages:
cpu: [x64]
os: [win32]
'@iconify-json/lucide@1.2.102':
resolution: {integrity: sha512-Dm3EEqu5NrmzyDMB2U1+8yroEj2/dB9V4KlH0m/szwwF/ofSf0cPaGTZqkd1aExXjCor+vU53ttRMCGuXf+/cg==}
'@iconify/tools@4.2.0':
resolution: {integrity: sha512-WRxPva/ipxYkqZd1+CkEAQmd86dQmrwH0vwK89gmp2Kh2WyyVw57XbPng0NehP3x4V1LzLsXUneP1uMfTMZmUA==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
@ -720,6 +744,10 @@ packages:
cpu: [x64]
os: [win32]
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -1040,9 +1068,17 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@ -1065,6 +1101,9 @@ packages:
array-iterate@2.0.1:
resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==}
astro-icon@1.1.5:
resolution: {integrity: sha512-CJYS5nWOw9jz4RpGWmzNQY7D0y2ZZacH7atL2K9DeJXJVaz7/5WrxeyIxO8KASk1jCM96Q4LjRx/F3R+InjJrw==}
astro@6.1.4:
resolution: {integrity: sha512-SRy1bONuCHkGWhI5JiWCQKVDVbeaXOikjAVZs/Nz+lvUvubtdLoZfnacmuZHQ9RL2IOkU54M8/qZYm9ypJDKrg==}
engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'}
@ -1086,6 +1125,9 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -1109,10 +1151,21 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
cheerio@1.2.0:
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
engines: {node: '>=20.18.1'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
ci-info@4.4.0:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
@ -1143,6 +1196,10 @@ packages:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
common-ancestor-path@2.0.0:
resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==}
engines: {node: '>= 18'}
@ -1152,6 +1209,12 @@ packages:
engines: {node: '>=18'}
hasBin: true
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
cookie-es@1.2.3:
resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==}
@ -1169,6 +1232,10 @@ packages:
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
css-tree@2.3.1:
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
@ -1344,6 +1411,12 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
encoding-sniffer@0.2.1:
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
enhanced-resolve@5.20.1:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
@ -1356,6 +1429,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@ -1404,9 +1481,17 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
extract-zip@2.0.1:
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
engines: {node: '>= 10.17.0'}
hasBin: true
fast-string-truncated-width@1.2.1:
resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==}
@ -1416,6 +1501,9 @@ packages:
fast-wrap-ansi@0.1.6:
resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==}
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -1474,12 +1562,20 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stream@5.2.0:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
get-tsconfig@4.13.7:
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
globals@15.15.0:
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
engines: {node: '>=18'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@ -1542,9 +1638,16 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
iron-webcrypto@1.2.1:
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
@ -1578,6 +1681,9 @@ packages:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
laravel-vite-plugin@2.1.0:
resolution: {integrity: sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1659,6 +1765,10 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
local-pkg@1.1.2:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@ -1721,6 +1831,9 @@ packages:
mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
@ -1816,6 +1929,17 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@3.1.0:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@ -1857,6 +1981,9 @@ packages:
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
@ -1881,9 +2008,21 @@ packages:
parse-latin@7.0.0:
resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==}
parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
parse5-parser-stream@7.1.2:
resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
@ -1932,6 +2071,12 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
playwright-core@1.59.1:
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
engines: {node: '>=18'}
@ -1973,6 +2118,12 @@ packages:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
radix3@1.1.2:
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
@ -2044,6 +2195,9 @@ packages:
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
@ -2109,6 +2263,11 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
svgo@3.3.3:
resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==}
engines: {node: '>=14.0.0'}
hasBin: true
svgo@4.0.1:
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
engines: {node: '>=16'}
@ -2121,6 +2280,10 @@ packages:
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
engines: {node: '>=6'}
tar@7.5.13:
resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==}
engines: {node: '>=18'}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
@ -2181,6 +2344,10 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@7.25.0:
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
engines: {node: '>=20.18.1'}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@ -2339,6 +2506,15 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
which-pm-runs@1.1.0:
resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
engines: {node: '>=4'}
@ -2347,6 +2523,9 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@ -2358,6 +2537,10 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@ -2370,6 +2553,9 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yocto-queue@1.2.2:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
@ -2382,6 +2568,13 @@ packages:
snapshots:
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.6.0
tinyexec: 1.1.1
'@antfu/utils@8.1.1': {}
'@astrojs/compiler@3.0.1': {}
'@astrojs/internal-helpers@0.8.0':
@ -2698,6 +2891,39 @@ snapshots:
'@esbuild/win32-x64@0.27.7':
optional: true
'@iconify-json/lucide@1.2.102':
dependencies:
'@iconify/types': 2.0.0
'@iconify/tools@4.2.0':
dependencies:
'@iconify/types': 2.0.0
'@iconify/utils': 2.3.0
cheerio: 1.2.0
domhandler: 5.0.3
extract-zip: 2.0.1
local-pkg: 1.1.2
pathe: 2.0.3
svgo: 3.3.3
tar: 7.5.13
transitivePeerDependencies:
- supports-color
'@iconify/types@2.0.0': {}
'@iconify/utils@2.3.0':
dependencies:
'@antfu/install-pkg': 1.1.0
'@antfu/utils': 8.1.1
'@iconify/types': 2.0.0
debug: 4.4.3
globals: 15.15.0
kolorist: 1.8.0
local-pkg: 1.1.2
mlly: 1.8.2
transitivePeerDependencies:
- supports-color
'@img/colour@1.1.0':
optional: true
@ -2795,6 +3021,10 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.3
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -3037,8 +3267,15 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 24.12.2
optional: true
'@ungap/structured-clone@1.3.0': {}
acorn@8.16.0: {}
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
@ -3056,6 +3293,14 @@ snapshots:
array-iterate@2.0.1: {}
astro-icon@1.1.5:
dependencies:
'@iconify/tools': 4.2.0
'@iconify/types': 2.0.0
'@iconify/utils': 2.3.0
transitivePeerDependencies:
- supports-color
astro@6.1.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3):
dependencies:
'@astrojs/compiler': 3.0.1
@ -3166,6 +3411,8 @@ snapshots:
boolbase@1.0.0: {}
buffer-crc32@0.2.13: {}
buffer-from@1.1.2: {}
call-bind-apply-helpers@1.0.2:
@ -3186,10 +3433,35 @@ snapshots:
character-entities@2.0.2: {}
cheerio-select@2.1.0:
dependencies:
boolbase: 1.0.0
css-select: 5.2.2
css-what: 6.2.2
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
cheerio@1.2.0:
dependencies:
cheerio-select: 2.1.0
dom-serializer: 2.0.0
domhandler: 5.0.3
domutils: 3.2.2
encoding-sniffer: 0.2.1
htmlparser2: 10.1.0
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
parse5-parser-stream: 7.1.2
undici: 7.25.0
whatwg-mimetype: 4.0.0
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
chownr@3.0.0: {}
ci-info@4.4.0: {}
cliui@8.0.1:
@ -3214,6 +3486,8 @@ snapshots:
commander@11.1.0: {}
commander@7.2.0: {}
common-ancestor-path@2.0.0: {}
concurrently@9.2.1:
@ -3225,6 +3499,10 @@ snapshots:
tree-kill: 1.2.2
yargs: 17.7.2
confbox@0.1.8: {}
confbox@0.2.4: {}
cookie-es@1.2.3: {}
cookie@1.1.1: {}
@ -3246,6 +3524,11 @@ snapshots:
mdn-data: 2.0.28
source-map-js: 1.2.1
css-tree@2.3.1:
dependencies:
mdn-data: 2.0.30
source-map-js: 1.2.1
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
@ -3324,6 +3607,15 @@ snapshots:
emoji-regex@8.0.0: {}
encoding-sniffer@0.2.1:
dependencies:
iconv-lite: 0.6.3
whatwg-encoding: 3.1.1
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
enhanced-resolve@5.20.1:
dependencies:
graceful-fs: 4.2.11
@ -3333,6 +3625,8 @@ snapshots:
entities@6.0.1: {}
entities@7.0.1: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@ -3441,8 +3735,20 @@ snapshots:
eventemitter3@5.0.4: {}
exsolve@1.0.8: {}
extend@3.0.2: {}
extract-zip@2.0.1:
dependencies:
debug: 4.4.3
get-stream: 5.2.0
yauzl: 2.10.0
optionalDependencies:
'@types/yauzl': 2.10.3
transitivePeerDependencies:
- supports-color
fast-string-truncated-width@1.2.1: {}
fast-string-width@1.1.0:
@ -3453,6 +3759,10 @@ snapshots:
dependencies:
fast-string-width: 1.1.0
fd-slicer@1.1.0:
dependencies:
pend: 1.2.0
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@ -3505,12 +3815,18 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
get-stream@5.2.0:
dependencies:
pump: 3.0.4
get-tsconfig@4.13.7:
dependencies:
resolve-pkg-maps: 1.0.0
github-slugger@2.0.0: {}
globals@15.15.0: {}
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
@ -3630,8 +3946,19 @@ snapshots:
html-void-elements@3.0.0: {}
htmlparser2@10.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 7.0.1
http-cache-semantics@4.2.0: {}
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
iron-webcrypto@1.2.1: {}
is-docker@3.0.0: {}
@ -3654,6 +3981,8 @@ snapshots:
dependencies:
argparse: 2.0.1
kolorist@1.8.0: {}
laravel-vite-plugin@2.1.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)):
dependencies:
picocolors: 1.1.1
@ -3709,6 +4038,12 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
local-pkg@1.1.2:
dependencies:
mlly: 1.8.2
pkg-types: 2.3.0
quansync: 0.2.11
longest-streak@3.1.0: {}
lru-cache@11.3.2: {}
@ -3849,6 +4184,8 @@ snapshots:
mdn-data@2.0.28: {}
mdn-data@2.0.30: {}
mdn-data@2.27.1: {}
micromark-core-commonmark@2.0.3:
@ -4048,6 +4385,19 @@ snapshots:
dependencies:
mime-db: 1.52.0
minipass@7.1.3: {}
minizlib@3.1.0:
dependencies:
minipass: 7.1.3
mlly@1.8.2:
dependencies:
acorn: 8.16.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.3
mrmime@2.0.1: {}
ms@2.1.3: {}
@ -4080,6 +4430,10 @@ snapshots:
ohash@2.0.11: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.5:
@ -4110,10 +4464,23 @@ snapshots:
unist-util-visit-children: 3.0.0
vfile: 6.0.3
parse5-htmlparser2-tree-adapter@7.1.0:
dependencies:
domhandler: 5.0.3
parse5: 7.3.0
parse5-parser-stream@7.1.2:
dependencies:
parse5: 7.3.0
parse5@7.3.0:
dependencies:
entities: 6.0.1
pathe@2.0.3: {}
pend@1.2.0: {}
pg-cloudflare@1.3.0:
optional: true
@ -4157,6 +4524,18 @@ snapshots:
picomatch@4.0.4: {}
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
mlly: 1.8.2
pathe: 2.0.3
pkg-types@2.3.0:
dependencies:
confbox: 0.2.4
exsolve: 1.0.8
pathe: 2.0.3
playwright-core@1.59.1: {}
playwright@1.59.1:
@ -4187,6 +4566,13 @@ snapshots:
proxy-from-env@2.1.0: {}
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
quansync@0.2.11: {}
radix3@1.1.2: {}
readdirp@5.0.0: {}
@ -4331,6 +4717,8 @@ snapshots:
dependencies:
tslib: 2.8.1
safer-buffer@2.1.2: {}
sax@1.6.0: {}
semver@7.7.4: {}
@ -4420,6 +4808,16 @@ snapshots:
dependencies:
has-flag: 4.0.0
svgo@3.3.3:
dependencies:
commander: 7.2.0
css-select: 5.2.2
css-tree: 2.3.1
css-what: 6.2.2
csso: 5.0.5
picocolors: 1.1.1
sax: 1.6.0
svgo@4.0.1:
dependencies:
commander: 11.1.0
@ -4434,6 +4832,14 @@ snapshots:
tapable@2.3.2: {}
tar@7.5.13:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.3
minizlib: 3.1.0
yallist: 5.0.0
tiny-inflate@1.0.3: {}
tinyclip@0.1.12: {}
@ -4474,6 +4880,8 @@ snapshots:
undici-types@7.16.0: {}
undici@7.25.0: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@ -4584,6 +4992,12 @@ snapshots:
web-namespaces@2.0.1: {}
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3
whatwg-mimetype@4.0.0: {}
which-pm-runs@1.1.0: {}
wrap-ansi@7.0.0:
@ -4592,12 +5006,16 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
xtend@4.0.2: {}
xxhash-wasm@1.1.0: {}
y18n@5.0.8: {}
yallist@5.0.0: {}
yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {}
@ -4612,6 +5030,11 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yauzl@2.10.0:
dependencies:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
yocto-queue@1.2.2: {}
zod@4.3.6: {}

View File

@ -1,199 +0,0 @@
# Implementation Plan: Finding Ownership Semantics Clarification
**Branch**: `001-finding-ownership-semantics` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-finding-ownership-semantics/spec.md`
**Note**: The setup script reported a numeric-prefix collision with `001-rbac-onboarding`, but it still resolved the active branch and plan path correctly to this feature directory. Planning continues against the current branch path.
## Summary
Clarify the meaning of finding owner versus finding assignee across the existing tenant findings list, detail surface, responsibility-update flows, and exception-request context without adding new persistence, capabilities, or workflow services. The implementation will reuse the existing `owner_user_id` and `assignee_user_id` fields, add a derived responsibility-state presentation layer on top of current data, tighten operator-facing copy and audit/feedback wording, preserve tenant-safe Filament behavior, and extend focused Pest + Livewire coverage for list/detail semantics, responsibility updates, and exception-owner boundary cases.
## Technical Context
**Language/Version**: PHP 8.4.15 / Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
**Storage**: PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned
**Testing**: Pest v4 feature and Livewire component tests via `./vendor/bin/sail artisan test --compact`
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith in Sail/Docker locally; Dokploy-hosted Linux deployment for staging/production
**Project Type**: Laravel monolith / Filament admin application
**Performance Goals**: No new remote calls, no new queued work, no material list/detail query growth beyond current eager loading of owner, assignee, and exception-owner relations
**Constraints**: Keep responsibility state derived rather than persisted; preserve existing tenant membership validation; preserve deny-as-not-found tenant isolation; do not split capabilities or add new abstractions; keep destructive-action confirmations unchanged
**Scale/Scope**: 1 primary Filament resource, 1 model helper or equivalent derived-state mapping, 1 workflow/audit wording touchpoint, 1 exception-owner wording boundary, and 2 focused new/expanded feature test families
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native
- **Shared-family relevance**: existing tenant findings resource and exception-context surfaces only
- **State layers in scope**: page, detail, URL-query
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: standard-native-filament
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none; the feature reuses existing resource/table/infolist/action primitives and keeps exception-owner semantics local to the finding context
- **Active feature PR close-out entry**: Guardrail
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS. The feature only clarifies operator semantics on tenant-owned findings and exception artifacts; it does not change observed-state or snapshot truth.
- Read/write separation: PASS. Responsibility updates remain TenantPilot-only writes on existing finding records; no Microsoft tenant mutation is introduced. Existing destructive-like actions keep their current confirmation rules.
- Graph contract path: PASS / N/A. No Graph calls or contract-registry changes are involved.
- Deterministic capabilities: PASS. Existing canonical findings capabilities remain the source of truth; no new capability split is introduced.
- RBAC-UX: PASS. Tenant-context membership remains the isolation boundary. Non-members remain 404 and in-scope members missing `TENANT_FINDINGS_ASSIGN` remain 403 for responsibility mutations.
- Workspace isolation: PASS. The feature remains inside tenant-context findings flows and preserves existing workspace-context stabilization patterns.
- Global search: PASS. `FindingResource` already has a `view` page, so any existing global-search participation remains compliant. This feature does not add or remove global search.
- Tenant isolation: PASS. All reads and writes remain tenant-scoped and continue to restrict owner/assignee selections to current tenant members.
- Run observability / Ops-UX: PASS / N/A. No new long-running, queued, or remote work is introduced and no `OperationRun` behavior changes.
- Automation / data minimization: PASS / N/A. No new background automation or payload persistence is introduced.
- Test governance (TEST-GOV-001): PASS. The narrowest proving surface is feature-level Filament + workflow coverage with low fixture cost and no heavy-family expansion.
- Proportionality / no premature abstraction / persisted truth / behavioral state: PASS. Responsibility state remains derived from existing fields and does not create a persisted enum, new abstraction, or new table.
- UI semantics / few layers: PASS. The plan uses direct domain-to-UI mapping on `FindingResource` and an optional local helper on `Finding` rather than a new presenter or taxonomy layer.
- Badge semantics (BADGE-001): PASS with restraint. If a new responsibility badge or label is added, it stays local to findings semantics unless multiple consumers later prove centralization is necessary.
- Filament-native UI / action surface contract / UX-001: PASS. Existing native Filament tables, infolists, filters, selects, grouped actions, and modals remain the implementation path; the finding remains the sole primary inspect/open model and row click remains the canonical inspect affordance.
**Post-Phase-1 re-check**: PASS. The design keeps responsibility semantics derived, tenant-safe, and local to the existing findings resource and tests.
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: The business truth is visible in Filament list/detail rendering, responsibility action behavior, and tenant-scoped audit feedback. Unit-only testing would miss the operator-facing semantics, while browser/heavy-governance coverage would add unnecessary cost.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
- **Fixture / helper / factory / seed / context cost risks**: Low. Existing tenant/user/finding factories and tenant membership helpers are sufficient. The only explicit context risk is tenant-panel routing and admin canonical tenant state.
- **Expensive defaults or shared helper growth introduced?**: no; tests should keep tenant context explicit rather than broadening shared fixtures.
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native relief; explicit tenant-panel routing is required for authorization assertions.
- **Closing validation and reviewer handoff**: Re-run the two focused test files plus formatter on dirty files. Reviewers should verify owner versus assignee wording on list/detail surfaces, exception-owner separation, and 404/403 semantics for out-of-scope versus in-scope unauthorized users.
- **Budget / baseline / trend follow-up**: none
- **Review-stop questions**: lane fit, hidden fixture cost, accidental presenter growth, tenant-context drift in tests
- **Escalation path**: none
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: This work stays inside the existing findings responsibility contract. Only future queue/team-routing or capability-split work would justify a separate spec.
## Project Structure
### Documentation (this feature)
```text
specs/001-finding-ownership-semantics/
├── plan.md
├── spec.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── finding-responsibility.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ └── Resources/
│ │ └── FindingResource.php # MODIFY: list/detail labels, derived responsibility state, filters, action help text, exception-owner wording
│ ├── Models/
│ │ └── Finding.php # MODIFY: local derived responsibility-state helper if needed
│ └── Services/
│ └── Findings/
│ ├── FindingWorkflowService.php # MODIFY: mutation feedback / audit wording for owner-only vs assignee-only changes
│ ├── FindingExceptionService.php # MODIFY: request-exception wording if the exception-owner boundary needs alignment
│ └── FindingRiskGovernanceResolver.php # MODIFY: next-action copy to reflect orphaned-accountability semantics
└── tests/
└── Feature/
├── Filament/
│ └── Resources/
│ └── FindingResourceOwnershipSemanticsTest.php # NEW: list/detail rendering, filters, exception-owner distinction, tenant-safe semantics
└── Findings/
├── FindingAssignmentAuditSemanticsTest.php # NEW: owner-only, assignee-only, combined update feedback/audit semantics
├── FindingWorkflowRowActionsTest.php # MODIFY: assignment form/help-text semantics and member validation coverage
└── FindingWorkflowServiceTest.php # MODIFY: audit metadata and responsibility-mutation expectations
```
**Structure Decision**: Keep all work inside the existing Laravel/Filament monolith. The implementation is a targeted semantics pass over the current findings resource and workflow tests; no new folders, packages, or service families are required.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |
## Proportionality Review
- **Current operator problem**: Operators cannot tell whether a finding change transferred accountability, active remediation work, or only exception ownership.
- **Existing structure is insufficient because**: The existing fields and actions exist, but the current UI copy and derived next-step language do not establish a stable contract across list, detail, and exception flows.
- **Narrowest correct implementation**: Reuse the existing `owner_user_id` and `assignee_user_id` fields, derive responsibility state from them, and tighten wording on existing Filament surfaces and audit feedback.
- **Ownership cost created**: Low ongoing UI/test maintenance to keep future findings work aligned with the clarified contract.
- **Alternative intentionally rejected**: A new ownership framework, queue model, or capability split was rejected because the current product has not yet exhausted the simpler owner-versus-assignee model.
- **Release truth**: Current-release truth
## Phase 0 — Research (output: `research.md`)
See: [research.md](./research.md)
Research goals:
- Confirm the existing source of truth for owner, assignee, and exception owner.
- Confirm the smallest derived responsibility-state model that fits the current schema.
- Confirm the existing findings tests and Filament routing pitfalls to avoid false negatives.
- Confirm which operator-facing wording changes belong in resource copy versus workflow service feedback.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- [data-model.md](./data-model.md)
- [contracts/finding-responsibility.openapi.yaml](./contracts/finding-responsibility.openapi.yaml)
- [quickstart.md](./quickstart.md)
Design focus:
- Keep responsibility truth on existing finding and finding-exception records.
- Model responsibility state as a derived projection over owner and assignee presence rather than a persisted enum.
- Preserve exception owner as a separate governance concept when shown from a finding context.
- Keep tenant membership validation and existing `FindingWorkflowService::assign()` semantics as the mutation boundary.
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
### Surface semantics pass
- Update the findings list column hierarchy so owner and assignee meaning is explicit at first scan.
- Add a derived responsibility-state label or equivalent summary on list/detail surfaces.
- Keep exception owner visibly separate from finding owner wherever both appear.
### Responsibility mutation clarity
- Add owner/assignee help text to assignment flows.
- Differentiate owner-only, assignee-only, and combined responsibility changes in operator feedback and audit-facing wording.
- Keep current tenant-member validation and open-finding restrictions unchanged.
### Personal-work and next-action alignment
- Add or refine personal-work filters so assignee-based work and owner-based accountability are explicitly separate.
- Update next-action copy for owner-missing states so assignee-only findings are treated as accountability gaps.
### Regression protection
- Add focused list/detail rendering tests for owner-only, assignee-only, both-set, same-user, and both-null states.
- Add focused responsibility-update tests for owner-only, assignee-only, and combined changes.
- Preserve tenant-context and authorization regression coverage using explicit Filament panel routing where needed.
### Verification
- Run the two focused Pest files and any directly modified sibling findings tests.
- Run Pint on dirty files through Sail.
## Constitution Check (Post-Design)
Re-check result: PASS. The design stays inside the existing findings domain, preserves tenant isolation and capability enforcement, avoids new persisted truth or semantic framework growth, and keeps responsibility state derived from current fields.
## Filament v5 Agent Output Contract
1. **Livewire v4.0+ compliance**: Yes. The feature only adjusts existing Filament v5 resources/pages/actions that already run on Livewire v4.0+.
2. **Provider registration location**: No new panel or service providers are needed. Existing Filament providers remain registered in `apps/platform/bootstrap/providers.php`.
3. **Global search**: `FindingResource` already has a `view` page via `getPages()`, so any existing global-search participation remains compliant. This feature does not enable new global search.
4. **Destructive actions and authorization**: No new destructive actions are introduced. Existing destructive-like findings actions remain server-authorized and keep `->requiresConfirmation()` where already required. Responsibility updates continue to enforce tenant membership and the canonical findings capability registry.
5. **Asset strategy**: No new frontend assets or published views. The feature uses existing Filament tables, infolists, filters, and action modals, so deployment asset handling stays unchanged and no new `filament:assets` step is added.
6. **Testing plan**: Cover the change with focused Pest feature tests for findings resource responsibility semantics, assignment/audit wording, and existing workflow regression surfaces. No browser or heavy-governance expansion is planned.

View File

@ -1,204 +0,0 @@
# Feature Specification: Finding Ownership Semantics Clarification
**Feature Branch**: `001-finding-ownership-semantics`
**Created**: 2026-04-20
**Status**: Draft
**Input**: User description: "Finding Ownership Semantics Clarification"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Open findings already store both an owner and an assignee, but the product does not yet state clearly which field represents accountability and which field represents active execution.
- **Today's failure**: Operators can see or change responsibility on a finding without being sure whether they transferred accountability, execution work, or only exception ownership, which makes triage slower and audit language less trustworthy.
- **User-visible improvement**: Findings surfaces make accountable owner, active assignee, and exception owner visibly distinct, so operators can route work faster and read ownership changes honestly.
- **Smallest enterprise-capable version**: Clarify the existing responsibility contract on current findings list/detail/action surfaces, add explicit derived responsibility states, and align audit and filter language without creating new roles, queues, or persistence.
- **Explicit non-goals**: No team queues, no escalation engine, no automatic reassignment hygiene, no new capability split, no new ownership framework, and no mandatory backfill before rollout.
- **Permanent complexity imported**: One explicit product contract for finding owner versus assignee, one derived responsibility-state vocabulary, and focused acceptance tests for mixed ownership contexts.
- **Why now**: The next findings execution slices depend on honest ownership semantics, and the ambiguity already affects triage, reassignment, and exception routing in todays tenant workflow.
- **Why not local**: A one-surface copy fix would leave contradictory meaning across the findings list, detail view, assignment flows, personal work cues, and exception-request language.
- **Approval class**: Core Enterprise
- **Red flags triggered**: None after scope cut. This spec clarifies existing fields instead of introducing a new semantics axis.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant
- **Primary Routes**: `/admin/t/{tenant}/findings`, `/admin/t/{tenant}/findings/{finding}`
- **Data Ownership**: Tenant-owned findings remain the source of truth; this spec also covers exception ownership only when it is shown or edited from a finding-context surface.
- **RBAC**: Tenant membership is required for visibility. Tenant findings view permission gates read access. Tenant findings assign permission gates owner and assignee changes. Non-members or cross-tenant requests remain deny-as-not-found. Members without mutation permission receive an explicit authorization failure.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Tenant findings list and grouped bulk actions | yes | Native Filament + existing UI enforcement helpers | Same tenant work-queue family as other tenant resources | table, bulk-action modal, notification copy | no | Clarifies an existing resource instead of adding a new page |
| Finding detail and single-record action modals | yes | Native Filament infolist + action modals | Same finding resource family | detail, action modal, helper copy | no | Keeps one decision flow inside the current resource |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Tenant findings list and grouped bulk actions | Primary Decision Surface | A tenant operator reviews the open backlog and decides who owns the outcome and who should act next | Severity, lifecycle status, due state, owner, assignee, and derived responsibility state | Full evidence, audit trail, run metadata, and exception details | Primary because it is the queue where responsibility is routed at scale | Follows the operators “what needs action now?” workflow instead of raw data lineage | Removes the need to open each record just to tell who is accountable versus merely assigned |
| Finding detail and single-record action modals | Secondary Context Surface | A tenant operator verifies one finding before reassigning work or requesting an exception | Finding summary, current owner, current assignee, exception owner when applicable, and next workflow action | Raw evidence, historical context, and additional governance details | Secondary because it resolves one case after the list has already identified the work item | Preserves the single-record decision loop without creating a separate ownership page | Avoids cross-page reconstruction when ownership and exception context must be compared together |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Tenant findings list and grouped bulk actions | List / Table / Bulk | Workflow queue / list-first resource | Open a finding or update responsibility | Finding | required | Structured `More` action group and grouped bulk actions | Existing dangerous actions remain inside grouped actions with confirmation where already required | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant context, filters, severity, due state, workflow state | Findings / Finding | Accountable owner, active assignee, and whether the finding is assigned, owned-but-unassigned, or orphaned | none |
| Finding detail and single-record action modals | Record / Detail / Actions | View-first operational detail | Confirm or update responsibility for one finding | Finding | N/A - detail surface | Structured header actions on the existing record page | Existing dangerous actions remain grouped and confirmed | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant breadcrumb, lifecycle state, due state, governance context | Findings / Finding | Finding owner, finding assignee, and exception owner stay visibly distinct in the same context | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant findings list and grouped bulk actions | Tenant operator or tenant manager | Route responsibility on open findings and separate accountable ownership from active work | Workflow queue | Who owns this outcome, who is doing the work, and what needs reassignment now? | Severity, lifecycle state, due state, owner, assignee, derived responsibility state, and personal-work cues | Raw evidence, run identifiers, historical audit details | lifecycle, severity, governance validity, responsibility state | TenantPilot only | Open finding, Assign, Triage, Start progress | Resolve, Close, Request exception |
| Finding detail and single-record action modals | Tenant operator or tenant manager | Verify and change responsibility for one finding without losing exception context | Detail/action surface | Is this finding owned by the right person, assigned to the right executor, and clearly separate from any exception owner? | Finding summary, owner, assignee, exception owner when present, due state, lifecycle state | Raw evidence payloads, extended audit history, related run metadata | lifecycle, governance validity, responsibility state | TenantPilot only | Assign, Start progress, Resolve | Close, Request exception |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no
- **New enum/state/reason family?**: no persisted family; only derived responsibility-state rules on existing fields
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators cannot reliably distinguish accountability from active execution, which creates avoidable misrouting and weak audit interpretation.
- **Existing structure is insufficient because**: Two existing user fields and an exception-owner concept can already appear in the same workflow, but current copy does not establish a stable product contract across list, detail, and action surfaces.
- **Narrowest correct implementation**: Reuse the existing finding owner and assignee fields, define their meaning, and apply that meaning consistently to labels, filters, derived states, and audit copy.
- **Ownership cost**: Focused UI copy review, acceptance tests for role combinations, and ongoing discipline to keep future findings work aligned with the same contract.
- **Alternative intentionally rejected**: A broader ownership framework with separate role types, queues, or team routing was rejected because it adds durable complexity before the product has proven the simpler owner-versus-assignee contract.
- **Release truth**: Current-release truth. This spec clarifies the meaning of responsibility on live findings now and becomes the contract for later execution surfaces.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: The change is visible through tenant findings UI behavior, responsibility-state rendering, and audit-facing wording. Focused feature coverage proves the contract without introducing browser or heavy-governance breadth.
- **New or expanded test families**: Expand tenant findings resource coverage to include responsibility rendering, personal-work cues, and mixed-context exception-owner wording. Add focused assignment-audit coverage for owner-only, assignee-only, and combined changes.
- **Fixture / helper cost impact**: Low. Existing tenant, membership, user, and finding factories are sufficient; tests only need explicit responsibility combinations and tenant-scoped memberships.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions for owner versus assignee visibility and authorization behavior.
- **Reviewer handoff**: Reviewers should confirm that one positive and one negative authorization case exist, that list and detail surfaces use the same vocabulary, and that audit-facing feedback distinguishes owner changes from assignee changes.
- **Budget / baseline / trend impact**: none
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Route accountable ownership clearly (Priority: P1)
As a tenant operator, I want to tell immediately who owns a finding and who is actively assigned to it, so I can route remediation work without guessing which responsibility changed.
**Why this priority**: This is the smallest slice that fixes the core trust gap. Without it, every later execution surface inherits ambiguous meaning.
**Independent Test**: Can be tested by loading the tenant findings list and detail pages with findings in owner-only, owner-plus-assignee, and no-owner states, and verifying that the first decision is possible without opening unrelated screens.
**Acceptance Scenarios**:
1. **Given** an open finding with an owner and no assignee, **When** an operator views the findings list, **Then** the record is shown as owned but unassigned rather than fully assigned.
2. **Given** an open finding with both an owner and an assignee, **When** an operator opens the finding detail, **Then** the accountable owner and active assignee are shown as separate roles.
3. **Given** an open finding with no owner, **When** an operator views the finding from the list or detail page, **Then** the accountability gap is surfaced as orphaned work.
---
### User Story 2 - Reassign work without losing accountability (Priority: P2)
As a tenant manager, I want to change the assignee, the owner, or both in one responsibility update, so I can transfer execution work without silently transferring accountability.
**Why this priority**: Reassignment is the first mutation where the ambiguity causes operational mistakes and misleading audit history.
**Independent Test**: Can be tested by performing responsibility updates on open findings and asserting separate outcomes for owner-only, assignee-only, and combined changes.
**Acceptance Scenarios**:
1. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the assignee, **Then** the owner remains unchanged and the resulting feedback states that only the assignee changed.
2. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the owner, **Then** the assignee remains unchanged and the resulting feedback states that only the owner changed.
---
### User Story 3 - Keep exception ownership separate (Priority: P3)
As a governance reviewer, I want exception ownership to remain visibly separate from finding ownership when both appear in one flow, so risk-acceptance work does not overwrite the meaning of the finding owner.
**Why this priority**: This is a narrower but important boundary that prevents the accepted-risk workflow from reintroducing the same ambiguity under a second owner label.
**Independent Test**: Can be tested by opening a finding that has, or is about to create, an exception record and verifying that finding owner and exception owner remain distinct in the same operator context.
**Acceptance Scenarios**:
1. **Given** a finding detail view that shows both finding responsibility and exception context, **When** the operator reviews ownership information, **Then** exception ownership is labeled separately from finding owner.
2. **Given** an operator starts an exception request from a finding, **When** the request asks for exception ownership, **Then** the form language makes clear that the selected person owns the exception artifact, not the finding itself.
### Edge Cases
- A finding may have the same user as both owner and assignee; the UI must show both roles as satisfied without implying duplication or error.
- A finding with an assignee but no owner is still an orphaned accountability case, not a healthy assigned state.
- Historical or imported findings may have both responsibility fields empty; they must render as orphaned without blocking rollout on a data backfill.
- When personal-work cues are shown, assignee-based work and owner-based accountability must not collapse into one ambiguous shortcut.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes tenant-scoped finding workflow semantics only. It introduces no Microsoft Graph calls, no scheduled or long-running work, and no new `OperationRun`. Responsibility mutations remain audited tenant-local writes.
**Constitution alignment (RBAC-UX):** The affected authorization plane is tenant-context only. Cross-tenant and non-member access remain deny-as-not-found. Members without the findings assign permission continue to be blocked from responsibility mutations. The feature reuses the canonical capability registry and does not introduce raw role checks or a new capability split.
**Constitution alignment (UI-FIL-001 / Filament Action Surfaces / UX-001):** The feature reuses existing native Filament tables, infolist entries, filters, selects, modal actions, grouped actions, and notifications. No local badge taxonomy or custom action chrome is added. The action-surface contract remains satisfied: the finding is still the one and only primary inspect/open model, row click remains the canonical inspect affordance on the list, no redundant view action is introduced, and existing dangerous actions remain grouped and confirmed where already required.
**Constitution alignment (UI-NAMING-001 / UI-SEM-001 / TEST-TRUTH-001):** Direct field labels alone are insufficient because finding owner, finding assignee, and exception owner can appear in the same operator flow. This feature resolves that ambiguity by tightening the product vocabulary on the existing domain truth instead of adding a new semantic layer or presenter family. Tests prove visible business consequences rather than thin indirection.
### Functional Requirements
- **FR-001**: The system MUST treat finding owner as the accountable user responsible for ensuring that a finding reaches a governed terminal outcome.
- **FR-002**: The system MUST treat finding assignee as the user currently expected to perform or coordinate active remediation work, and any operator-facing use of `assigned` language MUST refer to this role only.
- **FR-003**: Authorized users MUST be able to set, change, or clear finding owner and finding assignee independently on open findings while continuing to restrict selectable people to members of the active tenant.
- **FR-004**: The system MUST derive responsibility state from the existing owner and assignee fields without adding a persisted lifecycle field. At minimum it MUST distinguish `owned but unassigned`, `assigned`, and `orphaned accountability`.
- **FR-005**: Findings list, finding detail, and responsibility-update flows MUST label owner and assignee distinctly, and any mixed context that also references exception ownership MUST label that role as exception owner.
- **FR-006**: When personal-work shortcuts or filters are shown on the tenant findings list, the system MUST expose separate, explicitly named cues for assignee-based work and owner-based accountability.
- **FR-007**: Responsibility-update feedback and audit-facing wording MUST state whether a mutation changed the owner, the assignee, or both.
- **FR-008**: Historical or imported findings with null or partial responsibility data MUST render using the derived responsibility contract without requiring a blocking data migration before rollout.
- **FR-009**: Exception ownership MUST remain a separate governance concept. Creating, viewing, or editing an exception from a finding context MUST NOT silently replace the meaning of the finding owner.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant findings list | `/admin/t/{tenant}/findings` | None added by this feature | Full-row open into finding detail | Primary open affordance plus `More` action group with `Triage`, `Start progress`, `Assign`, `Resolve`, `Close`, and `Request exception` | Grouped actions including `Triage selected`, `Assign selected`, `Resolve selected`, and existing close/exception-safe bulk actions if already present | Existing findings empty state remains; no new CTA introduced | N/A | N/A | Yes for responsibility mutations and existing workflow mutations | Action Surface Contract satisfied. No empty groups or redundant view action introduced. |
| Finding detail | `/admin/t/{tenant}/findings/{finding}` | Existing record-level workflow actions only | Entered from the list row click; no separate inspect action added | Structured record actions retain `Assign`, `Start progress`, `Resolve`, `Close`, and `Request exception` | N/A | N/A | Existing view header actions only | N/A | Yes for responsibility mutations and existing workflow mutations | UI-FIL-001 satisfied through existing infolist and action modal primitives. No exemption needed. |
### Key Entities *(include if feature involves data)*
- **Finding responsibility**: The visible responsibility contract attached to an open finding, consisting of accountable ownership, active execution assignment, and a derived responsibility state.
- **Finding owner**: The accountable person who owns the outcome of the finding and is expected to ensure it reaches a governed end state.
- **Finding assignee**: The person currently expected to perform or coordinate the remediation work on the finding.
- **Exception owner**: The accountable person for an exception artifact created from a finding; separate from finding owner whenever both appear in one operator context.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In acceptance review, an authorized operator can identify owner, assignee, and responsibility state for any scripted open-finding example from the list or detail surface within 10 seconds.
- **SC-002**: 100% of covered responsibility combinations in automated acceptance tests, including same-person, owner-only, owner-plus-assignee, assignee-only, and both-empty cases, render the correct visible labels and derived state.
- **SC-003**: 100% of covered responsibility-update tests distinguish owner-only, assignee-only, and combined changes in operator feedback and audit-facing wording.
- **SC-004**: When personal-work shortcuts are present, an operator can isolate assignee-based work and owner-based accountability from the findings list in one interaction each.
## Assumptions
- Existing finding owner and assignee fields remain the single source of truth for responsibility in this slice.
- Open findings may legitimately begin without an assignee while still needing an accountable owner.
- Membership hygiene for users who later lose tenant access is a separate follow-up concern and not solved by this clarification slice.
## Non-Goals
- Introduce team, queue, or workgroup ownership.
- Add automatic escalation, reassignment, or inactivity timers.
- Split authorization into separate owner-edit and assignee-edit capabilities.
- Require a mandatory historical backfill before the clarified semantics can ship.
## Dependencies
- Spec 111, Findings Workflow + SLA, remains the lifecycle and assignment baseline that this spec clarifies.
- Spec 154, Finding Risk Acceptance, remains the exception governance baseline whose owner semantics must stay separate from finding owner.

View File

@ -159,3 +159,10 @@ ### Measurable Outcomes
- **SC-004**: Every core page includes a visible next-step CTA and at least one deeper path into the product, trust, or contact story.
- **SC-005**: No released page contains placeholder copy, unsubstantiated trust or compliance claims, or speculative integration promises.
- **SC-006**: Core pages remain readable and navigable on both desktop and mobile widths without horizontal scrolling or hidden primary navigation.
## Spec 223 Rebuild Status
- **Classification**: partially valid
- **Forward owner**: `../223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`
- **Material drift now recorded**: canonical trust routing now lives on `/trust`, `/security-trust` is redirect-only, `/changelog` and `/imprint` are canonical public surfaces, and `/legal`, `/terms`, `/solutions`, and `/integrations` are retained secondary routes under the smaller IA.
- **Forward-work rule**: new implementation work must start from the AstroDeck intake aliases and the Spec 223 mapping sheet instead of from the current `apps/website` implementation.

View File

@ -5,6 +5,12 @@ # Tasks: Initial Website Foundation & v0 Product Site
**Tests**: Browser smoke coverage is required for this runtime-changing website feature, together with the root website build proof.
## Historical Status
This implementation record is retained for traceability and is superseded by AstroDeck rebuild.
Forward planning now lives in `../223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`. Do not reset or reopen the checkbox state below; new work belongs to the Spec 223 mapping and follow-up slices.
## Test Governance Checklist
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.

View File

@ -174,3 +174,10 @@ ### Measurable Outcomes
- **SC-003**: 100% of library-derived website components proposed for the initial implementation phase can be justified as adaptations of the defined token and primitive model rather than uncontrolled default styles.
- **SC-004**: The specification contains zero requirements that obligate visual changes to `apps/platform`, Filament, or a shared cross-surface design system.
- **SC-005**: At least one primary CTA hierarchy and one low-emphasis CTA pattern are defined clearly enough that reviewers can classify CTA weight consistently across representative website pages.
## Spec 223 Rebuild Status
- **Classification**: continuing
- **Forward owner**: `../223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md`
- **Material drift**: none logged. AstroDeck changes the implementation substrate only; this website-local visual contract remains authoritative.
- **Forward-work rule**: all AstroDeck primitives must be adapted to the Spec 214 foundation before they are allowed into `apps/website`.

View File

@ -5,6 +5,12 @@ # Tasks: Website Visual Foundation
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
## Historical Status
This implementation record is retained for traceability and is superseded by AstroDeck rebuild.
Forward planning now lives in `../223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md`. Do not reset or reopen the checkbox state below; new work belongs to the Spec 223 mapping and follow-up slices.
## Test Governance Checklist
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.

View File

@ -193,11 +193,19 @@ ### Measurable Outcomes
## Planned Follow-on Specs
- Spec 216 - Homepage Structure and Section Model
- Spec 217 - Product Page Structure
- Spec 218 - Trust Surface
- Spec 219 - Contact / Demo Flow
- Spec 220 - Changelog Surface
- Spec 221 - Blog / Resources Surface, if activated
- Spec 222 - Solutions / Use-Case Surfaces, if activated later
- Spec 223 - Pricing Surface, if activated later
- Spec 217 - Homepage Structure and Section Model
- Spec 218 - Homepage Hero Contract
- Spec 219 - Product Page Structure
- Spec 220 - Trust Surface
- Spec 221 - Contact / Demo Flow
- Spec 222 - Changelog Surface
- Spec 223 - Blog / Resources Surface, if activated
- Spec 224 - Solutions / Use-Case Surfaces, if activated later
- Spec 225 - Pricing Surface, if activated later
## Spec 223 Rebuild Status
- **Classification**: continuing
- **Forward owner**: `../223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md`
- **Material drift**: none logged. This spec remains the canonical IA source of truth that constrains AstroDeck adoption.
- **Forward-work rule**: no AstroDeck route or navigation item may become public unless it matches the required, retained-secondary, or approved-optional surfaces defined here.

View File

@ -5,6 +5,12 @@ # Tasks: Website Information Architecture / Core Pages
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
## Historical Status
This implementation record is retained for traceability and is superseded by AstroDeck rebuild.
Forward planning now lives in `../223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md`. Do not reset or reopen the checkbox state below; new work belongs to the Spec 223 mapping and follow-up slices.
## Test Governance Checklist
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.

View File

@ -1,66 +0,0 @@
# Quickstart: Website Homepage Structure & Section Model
## Goal
Verify that the homepage in `apps/website` follows the Spec 216 section contract and routes visitors clearly into Product, Trust, Changelog, and Contact.
## Prerequisites
- Node.js 20+
- Corepack enabled
- Repo dependencies installed with `corepack pnpm install`
## Run the website locally
From the repository root:
```bash
corepack pnpm dev:website
```
Alternative, inside the website app:
```bash
cd apps/website
corepack pnpm dev
```
Default local URL: `http://127.0.0.1:4321/`
## What to verify on the homepage
Check the homepage in this order:
1. Header and global navigation expose Product, Trust, Changelog, and Contact, with no prominent links to unsubstantial optional routes.
2. Hero shows one dominant primary CTA, one secondary deepening CTA, and a product-near visual.
3. Outcome framing explains why the product matters in buyer language rather than route or feature-admin language.
4. Capability section groups the product model instead of listing a flat feature wall.
5. Trust block appears before the final CTA and routes to `/trust`.
6. Progress block shows visible dated product movement and routes to `/changelog`.
7. Final CTA offers one clear next step, currently `/contact`.
8. Footer keeps Product, Trust, Changelog, Contact, Privacy, and Imprint reachable.
## Build proof
From the repository root:
```bash
corepack pnpm build:website
```
## Browser smoke proof
Run the website smoke suite:
```bash
cd apps/website
corepack pnpm exec playwright test
```
## Expected proof points
- Homepage required blocks are visible in the intended order.
- The hero CTA hierarchy remains clear and non-competing.
- `/product`, `/trust`, `/changelog`, and `/contact` are reachable from the homepage.
- Optional unpublished routes are not surfaced prominently.
- The homepage remains readable on desktop and mobile widths.

View File

@ -2,10 +2,10 @@ openapi: 3.1.0
info:
title: TenantAtlas Homepage Surface Contract
version: 0.1.0
summary: Structural contract for the `apps/website` homepage in Spec 216.
summary: Structural contract for the `apps/website` homepage in Spec 217.
description: >-
This contract defines the public HTML routes that participate in the
homepage journey for Spec 216. The homepage remains a static Astro surface
homepage journey for Spec 217. The homepage remains a static Astro surface
and must route visitors into Product, Trust, Changelog, and Contact while
satisfying the required homepage section model.
servers:

View File

@ -1,16 +1,23 @@
# Implementation Plan: Website Homepage Structure & Section Model
**Branch**: `216-homepage-structure` | **Date**: 2026-04-19 | **Spec**: `specs/216-homepage-structure/spec.md`
**Input**: Feature specification from `specs/216-homepage-structure/spec.md`
**Branch**: `217-homepage-structure` | **Date**: 2026-04-19 | **Spec**: `specs/217-homepage-structure/spec.md`
**Input**: Feature specification from `specs/217-homepage-structure/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Rework the `apps/website` homepage into the explicit section flow required by Spec 216: hero, outcome framing, capability model, trust, progress, CTA, while preserving existing header and footer shells.
- Rework the `apps/website` homepage into the explicit section flow required by Spec 217: hero, outcome framing, capability model, trust, progress, CTA, while preserving existing header and footer shells.
- Implement the change by extending the current Astro content-driven homepage model (`src/content/pages/home.ts`) and existing section primitives instead of adding a new section registry or CMS-like composition layer.
- Reuse existing Trust and Changelog truth for homepage proof signals, and validate the result with the current website build proof plus focused Playwright smoke coverage.
## Addendum Status
- A post-implementation refinement now extends Spec 217 with hero art-direction guardrails aimed at avoiding generic neutral drift and generic shadcn-style marketing output.
- The original structural homepage work remains completed through T019.
- Phase 7 is now completed through T023, with an explicit headline primary anchor, stronger hero content contracts, a split desktop composition, and a governance-specific visual surface on the homepage.
- The hero-direction guardrails are now enforced by automated smoke coverage for primary anchor presence, supporting-copy subordination, CTA-anchor reinforcement, governance-specific visual semantics, and desktop/mobile hierarchy.
## Technical Context
**Language/Version**: Astro 6.0.0 templates + TypeScript 5.9.x
@ -74,7 +81,7 @@ ## Project Structure
### Documentation (this feature)
```text
specs/216-homepage-structure/
specs/217-homepage-structure/
├── plan.md
├── research.md
├── data-model.md
@ -125,7 +132,7 @@ ### Source Code (repository root)
└── smoke-helpers.ts
```
**Structure Decision**: Keep the feature completely inside `apps/website`, using the existing Astro page/content/component split. Extend `src/content/pages/home.ts`, reuse current section components where possible, and add only the smallest missing homepage composition pieces required by Spec 216.
**Structure Decision**: Keep the feature completely inside `apps/website`, using the existing Astro page/content/component split. Extend `src/content/pages/home.ts`, reuse current section components where possible, and add only the smallest missing homepage composition pieces required by Spec 217.
## Complexity Tracking
@ -142,11 +149,11 @@ ## Proportionality Review
## Phase 0 — Outline & Research (complete)
- Output: `specs/216-homepage-structure/research.md`
- Output: `specs/217-homepage-structure/research.md`
- Key decisions captured:
- Keep the homepage local to the static Astro website and preserve runtime separation from `apps/platform`.
- Extend the current content-driven homepage model instead of adding a new section framework.
- Reorder the homepage into the explicit Spec 216 narrative flow while preserving optional supporting context only where it helps clarity.
- Reorder the homepage into the explicit Spec 217 narrative flow while preserving optional supporting context only where it helps clarity.
- Reuse existing Trust page truth and changelog collection data for homepage proof signals.
- Validate via the current website build proof plus focused Playwright smoke coverage.
@ -154,17 +161,17 @@ ## Phase 1 — Design & Contracts (complete)
### Data model
- Output: `specs/216-homepage-structure/data-model.md`
- Output: `specs/217-homepage-structure/data-model.md`
- Model remains file- and route-based. No database schema changes are required.
### Public homepage contract
- Output: `specs/216-homepage-structure/contracts/homepage-surface.openapi.yaml`
- Output: `specs/217-homepage-structure/contracts/homepage-surface.openapi.yaml`
- Contract captures the homepage route plus the required onward routes (`/product`, `/trust`, `/changelog`, `/contact`) and the structural rules the homepage must satisfy.
### Quickstart
- Output: `specs/216-homepage-structure/quickstart.md`
- Output: `specs/217-homepage-structure/quickstart.md`
- Quickstart covers local development, homepage verification points, build proof, and smoke-test execution.
### Agent context update
@ -216,10 +223,20 @@ ## Close-Out Notes
**Implementation completed**: All 19 tasks (T001T019) across 6 phases.
**Post-close-out refinement**: Spec 217 now also includes a completed Phase 7 hero-direction addendum that sharpens the homepage hero without widening the rest of the homepage contract.
### Build & Test Proof
- `corepack pnpm build:website`: ✅ 12 pages built, 0 errors
- `cd apps/website && corepack pnpm exec playwright test`: ✅ 20/20 tests pass
- Phase 7 validation on 2026-04-20: `corepack pnpm build:website` ✅ and `cd apps/website && corepack pnpm exec playwright test` ✅ 26/26 tests pass
### Phase 7 Hero Refinement
- The homepage hero now uses an explicit `headline` primary anchor with stronger typographic tension and subordinate supporting copy.
- The desktop hero now reads as one split composition, with the copy block and governance surface sharing the same first-read layer instead of stacking like separate sections.
- The hero visual now emphasizes baseline drift, restore preview, evidence linking, and review queue semantics instead of generic KPI-oriented dashboard language.
- Phase 7 browser coverage now proves the addendum requirements directly in `apps/website/tests/smoke/home-product.spec.ts` and `apps/website/tests/smoke/smoke-helpers.ts`.
### Summary of Changes
@ -231,17 +248,17 @@ ### Summary of Changes
- `apps/website/public/images/hero-product-visual.svg` — product-near hero visual
**Modified files**:
- `apps/website/src/types/site.ts` — 7 new interfaces for Spec 216 section model
- `apps/website/src/content/pages/home.ts` — full rewrite with Spec 216 content
- `apps/website/src/types/site.ts` — 7 new interfaces for Spec 217 section model
- `apps/website/src/content/pages/home.ts` — full rewrite with Spec 217 content
- `apps/website/src/pages/index.astro` — full rewrite with new section flow
- `apps/website/src/components/sections/PageHero.astro` — added productVisual + trustSubclaims support
- `apps/website/src/components/primitives/Section.astro` — forward rest attributes (data-section)
- `apps/website/src/components/primitives/Card.astro` — forward rest attributes (data-hero-visual)
- `apps/website/tests/smoke/home-product.spec.ts` — rewritten for Spec 216 homepage structure
- `apps/website/tests/smoke/home-product.spec.ts` — rewritten for Spec 217 homepage structure
- `apps/website/tests/smoke/smoke-helpers.ts` — 4 new assertion helpers
- `apps/website/tests/smoke/visual-foundation-guardrails.spec.ts` — updated CTA labels
### Homepage Section Flow (Spec 216)
### Homepage Section Flow (Spec 217)
1. **PageHero** — eyebrow, headline, description, primary/secondary CTA, product visual, trust subclaims
2. **LogoStrip** — ecosystem fit (Microsoft Graph, Entra ID, Intune, Review workflows)

View File

@ -0,0 +1,80 @@
# Quickstart: Website Homepage Structure & Section Model
## Goal
Verify that the homepage in `apps/website` follows the Spec 217 section contract and routes visitors clearly into Product, Trust, Changelog, and Contact.
## Prerequisites
- Node.js 20+
- Corepack enabled
- Repo dependencies installed with `corepack pnpm install`
## Run the website locally
From the repository root:
```bash
corepack pnpm dev:website
```
Alternative, inside the website app:
```bash
cd apps/website
corepack pnpm dev
```
Default local URL: `http://127.0.0.1:4321/`
## What to verify on the homepage
Check the homepage in this order:
1. Header and global navigation expose Product, Trust, Changelog, and Contact, with no prominent links to unsubstantial optional routes.
2. Hero shows one dominant primary anchor, one dominant primary CTA, one secondary deepening CTA, and a product-near visual.
3. Hero typography, spacing, and surface contrast feel deliberate rather than generic or washed out.
4. Hero visual reads as governance-, review-, restore-, drift-, or evidence-oriented product truth rather than generic dashboard wallpaper.
5. Supporting copy and secondary CTA clearly serve the primary anchor instead of competing with it.
6. Accent use supports hierarchy and product truth rather than behaving like decorative garnish.
7. Outcome framing explains why the product matters in buyer language rather than route or feature-admin language.
8. Capability section groups the product model instead of listing a flat feature wall.
9. Trust block appears before the final CTA and routes to `/trust`.
10. Progress block shows visible dated product movement and routes to `/changelog`.
11. Final CTA offers one clear next step, currently `/contact`.
12. Footer keeps Product, Trust, Changelog, Contact, Privacy, and Imprint reachable.
## Addendum review note
The hero-direction checks above remain the manual review rubric for overall quality, but they are no longer manual-only. Phase 7 now adds automated smoke proof for the explicit primary anchor, supporting-copy subordination, CTA-anchor reinforcement, governance-specific visual semantics, and desktop/mobile hierarchy.
## Build proof
From the repository root:
```bash
corepack pnpm build:website
```
## Browser smoke proof
Run the website smoke suite:
```bash
cd apps/website
corepack pnpm exec playwright test
```
## Expected proof points
- Homepage required blocks are visible in the intended order.
- The hero CTA hierarchy remains clear and non-competing.
- The hero exposes one explicit primary anchor and keeps supporting copy visually subordinate to it.
- The hero has one obvious focal point and does not flatten into neutral mush.
- The hero visual conveys TenantAtlas-specific governance truth rather than generic admin or analytics UI.
- The hero desktop layout keeps copy and product surface in one split composition instead of stacking them into unrelated blocks.
- Supporting copy and the secondary CTA reinforce the focal point instead of competing with it.
- Hero hierarchy remains legible on both desktop and mobile widths.
- `/product`, `/trust`, `/changelog`, and `/contact` are reachable from the homepage.
- Optional unpublished routes are not surfaced prominently.
- The homepage remains readable on desktop and mobile widths.

View File

@ -3,7 +3,7 @@ # Research: Website Homepage Structure & Section Model
## Decision 1: Keep the homepage implementation local to the static Astro website
- **Decision**: Continue treating the homepage as a static `apps/website` route composed from Astro content modules and section components, with no runtime dependency on `apps/platform`.
- **Rationale**: Spec 216 is explicitly website-only. The current website already runs as a standalone Astro app, and the required homepage improvements concern structure, sequencing, and public route discoverability rather than dynamic runtime behavior.
- **Rationale**: Spec 217 is explicitly website-only. The current website already runs as a standalone Astro app, and the required homepage improvements concern structure, sequencing, and public route discoverability rather than dynamic runtime behavior.
- **Alternatives considered**:
- Couple homepage composition to `apps/platform` or a shared API: rejected because the spec forbids platform obligations and the homepage needs no dynamic platform data.
- Introduce a CMS or page-builder layer first: rejected because a single homepage route does not justify that operational overhead.
@ -21,7 +21,7 @@ ## Decision 3: Recompose the homepage into an explicit narrative flow
- **Decision**: Implement the homepage in the following functional order: header, hero, outcome framing, capability model, trust, progress, CTA, footer. Optional supporting context stays secondary and may only appear if it reinforces clarity.
- **Rationale**: Exploration of the current homepage showed that the site already has hero, optional ecosystem context, and CTA pieces, but the middle narrative is misaligned: the current feature grid explains route jobs instead of product outcomes or capabilities, trust is too implicit, and progress is only a CTA target.
- **Alternatives considered**:
- Keep the current hero → ecosystem → route-jobs → proof → CTA sequence: rejected because it does not satisfy Spec 216s required block responsibilities.
- Keep the current hero → ecosystem → route-jobs → proof → CTA sequence: rejected because it does not satisfy Spec 217s required block responsibilities.
- Collapse trust or progress into the CTA block: rejected because the spec requires both to appear explicitly before the final CTA.
## Decision 4: Reuse existing Trust and Changelog truth for homepage proof blocks
@ -34,8 +34,16 @@ ## Decision 4: Reuse existing Trust and Changelog truth for homepage proof block
## Decision 5: Validate through the existing website smoke harness
- **Decision**: Prove Spec 216 with the existing website build command and focused Playwright smoke updates for homepage section order, CTA hierarchy, and onward route reachability.
- **Decision**: Prove Spec 217 with the existing website build command and focused Playwright smoke updates for homepage section order, CTA hierarchy, and onward route reachability.
- **Rationale**: The homepage contract is about public rendering, navigational clarity, and responsive visibility. Browser smoke coverage is the narrowest proving layer that can validate those concerns.
- **Alternatives considered**:
- Build-only proof alone: rejected because static output generation does not prove section order, CTA hierarchy, or visible route reachability.
- Add visual regression or heavier browser matrices immediately: rejected because the feature scope does not require that extra cost.
## Decision 6: The hero must favor distinctive restraint over generic neutral minimalism
- **Decision**: Treat the homepage hero as a brand and product identity surface with one clear anchor, governance-specific product truth, and enough contrast to avoid neutral drift.
- **Rationale**: Structural correctness alone still allows a failure mode where the hero feels like a clean but anonymous shadcn or Tailwind marketing shell. TenantAtlas needs a hero that remains calm while still being memorable, product-near, and clearly governance-oriented.
- **Alternatives considered**:
- Keep iterating only on structure and copy while leaving art direction implicit: rejected because that path still permits correct-but-forgettable output.
- Solve distinctiveness mainly through stronger color or decoration: rejected because the desired signal is precise, enterprise, and trust-first rather than flashy.

View File

@ -1,9 +1,9 @@
# Feature Specification: Website Homepage Structure & Section Model
**Feature Branch**: `216-homepage-structure`
**Feature Branch**: `217-homepage-structure`
**Created**: 2026-04-19
**Status**: Draft
**Input**: User description: "Define Spec 216 as the website-only homepage structure and section model for `apps/website`, covering required sections, ordering, CTA logic, trust signal placement, product visuals, and onward routing."
**Input**: User description: "Define Spec 217 as the website-only homepage structure and section model for `apps/website`, covering required sections, ordering, CTA logic, trust signal placement, product visuals, and onward routing."
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
@ -113,6 +113,33 @@ ### Edge Cases
- How does the homepage behave on narrow screens? The same meaning order must survive mobile compression, and trust, progress, product-near context, and the primary CTA must remain visible without horizontal scrolling.
- What happens when the changelog surface has only a small amount of published history? The progress signal may stay concise, but it must still indicate real dated movement and link into the actual changelog route.
## Hero Direction Addendum *(Homepage Hero Refinement / Anti-Generic Direction)*
This addendum sharpens Spec 217 without widening scope beyond `apps/website`. The base homepage contract already fixes structure, routing, trust sequencing, and CTA discipline. This refinement adds the missing art-direction guardrail: the hero must not be merely correct and calm; it must also be distinct, product-specific, and immediately recognizable as TenantAtlas.
### Additional Problem Definition
- A hero can satisfy the structural contract and still fail if it has no clear visual stance, feels like a neat shadcn or Tailwind midpoint, spreads attention evenly, or uses so many similar neutral surfaces that nothing leads.
- TenantAtlas needs a hero that reads as a serious governance surface, not as a generic friendly SaaS shell with better spacing.
- The failure mode to avoid is not visual loudness but visual anonymity: a clean hero that nobody remembers after ten seconds is still a failed hero.
### Direction Principles
- **Distinctive restraint**: The hero SHOULD remain calm, but calmness MUST still feel intentional and ownable rather than default-neutral.
- **One dominant idea**: The hero MUST have one clearly dominant focal point, whether that is the headline, the product visual, or a deliberately weighted composition of both.
- **Product-first art direction**: The hero SHOULD feel like an entry into the product, not a generic marketing scaffold decorated with UI.
- **Controlled brand signal**: Brand signal MUST come primarily from typography, contrast, composition, product truth, and accent discipline rather than from decorative effects.
- **No neutral drift**: Neutral-first styling MAY remain the baseline, but the hero MUST NOT flatten into washed-out sameness, accidental softness, or brandless enterprise blandness.
### Hero Anti-Patterns
- **Correct but forgettable**: Everything is structurally right, but nothing leaves a mark.
- **Neutral mush**: Too many similar light surfaces and too little hierarchy or focus.
- **Dashboard wallpaper**: The product visual exists, but it behaves as decorative scenery rather than as a meaning-carrying surface.
- **Generic shadcn marketing**: Good spacing and clean cards, but no product-specific identity or visual stance.
- **Over-disciplined minimalism**: The hero becomes so restrained that it stops leading.
- **Brandless enterprise**: The page looks professional, but not like TenantAtlas.
## Requirements *(mandatory)*
This feature changes only the public homepage in `apps/website`. It introduces no Microsoft Graph calls, no platform authorization changes, no Filament surfaces, no queued work, and no runtime coupling to `apps/platform`.
@ -140,6 +167,17 @@ ### Functional Requirements
- **FR-019**: The homepage MUST stay understandable and structurally equivalent on mobile. Hero, outcome, capability, trust, progress, and CTA blocks MUST remain recognizable on narrow screens, and mobile compression MUST NOT effectively hide trust or product-near context.
- **FR-020**: The homepage MUST avoid the following disallowed patterns: template-first SaaS framing, abstract-only storytelling, unstructured feature walls, hidden trust, demo-only pressure, fake social proof, and enterprise-theater claims.
- **FR-021**: The homepage MUST remain strictly local to `apps/website` and MUST NOT create implementation or contract requirements for `apps/platform`.
- **FR-022**: The homepage hero MUST be distinct and memorable enough to signal TenantAtlas as a serious governance surface; structural correctness and visual cleanliness alone are not sufficient.
- **FR-023**: The hero MUST establish one clear primary anchor through the headline, the product visual, or a deliberately weighted composition of both. Text, CTA, badge, and product visual MUST NOT all compete as equally weak elements.
- **FR-024**: The hero MUST create internal contrast through scale, whitespace, typography, surface hierarchy, accent placement, or a combination of these. Near-identical surface values across the text block, supporting elements, and visual block are not sufficient.
- **FR-025**: Hero typography SHOULD create more tension than standard UI copy through a clear size hierarchy, controlled line breaks, display treatment, or similarly intentional framing. The headline MUST remain scannable and formally deliberate rather than blocky or purely utilitarian.
- **FR-026**: Supporting copy in the hero MUST serve the headline focus and MUST NOT claim equal visual priority.
- **FR-027**: The hero product visual MUST depict governance-, audit-, drift-, review-, restore-, evidence-, or bounded-access-oriented product truth and MUST NOT read as a generic analytics dashboard, vague KPI slab, or decorative admin table.
- **FR-028**: The hero product visual SHOULD feel compositionally integrated with the hero surface and MUST NOT read as a generic screenshot box inserted beside unrelated marketing copy.
- **FR-029**: Neutral-first color usage MAY remain the baseline, but the hero MUST still provide a clear hierarchy and MUST NOT collapse into washed-out sameness or accidental softness.
- **FR-030**: Brand signal in the hero MUST come primarily from typography, contrast, composition, product specificity, and disciplined accent usage rather than from decorative effects or scattered color.
- **FR-031**: Hero CTA composition MUST reinforce the primary anchor. The primary CTA MUST remain clearly dominant, and the secondary CTA SHOULD remain legible without reading as a generic outline fallback detached from the rest of the hero.
- **FR-032**: The hero MUST avoid the following additional anti-patterns: correct but forgettable, neutral mush, dashboard wallpaper, generic shadcn marketing, over-disciplined minimalism, and brandless enterprise.
### Key Entities *(include if feature involves data)*
@ -148,6 +186,9 @@ ### Key Entities *(include if feature involves data)*
- **Trust Claim**: A bounded public assertion about hosting, residency, seriousness, isolation, governance posture, or similar credibility signals that must be supportable by the Trust surface.
- **Progress Signal**: A homepage block or teaser that shows visible dated product movement and routes to the changelog.
- **CTA Target**: A next-question route reached from the homepage, especially Product, Trust, Changelog, or Contact.
- **Hero Primary Anchor**: The single dominant focal point in the hero, formed by the headline, the product visual, or an intentionally weighted composition of both.
- **Governance-Specific Product Visual**: A hero visual that communicates change history, baselines, drift, findings, review workflows, restore planning, evidence, or scoped actions rather than generic KPI output.
- **Hero Anti-Pattern**: A hero failure mode such as neutral mush or dashboard wallpaper that can satisfy structural correctness while still failing memorability or product specificity.
## Assumptions & Dependencies
@ -167,3 +208,15 @@ ### Measurable Outcomes
- **SC-004**: Trust and progress signals appear before the final CTA and remain discoverable without leaving the homepage, while deeper substantiation stays reachable in one click to `/trust` and `/changelog`.
- **SC-005**: No released homepage version contains unsupported trust claims, fake logos or badges, placeholder routes, or more than one equally dominant primary conversion action.
- **SC-006**: On mobile widths, visitors can still identify the hero, outcome framing, capability model, trust block, progress block, and CTA transition without horizontal scrolling or hidden primary navigation.
- **SC-007**: In a homepage review, a reviewer can identify the hero's primary anchor within 10 seconds and can distinguish headline, product visual, and CTA hierarchy without ambiguity.
- **SC-008**: The hero visual communicates at least one TenantAtlas-specific governance concept such as drift, review, restore, evidence, or bounded access rather than generic dashboard activity.
- **SC-009**: Reviewers can identify at least one typographic or compositional cue and one product-truth cue that distinguish the hero from a generic shadcn or Tailwind marketing layout.
- **SC-010**: On desktop and mobile, the hero retains clear contrast between the dominant element, supporting copy, product visual, and CTA instead of flattening into visually equal neutral surfaces.
- **SC-011**: The released homepage hero avoids the anti-patterns of neutral mush and correct-but-forgettable minimalism while remaining calm and trust-oriented.
## Spec 223 Rebuild Status
- **Classification**: continuing
- **Forward owner**: `../223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md`
- **Material drift**: none logged. Optional AstroDeck proof sections are handled as remove/adapt decisions inside the mapping sheet, not as structural homepage truth changes.
- **Forward-work rule**: AstroDeck homepage work must preserve the required block order of hero, outcome, product model, trust, progress, CTA, and footer.

View File

@ -1,10 +1,16 @@
# Tasks: Website Homepage Structure & Section Model
**Input**: Design documents from `/specs/216-homepage-structure/`
**Input**: Design documents from `/specs/217-homepage-structure/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/homepage-surface.openapi.yaml`
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
## Historical Status
This implementation record is retained for traceability and is superseded by AstroDeck rebuild.
Forward planning now lives in `../223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md`. Do not reset or reopen the checkbox state below; new work belongs to the Spec 223 mapping and follow-up slices.
## Test Governance Checklist
- [X] Lane assignment stays `Browser` in `fast-feedback`, which is the narrowest sufficient proof for this homepage-only change.
@ -49,7 +55,7 @@ ### Tests for User Story 1
### Implementation for User Story 1
- [X] T006 [P] [US1] Add Spec 216 hero content, product-near visual data, optional bounded trust subclaims, and outcome blocks in `apps/website/src/content/pages/home.ts`
- [X] T006 [P] [US1] Add Spec 217 hero content, product-near visual data, optional bounded trust subclaims, and outcome blocks in `apps/website/src/content/pages/home.ts`
- [X] T007 [US1] Implement the hero-to-outcome homepage flow and hero visual rendering in `apps/website/src/pages/index.astro` and `apps/website/src/components/sections/PageHero.astro`
**Checkpoint**: The homepage delivers the MVP story of product clarity, buyer relevance, and one clear next step.
@ -105,9 +111,20 @@ ## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Validate proof commands, tighten claim wording, and capture close-out notes.
- [X] T016 [P] Review homepage proof and trust wording against bounded-claim rules in `apps/website/src/content/pages/home.ts`, `apps/website/src/content/pages/trust.ts`, and `apps/website/src/content/pages/changelog.ts`
- [X] T017 [P] Run `corepack pnpm build:website` from `package.json` and confirm homepage proof expectations in `specs/216-homepage-structure/quickstart.md`
- [X] T017 [P] Run `corepack pnpm build:website` from `package.json` and confirm homepage proof expectations in `specs/217-homepage-structure/quickstart.md`
- [X] T018 [P] Run `cd apps/website && corepack pnpm exec playwright test` against `apps/website/tests/smoke/home-product.spec.ts`, `apps/website/tests/smoke/changelog-core-ia.spec.ts`, and `apps/website/tests/smoke/contact-legal.spec.ts`
- [X] T019 Record the homepage smoke-coverage close-out and verification notes in `specs/216-homepage-structure/plan.md` and `specs/216-homepage-structure/quickstart.md`
- [X] T019 Record the homepage smoke-coverage close-out and verification notes in `specs/217-homepage-structure/plan.md` and `specs/217-homepage-structure/quickstart.md`
---
## Phase 7: Hero Direction Addendum Follow-Up
**Purpose**: Sharpen the homepage hero so Spec 217 remains product-specific, memorable, and resistant to generic neutral drift.
- [X] T020 [P] Add homepage proof coverage for a clear hero primary anchor, supporting-copy subordination, CTA-anchor reinforcement, governance-specific product visual semantics, and desktop/mobile anti-generic hierarchy in `apps/website/tests/smoke/home-product.spec.ts` and `apps/website/tests/smoke/smoke-helpers.ts`
- [X] T021 [P] Extend homepage content and hero type contracts for stronger typographic tension, a dominant anchor, and governance-specific visual truth in `apps/website/src/content/pages/home.ts` and `apps/website/src/types/site.ts`
- [X] T022 Rework hero composition, accent discipline, and product-visual integration to avoid dashboard-wallpaper, neutral-mush, and detached-outline-CTA outcomes in `apps/website/src/components/sections/PageHero.astro`, `apps/website/src/components/content/HeroDashboard.astro`, and `apps/website/src/pages/index.astro`
- [X] T023 [P] Re-run `corepack pnpm build:website` plus `cd apps/website && corepack pnpm exec playwright test`, then refresh Phase 7 proof notes in `specs/217-homepage-structure/quickstart.md` and `specs/217-homepage-structure/plan.md`
---
@ -119,6 +136,7 @@ ### Phase Dependencies
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories.
- **User Stories (Phases 3-5)**: Depend on Foundational. They remain independently testable, but shared homepage assembly in `apps/website/src/pages/index.astro` should land sequentially in story order.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
- **Hero Direction Addendum (Phase 7)**: Depends on the structural homepage work in Phases 1-6 and sharpens the hero without expanding the rest of the homepage contract.
### User Story Dependencies
@ -150,7 +168,7 @@ ## Parallel Example: User Story 1
```bash
# After the foundations are complete, split the first slice into test + content work:
Task: "T005 [US1] Write failing homepage smoke assertions for hero clarity, outcome framing, and one dominant CTA hierarchy"
Task: "T006 [US1] Add Spec 216 hero and outcome content blocks"
Task: "T006 [US1] Add Spec 217 hero and outcome content blocks"
# Then assemble the homepage route:
Task: "T007 [US1] Implement the hero-to-outcome homepage flow"
@ -198,6 +216,7 @@ ### Incremental Delivery
3. US2 upgrades the homepage middle narrative into grouped product model, trust, and progress proof.
4. US3 sharpens onward routing and discoverability for Product, Trust, Changelog, Contact, and legal follow-through.
5. Polish runs both proof commands, validates wording, and records close-out notes before merge.
6. Phase 7 refines the hero art direction so the homepage avoids generic neutral drift while preserving the original structure contract.
### Suggested MVP Scope

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Website Homepage Hero
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-19
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass 1 completed successfully.
- No clarification questions were required because the hero role, required elements, CTA structure, visual constraints, and website-only scope boundaries were explicit in the input.

View File

@ -0,0 +1,148 @@
openapi: 3.1.0
info:
title: TenantAtlas Homepage Hero Contract
version: 0.1.0
summary: Semantic contract for the `apps/website` homepage hero in Spec 218.
description: >-
This contract defines the public HTML routes that participate in the
homepage hero journey for Spec 218. The homepage remains a static Astro
surface and must present a clear category context, one headline, one
supporting-copy block, one CTA pair, a product-near visual, and optional
bounded trust cues while routing visitors into Product, Trust, Changelog,
and Contact.
servers:
- url: http://localhost:{port}
description: Local Astro development or preview server
variables:
port:
default: '4321'
tags:
- name: Homepage Hero Journey
description: Public HTML routes used by the homepage hero contract
paths:
/:
get:
tags: [Homepage Hero Journey]
operationId: getHomepageHero
summary: Homepage hero
description: >-
Product-near homepage hero that positions the product, explains the
problem space, offers one clear CTA pair, and establishes bounded
first-read credibility without replacing the deeper Product or Trust
surfaces.
x-tenantatlas-homepage-hero:
requiredElements:
- category-context
- headline
- supporting-copy
- primary-cta
- secondary-cta
- product-near-visual
optionalElements:
- trust-subclaims
contentPriority:
- product-and-problem-understanding
- clear-next-step
- product-reality
- early-trust
- stylistic-finish
primaryCtaTargets:
- /contact
- /demo
secondaryCtaTargets:
- /product
- /trust
- /changelog
onwardRoutes:
- /product
- /trust
- /changelog
- /contact
mobileMeaningOrder:
- headline
- supporting-copy
- cta-pair
- product-near-visual
- trust-subclaims
productVisualRules:
- derived-from-real-product-structure
- no-generic-dashboard-wallpaper
- no-fake-metrics
- alt-text-must-be-product-specific
trustSubclaimRules:
- factually-supportable
- concise
- supportable-by-trust-surface
- no-legal-or-compliance-guarantees
- no-badge-wall
forbiddenPatterns:
- generic-startup-hero
- abstract-only-hero
- dashboard-wallpaper-hero
- badge-overload-hero
- sales-pressure-hero
- compliance-theater-hero
responses:
'200':
description: Homepage HTML
content:
text/html:
schema:
$ref: '#/components/schemas/HtmlDocument'
/product:
get:
tags: [Homepage Hero Journey]
operationId: getHomepageHeroProductTarget
summary: Product target route
description: Deeper product-model route linked from the homepage hero.
responses:
'200':
description: Product page HTML
content:
text/html:
schema:
$ref: '#/components/schemas/HtmlDocument'
/trust:
get:
tags: [Homepage Hero Journey]
operationId: getHomepageHeroTrustTarget
summary: Trust target route
description: Bounded trust route that supports any public trust-adjacent hero language.
responses:
'200':
description: Trust page HTML
content:
text/html:
schema:
$ref: '#/components/schemas/HtmlDocument'
/changelog:
get:
tags: [Homepage Hero Journey]
operationId: getHomepageHeroChangelogTarget
summary: Changelog target route
description: Dated progress route that may be used as a secondary deepening destination from the homepage journey.
responses:
'200':
description: Changelog page HTML
content:
text/html:
schema:
$ref: '#/components/schemas/HtmlDocument'
/contact:
get:
tags: [Homepage Hero Journey]
operationId: getHomepageHeroContactTarget
summary: Contact target route
description: Primary next-step route used by the homepage hero until a distinct public `/demo` route exists.
responses:
'200':
description: Contact page HTML
content:
text/html:
schema:
$ref: '#/components/schemas/HtmlDocument'
components:
schemas:
HtmlDocument:
type: string
description: Server-rendered static HTML document

View File

@ -0,0 +1,100 @@
# Data Model: Website Homepage Hero
This feature introduces no database schema and no platform-side persistence. The model is file- and route-based inside `apps/website` and defines how the homepage hero expresses product truth, CTA hierarchy, early trust cues, and the next-step path.
## 1. Homepage Hero Source Object
Represents the hero content exported from `apps/website/src/content/pages/home.ts`.
| Field | Type | Description | Rules |
|---|---|---|---|
| `eyebrow` | string | Short positioning cue or category context | Required; must anchor the hero in a believable category or problem space |
| `title` | string | Primary positioning headline | Required; must frame product, problem, or outcome without hype language |
| `description` | string | Supporting copy | Required; must sharpen the headline in plain product language |
| `primaryCta` | `CtaLink` | Dominant next-step action | Required; exactly one dominant primary CTA |
| `secondaryCta` | `CtaLink` | Secondary deepening action | Required for Spec 218 hero contract; must remain lower-emphasis than the primary CTA |
| `productVisual` | `HeroVisualContent` | Product-near screenshot or stylized product shot | Required by the hero contract unless a temporary explicit exemption is documented |
| `trustSubclaims` | string[] | Optional early trust cues | Optional; must remain short, factual, and bounded |
| `highlights` | string[] | Optional lightweight support points | Optional; should not compete with trust subclaims when both exist |
**Relationships**:
- Rendered by `apps/website/src/components/sections/PageHero.astro`
- Consumed by `apps/website/src/pages/index.astro`
- Secondary truth for trust-facing claims must remain compatible with `apps/website/src/content/pages/trust.ts`
**Validation rules**:
- Exactly one primary CTA and one secondary CTA must remain visible as the hero action pair
- Headline and description must not collapse into generic SaaS phrasing or stacked claims
- Trust subclaims must never become a badge wall or imply legal or compliance guarantees
## 2. Hero CTA Pair
Represents the two hero actions as one intentional routing pair.
| Field | Type | Description | Rules |
|---|---|---|---|
| `primary` | `CtaLink` | Primary next-step action | Must lead to `/contact`, `/demo`, or an equivalent explicitly approved next step |
| `secondary` | `CtaLink` | Lower-emphasis exploration route | Must lead to a maintained informational surface such as `/product`, `/trust`, or `/changelog` |
| `dominance` | derived UI property | Relative emphasis between the two CTAs | Primary must remain visually dominant |
| `reachability` | derived route truth | Whether the linked routes are real and maintained | Hero must not point to placeholder or immature surfaces |
**Validation rules**:
- The pair must communicate one clear next step and one clear deepening path
- Multiple equally dominant primary sales actions are forbidden
## 3. Hero Visual Asset
Represents the product-near media used in the hero.
| Field | Type | Description | Rules |
|---|---|---|---|
| `src` | string | Public asset path | Must resolve to a maintained hero asset in `apps/website/public` or equivalent |
| `alt` | string | Accessibility text for the visual | Must describe product-relevant UI truth, not generic marketing scenery |
| `truthBasis` | derived review rule | Why the visual is considered product-near | Must be traceable to real product structure or a truthful simplification |
| `mobilePersistence` | derived render rule | Whether the visual remains visible on small screens | Must stay visible when it is a key credibility signal |
**Validation rules**:
- No fantasy metrics, fake dashboards, or unrelated analytics wallpaper
- Cropping or stylization is allowed only when product structure remains clear
## 4. Trust Subclaim Set
Represents the optional early-trust layer inside the hero.
| Field | Type | Description | Rules |
|---|---|---|---|
| `claims` | string[] | Short factual trust cues | Optional; should remain few and concise |
| `supportRoute` | route or derived surface | Where deeper trust context lives | Normally `/trust` |
| `visibility` | derived render rule | When trust cues are shown | Show only when claims are supportable and do not compete with the hero core |
**Validation rules**:
- Claims must be factual and publicly supportable
- Claims must not imply absolute compliance, security, or hosting guarantees
- Trust cues must support the hero, not overrun it
## 5. Hero Render Contract
Represents the semantic structure that `PageHero.astro` must preserve.
| Element | Role | Requirement |
|---|---|---|
| Category context | Early positioning cue | Must appear before or adjacent to the headline |
| Headline | Primary positioning statement | Must remain the dominant text signal |
| Supporting copy | Headline clarification | Must remain directly associated with the headline |
| CTA pair | Action transition | Must keep one dominant primary and one lower-emphasis secondary action |
| Product-near visual | Product truth signal | Must remain part of the hero composition |
| Optional trust subclaims | Early credibility cues | Must stay secondary to product and CTA understanding |
**Mobile meaning order**:
- headline and supporting copy
- CTA pair
- product-near visual
- optional trust signals
## Derived State and Availability
- No independent state machine is added by this feature.
- Hero route availability remains derived from the homepage route at `/`.
- Hero CTA reachability remains derived from canonical public routes in `apps/website`.
- Trust-subclaim legitimacy remains derived from public website truth, especially the Trust surface.
- Product-visual readiness remains asset-based; if the current visual stops being truthful enough, it must be replaced rather than papered over with abstraction.

View File

@ -0,0 +1,204 @@
# Implementation Plan: Website Homepage Hero
**Branch**: `218-homepage-hero` | **Date**: 2026-04-19 | **Spec**: `specs/218-homepage-hero/spec.md`
**Input**: Feature specification from `specs/218-homepage-hero/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Tighten the `apps/website` homepage hero so the first screen satisfies Spec 218: category context, precise headline and supporting copy, one clear CTA pair, a product-near visual, and bounded early trust signals.
- Implement the change by reusing the current `homeHero` content object and `PageHero.astro`, adding only the smallest missing semantic hooks, ordering guarantees, and asset or copy refinements instead of introducing a new hero framework.
- Validate the result with `corepack pnpm build:website` plus focused Playwright smoke coverage for hero composition, CTA hierarchy, visual truthfulness, and mobile meaning order.
## Technical Context
**Language/Version**: Astro 6.0.0 templates + TypeScript 5.9.x
**Primary Dependencies**: Astro 6, Tailwind CSS v4, existing Astro content modules and section primitives, Playwright browser smoke tests
**Storage**: Static filesystem content and assets under `apps/website/src` and `apps/website/public`; no database
**Testing**: `corepack pnpm build:website` plus Playwright smoke coverage in `apps/website/tests/smoke`
**Validation Lanes**: fast-feedback
**Target Platform**: Static public website for modern desktop and mobile browsers
**Project Type**: Web application in a monorepo (`apps/platform` plus `apps/website`)
**Performance Goals**: Keep the hero server-rendered and readable without required client-side hydration, preserve fast first-read clarity on desktop and mobile, and keep the product visual and CTA visible on narrow screens
**Constraints**: Stay strictly inside `apps/website`; preserve canonical core routes (`/`, `/product`, `/trust`, `/changelog`, `/contact`); keep one dominant primary CTA and one lower-emphasis secondary CTA; avoid unsupported trust claims, generic dashboard visuals, and any runtime coupling to `apps/platform`
**Scale/Scope**: One homepage route, one shared hero component, one hero content object, one product-near asset, and a focused extension of the existing homepage smoke coverage
## UI / Surface Guardrail Plan
- **Guardrail scope**: no operator-facing surface change
- **Native vs custom classification summary**: N/A - public Astro website surface only
- **Shared-family relevance**: none
- **State layers in scope**: page
- **Handling modes by drift class or surface**: N/A
- **Repository-signal treatment**: report-only
- **Special surface test profiles**: N/A
- **Required tests or manual smoke**: manual-smoke plus homepage-focused browser smoke coverage
- **Exception path and spread control**: none
- **Active feature PR close-out entry**: Smoke Coverage
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshots / Graph contract / deterministic capabilities / RBAC-UX / Filament guardrails: N/A for this feature because all work stays on the public Astro website and changes no `/admin`, `/admin/t/{tenant}/...`, or `/system` runtime surfaces.
- Read/write separation: Pass. The homepage hero remains a static public read surface. No writes, remote calls, queued work, or contact submission backend are introduced in this feature.
- Workspace and tenant isolation: Pass. The hero stays runtime-independent from `apps/platform`, with no shared auth, session, tenant data, or scoped route behavior.
- Data minimization: Pass. The feature only refines public copy, CTA paths, visual assets, and render semantics already owned by `apps/website`.
- Test governance: Pass. Proof remains in `fast-feedback` through static build output and focused browser smoke coverage, with no database, membership, provider, or heavy-suite defaults.
- Proportionality / no premature abstraction: Pass. The plan reuses `home.ts`, `PageHero.astro`, current CTA primitives, and the existing smoke harness instead of introducing a hero registry, CMS layer, or presentation framework.
- Persisted truth / new state: Pass. No database artifacts, queues, or independent state machines are added. Hero trust signals and the product-near visual remain file-based and derived from public website truth.
- UI semantics / few layers: Pass. The hero contract maps directly from `homeHero` content into `PageHero.astro`, with only thin render hooks or test markers if needed.
Status: ✅ No constitution violations identified before research.
## Test Governance Check
- **Test purpose / classification by changed surface**: Browser
- **Affected validation lanes**: fast-feedback
- **Why this lane mix is the narrowest sufficient proof**: The feature changes only public hero rendering, CTA emphasis, visual truthfulness, and responsive visibility. Browser smoke coverage is the narrowest layer that can prove those concerns without introducing backend or heavy browser-matrix cost.
- **Narrowest proving command(s)**: `corepack pnpm build:website` and `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts`
- **Fixture / helper / factory / seed / context cost risks**: none; public routes do not require database, auth, provider, workspace, or tenant setup
- **Expensive defaults or shared helper growth introduced?**: no; any helper additions stay inside the existing `apps/website/tests/smoke` harness and remain homepage-focused
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: N/A
- **Closing validation and reviewer handoff**: Re-run the website build and the focused homepage smoke file after hero changes. If shared smoke helpers change materially, reviewers may also run the full website smoke suite. Reviewers should verify required hero elements, one dominant CTA pair, visible product-near media, bounded trust cues, and mobile visibility order.
- **Budget / baseline / trend follow-up**: none beyond a small increase in homepage smoke assertions
- **Review-stop questions**: Does proof stay homepage-focused and browser-only? Did the change accidentally introduce a new abstraction or shared helper burden? Does the mobile layout keep CTA and product-near visual visible? Are trust claims still bounded and supportable?
- **Escalation path**: document-in-feature
- **Active feature PR close-out entry**: Smoke Coverage
- **Why no dedicated follow-up spec is needed**: Validation remains feature-local to the homepage hero and the existing website smoke harness. A separate follow-up spec is only needed if screenshot governance, visual-regression tooling, or multi-page hero conventions become shared structural concerns.
## Project Structure
### Documentation (this feature)
```text
specs/218-homepage-hero/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── homepage-hero.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/website/
├── package.json
├── public/
│ └── images/
│ └── hero-product-visual.svg
├── src/
│ ├── content/
│ │ └── pages/
│ │ ├── home.ts
│ │ ├── product.ts
│ │ └── trust.ts
│ ├── components/
│ │ ├── content/
│ │ ├── primitives/
│ │ └── sections/
│ │ └── PageHero.astro
│ ├── pages/
│ │ └── index.astro
│ └── types/
│ └── site.ts
└── tests/
└── smoke/
├── home-product.spec.ts
└── smoke-helpers.ts
```
**Structure Decision**: Keep the feature completely inside `apps/website`, reusing the existing `HeroContent` data shape, `PageHero.astro`, and homepage smoke suite. Prefer small edits to `home.ts`, `PageHero.astro`, and homepage smoke assertions over new hero components, registries, or cross-page content frameworks.
## Complexity Tracking
None.
## Proportionality Review
- **Current operator problem**: The homepage hero is the highest-risk public screen for drift into generic marketing or weak product truth, so small regressions there have disproportionate impact on credibility and next-step clarity.
- **Existing structure is insufficient because**: Spec 217 establishes homepage section flow broadly, but the current implementation still needs a hero-specific contract to lock down required semantics, CTA hierarchy, product-near truth, and mobile meaning order.
- **Narrowest correct implementation**: Reuse the existing `homeHero` content object and `PageHero.astro`, tightening content, render semantics, and smoke coverage only where Spec 218 requires stronger guarantees.
- **Ownership cost created**: Ongoing maintenance of explicit hero content rules, a product-near visual asset, and a slightly richer homepage smoke suite.
- **Alternative intentionally rejected**: A generic hero framework or a separate CMS-like hero configuration layer was rejected because only one homepage hero needs this contract now and the current Astro content model already fits the problem.
- **Release truth**: Current-release truth
## Phase 0 — Outline & Research (complete)
- Output: `specs/218-homepage-hero/research.md`
- Key decisions captured:
- Keep the hero local to the static Astro website and preserve runtime separation from `apps/platform`.
- Reuse the existing `homeHero` content object and `PageHero.astro` instead of adding a new hero abstraction.
- Treat hero semantics as explicit render responsibilities that can be tested directly.
- Keep the product visual truthful and derived from real product structure rather than decorative SaaS wallpaper.
- Validate through focused Playwright homepage smoke coverage plus build proof.
## Phase 1 — Design & Contracts (complete)
### Data model
- Output: `specs/218-homepage-hero/data-model.md`
- Model remains file- and route-based. No database schema changes are required.
### Public hero contract
- Output: `specs/218-homepage-hero/contracts/homepage-hero.openapi.yaml`
- Contract captures the homepage route plus the hero-only semantic requirements and downstream route expectations for Product, Trust, Changelog, and Contact.
### Quickstart
- Output: `specs/218-homepage-hero/quickstart.md`
- Quickstart covers local development, hero-specific verification points, build proof, and focused smoke-test execution.
### Agent context update
- Completed via `.specify/scripts/bash/update-agent-context.sh copilot` after plan artifacts were generated.
### Constitution re-check (post-design)
- ✅ The plan remains website-only and static, with no platform-runtime coupling.
- ✅ No new persistence, state machines, background operations, or auth flows are introduced.
- ✅ The chosen shape reuses existing Astro content modules, CTA primitives, and section components instead of adding speculative abstraction.
- ✅ Validation remains cheap, local, and aligned with the current website smoke harness.
## Phase 2 — Implementation Plan (next)
### Story 1 (P1): Understand the product from the first screen
- Review and tighten `apps/website/src/content/pages/home.ts` so the hero eyebrow, headline, supporting copy, CTA pair, and visual all align with Spec 218 language and positioning rules without hype or stacked claims.
- Keep hero composition inside `apps/website/src/components/sections/PageHero.astro`; add only the smallest missing semantic hooks and DOM structure needed to expose category context, headline, supporting copy, CTA pair, product-near visual, and optional trust subclaims as explicit hero elements.
- Preserve one dominant primary CTA to `/contact` and one secondary deepening CTA to `/product`, unless a stronger already-supported secondary destination is chosen during copy review.
- Tests / validation:
- Extend `apps/website/tests/smoke/home-product.spec.ts` and `apps/website/tests/smoke/smoke-helpers.ts` only as needed to assert the required hero elements and one clear CTA pair.
- Re-run `corepack pnpm build:website`.
### Story 2 (P2): Establish credibility without overclaiming
- Review `homeHero.trustSubclaims` against the current `/trust` public truth so any hero subclaim remains bounded, factual, concise, and supportable.
- Confirm `apps/website/public/images/hero-product-visual.svg` and its alt text remain product-near and truthfully derived from real UI structure; replace or tighten only if the asset reads like a generic dashboard or decorative placeholder.
- Ensure the hero stays product-near even if the visual remains stylized, and avoid introducing a separate trust badge, compliance matrix, or homepage-only proof system.
- Tests / validation:
- Add smoke assertions for visible product-near media, concise trust signals, and continued homepage reachability to `/trust` and other downstream routes.
- Keep assertions inside homepage smoke coverage; do not add a new visual-regression matrix.
### Story 3 (P3): Preserve correct next-step routing on desktop and mobile
- Verify the hero meaning order stays stable across desktop and narrow screens: category context and headline, supporting copy, CTA pair, product-near visual, and optional trust signals.
- Tighten responsive composition inside `PageHero.astro` only as needed to keep CTA and product visual visible without reordering semantics or hiding credibility cues on mobile.
- Finalize targeted smoke coverage for mobile visibility and hero-first route reachability into `/product`, `/contact`, and supporting public surfaces.
- Tests / validation:
- Extend `apps/website/tests/smoke/home-product.spec.ts` with narrow-screen hero checks or extracted helper assertions in `smoke-helpers.ts`.
- Run `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts`.
## Implementation Close-out
- Homepage hero delivery stayed local to `apps/website` and reused the existing `homeHero` content object plus `PageHero.astro` with no new hero framework or platform coupling.
- The shipped hero now exposes explicit semantic hooks for category context, headline, supporting copy, CTA pair, product-near visual, and trust cues, while keeping one primary CTA to `/contact` and one secondary deepening CTA to `/product`.
- The product visual was replaced with a product-near operating-record illustration that avoids fake KPI cards or generic analytics-dashboard theater.
- Shared smoke-helper growth stayed homepage-focused in `apps/website/tests/smoke/smoke-helpers.ts`; no auth, backend, provider, or database fixtures were introduced.
- Validation completed on 2026-04-19 with `corepack pnpm build:website` and `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts`.

View File

@ -0,0 +1,91 @@
# Quickstart: Website Homepage Hero
## Goal
Verify that the homepage hero in `apps/website` follows the Spec 218 contract: clear category context, precise copy, one CTA pair, product-near visual truth, bounded trust cues, and stable mobile meaning order.
## Prerequisites
- Node.js 20+
- Corepack enabled
- Repo dependencies installed with `corepack pnpm install`
## Run the website locally
From the repository root:
```bash
corepack pnpm dev:website
```
Alternative, inside the website app:
```bash
cd apps/website
corepack pnpm dev
```
Default local URL: `http://127.0.0.1:4321/`
## What to verify in the homepage hero
Check the hero in this order:
1. A clear category context or positioning cue appears above or adjacent to the main headline.
2. The main headline explains the product category, problem space, or intended outcome without generic startup language.
3. Supporting copy makes the headline easier to understand instead of repeating it.
4. Exactly one dominant primary CTA is visible, plus one lower-emphasis secondary CTA.
5. The hero includes a product-near visual that looks derived from real product structure rather than a generic dashboard placeholder.
6. Any trust subclaims remain concise, factual, and secondary to the product explanation.
7. The hero still routes cleanly into maintained downstream pages such as `/product`, `/trust`, `/changelog`, and `/contact`.
## Mobile verification
On a narrow viewport, verify:
1. Headline and supporting copy remain first in the reading flow.
2. CTA pair remains clearly visible without being pushed below decorative content.
3. Product-near visual still appears when it is a key credibility signal.
4. Optional trust cues stay visible only if they do not bury the main message.
## Build proof
From the repository root:
```bash
corepack pnpm build:website
```
## Focused browser smoke proof
Run the homepage-focused smoke file:
```bash
cd apps/website
corepack pnpm exec playwright test tests/smoke/home-product.spec.ts
```
## Optional broader browser proof
If shared smoke helpers or route behavior changed more broadly, run the full website smoke suite:
```bash
cd apps/website
corepack pnpm exec playwright test
```
## Expected proof points
- Hero required elements are all visible.
- One dominant primary CTA and one secondary CTA remain clearly differentiated.
- Product-near visual remains visible and believable.
- Trust subclaims stay bounded and concise.
- Homepage still routes cleanly into deeper informational and action surfaces.
- Mobile layout preserves the intended meaning order without hiding CTA or product reality.
## Feature Close-out
- Validated on 2026-04-19 with `corepack pnpm build:website` from the repository root.
- Validated on 2026-04-19 with `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts`.
- Homepage smoke coverage now checks the hero semantic structure, CTA pair, product-near visual alt text, bounded trust cues, and narrow-screen meaning order.
- Mobile proof keeps the CTA pair above the fold and verifies the product visual remains present within the hero in the correct sequence.

View File

@ -0,0 +1,41 @@
# Research: Website Homepage Hero
## Decision 1: Keep the hero implementation local to the static Astro website
- **Decision**: Continue treating the homepage hero as a static `apps/website` concern composed from the current Astro content module and section component, with no runtime dependency on `apps/platform`.
- **Rationale**: Spec 218 is explicitly website-only. The current website already runs as a standalone Astro app, and the hero changes concern semantics, copy discipline, asset truthfulness, and responsive ordering rather than dynamic runtime behavior.
- **Alternatives considered**:
- Couple hero content to `apps/platform` or a shared API: rejected because the spec forbids platform obligations and the hero needs no dynamic platform data.
- Introduce a CMS or hero-builder layer first: rejected because one homepage hero does not justify that operational overhead.
## Decision 2: Reuse the existing `homeHero` content object and `PageHero.astro`
- **Decision**: Preserve `apps/website/src/content/pages/home.ts` as the canonical source for hero content and refine `apps/website/src/components/sections/PageHero.astro` as the render surface instead of creating a new hero abstraction.
- **Rationale**: The current site already uses typed content exports and a shared `PageHero` component. This is the narrowest correct place to express hero-specific semantics without adding a second content shape, a render registry, or a page-builder concept.
- **Alternatives considered**:
- Build a homepage-hero-specific component tree parallel to `PageHero.astro`: rejected because the existing component already supports eyebrow, CTA pair, product visual, and trust subclaims.
- Hardcode all hero content directly in `index.astro`: rejected because it would bypass the current typed content pattern and make later hero iteration less disciplined.
## Decision 3: Make hero semantics explicit through direct render structure, not through a new framework
- **Decision**: If stronger hero guarantees are needed, add small render markers and explicit DOM ordering to `PageHero.astro` rather than inventing a new semantic layer.
- **Rationale**: Spec 218 requires stable verification of category context, headline, supporting copy, CTA hierarchy, product-near visual, and optional trust cues. Those guarantees can be expressed directly in the hero render surface and smoke tests.
- **Alternatives considered**:
- Add a separate hero registry or semantic presenter layer: rejected as premature abstraction for one public surface.
- Rely on text-only selectors in browser tests: rejected because stable hero markers make long-term regression checks clearer and less brittle.
## Decision 4: Treat the hero visual as a curated product-truth asset
- **Decision**: Keep the hero visual tied to a product-near asset such as the current `hero-product-visual.svg`, and only replace it with another asset that remains truthfully derived from real product structure.
- **Rationale**: Spec 218 explicitly rejects generic dashboard wallpaper and fantasy metrics. The visual must support credibility by showing product-adjacent structure, even if it is stylized for the website.
- **Alternatives considered**:
- Use abstract shapes or decorative illustration only: rejected because the spec requires product-near credibility.
- Pull in a theme-provided analytics dashboard placeholder: rejected because it would weaken product truth and create a generic SaaS feel.
## Decision 5: Validate through focused homepage smoke coverage
- **Decision**: Prove Spec 218 with the current website build command and focused Playwright updates in `apps/website/tests/smoke/home-product.spec.ts`, adding shared helpers only if they improve clarity without broadening scope.
- **Rationale**: The hero contract is about visible public rendering, CTA hierarchy, mobile meaning order, and route reachability. Browser smoke coverage is the narrowest proving layer that can validate those concerns.
- **Alternatives considered**:
- Build-only proof alone: rejected because static output generation does not prove CTA hierarchy, product-visual presence, or mobile ordering.
- Add full visual regression or multi-browser matrices immediately: rejected because the feature scope does not require that extra cost.

View File

@ -0,0 +1,196 @@
# Feature Specification: Website Homepage Hero
**Feature Branch**: `218-homepage-hero`
**Created**: 2026-04-19
**Status**: Draft
**Input**: User description: "Define Spec 218 as the website-only homepage hero contract for `apps/website`, covering hero role, required elements, copy and CTA rules, product-near visual constraints, trust subclaims, layout logic, responsive behavior, and explicit anti-patterns."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Without a clear homepage hero contract, the first screen of `apps/website` can drift into generic SaaS language, abstract visuals, weak product truth, or premature CTA pressure that makes the product feel less credible than it is.
- **Today's failure**: A first-time visitor can see a polished hero yet still fail to understand what the product is, why it matters, why it should be trusted, or which next route is the right one.
- **User-visible improvement**: Visitors can classify the product, understand the problem space, see a product-near signal, and choose a sensible next step from the hero alone.
- **Smallest enterprise-capable version**: One homepage-hero-only contract that defines the hero's role, mandatory elements, content priorities, CTA structure, product-visual rules, trust-subclaim rules, and excluded anti-patterns, without prescribing final copy or detailed visual implementation.
- **Explicit non-goals**: No full homepage structure beyond hero responsibilities, no final visual design, no final production copy, no complete Product or Trust page spec, no Pricing or Docs surface, no platform UI work, no Filament theming, no motion-spec detail, and no implementation detail for Astro or Tailwind.
- **Permanent complexity imported**: A stable website-local hero vocabulary covering category context, headline, supporting copy, CTA roles, product-near visual rules, trust-subclaim rules, information-density guardrails, and hero review criteria.
- **Why now**: The homepage structure is already defined in Spec 217, and the hero needs a tighter contract before screenshot strategy, final copy work, and Stitch exploration harden around weak defaults.
- **Why not local**: A one-off hero iteration would not reliably prevent generic marketing patterns, screenshot drift, overclaiming, or multi-CTA pressure as future design and copy passes land.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New website-local hero taxonomy; risk of drifting into design detail; risk of unsupported trust or compliance language. The scope remains justified because it is narrow, homepage-only, and directly improves product clarity and credibility.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**: `/` with onward routing from the hero into `/product`, `/trust`, `/changelog`, and `/contact`
- **Data Ownership**: Website-owned homepage-hero semantics, CTA targets, product-visual expectations, trust-subclaim boundaries, and responsive ordering inside `apps/website`; no tenant-owned records, platform runtime data, or shared persistence.
- **RBAC**: None. This feature applies to a public website surface and introduces no authorization model.
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: yes, but only within `apps/website`
- **Current operator problem**: Website contributors and reviewers do not yet have a bounded semantic contract for the most important public screen, so hero work can regress into generic marketing patterns or conflicting hero decisions.
- **Existing structure is insufficient because**: Spec 217 defines the homepage section model broadly, but it does not define the hero's specific semantic responsibilities, content priorities, screenshot truth rules, or CTA boundaries tightly enough for repeatable hero work.
- **Narrowest correct implementation**: One hero-only specification that locks the homepage hero into required semantic parts, bounded trust language, product-near visual expectations, and responsive meaning order without expanding into full page design or implementation detail.
- **Ownership cost**: Future homepage hero reviews must enforce category clarity, CTA discipline, visual truthfulness, and anti-pattern avoidance instead of allowing ad hoc exceptions to accumulate.
- **Alternative intentionally rejected**: Designing the hero directly in a theme or in Stitch without a semantic contract was rejected because it would let aesthetics re-decide product truth, CTA weight, and screenshot honesty each time.
- **Release truth**: Current-release truth
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Browser
- **Validation lane(s)**: fast-feedback
- **Why this classification and these lanes are sufficient**: This specification governs public hero rendering, claim placement, CTA hierarchy, and responsive information order on a static website surface. Browser smoke coverage and website build proof are the narrowest honest validation; no auth, tenant, database, or platform-runtime setup is required.
- **New or expanded test families**: Focused website smoke coverage for homepage hero presence, CTA reachability, visible product-near media, and mobile meaning-order checks.
- **Fixture / helper cost impact**: low; no workspace, tenant, auth, provider, or database fixtures are required.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: N/A
- **Standard-native relief or required special coverage**: Homepage-specific browser assertions are sufficient; no platform or operator-surface coverage is required.
- **Reviewer handoff**: Reviewers should confirm that the released hero includes the required semantic elements, maintains one dominant primary CTA, routes to real downstream pages, avoids unsupported trust language, and keeps product-near visibility on desktop and mobile.
- **Budget / baseline / trend impact**: none beyond small website smoke-suite growth.
- **Escalation needed**: document-in-feature
- **Active feature PR close-out entry**: Smoke Coverage
- **Planned validation commands**: `corepack pnpm build:website` and `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Understand the product from the first screen (Priority: P1)
A first-time buyer or technical evaluator lands on the homepage and can understand what the product roughly is, why it matters, and what to do next before leaving the hero.
**Why this priority**: If the hero fails to create immediate product clarity, every deeper page loses value.
**Independent Test**: This can be tested by visiting the homepage only and confirming that the hero provides category context, product/problem framing, and a clear primary next step without requiring deeper page visits.
**Acceptance Scenarios**:
1. **Given** a first-time visitor opens the homepage, **When** they read the hero, **Then** they can describe what kind of product this is and why it is relevant.
2. **Given** a visitor wants to know what to do next, **When** they inspect the hero CTAs, **Then** they can distinguish the primary next step from the secondary deepening route.
3. **Given** a visitor is unsure whether the product is real or just marketing, **When** they inspect the hero visual, **Then** they see a product-near signal rather than pure abstraction.
---
### User Story 2 - Assess credibility before committing (Priority: P2)
A serious evaluator can leave the hero with a preliminary sense that the product is real, technically serious, and careful about trust claims without the hero trying to carry the full Trust page.
**Why this priority**: The hero must establish enough credibility to earn deeper exploration, but not by overclaiming.
**Independent Test**: This can be tested by reviewing the hero only and confirming that trust language remains bounded, product-near evidence is present, and deeper trust routes are available.
**Acceptance Scenarios**:
1. **Given** the hero includes trust subclaims, **When** a reviewer inspects them, **Then** each claim is concise, supportable, and not presented as a legal or compliance guarantee.
2. **Given** a technical stakeholder scans the hero, **When** they read the copy and view the product-near visual, **Then** they see signs of governance, audit, recovery, or drift seriousness without being overwhelmed by detail.
---
### User Story 3 - Choose the right deeper route (Priority: P3)
A qualified visitor can use the hero to move into the Product, Trust, Changelog, or Contact flow instead of being forced into immediate sales contact or a dead-end route.
**Why this priority**: The hero should route intentionally, not absorb the whole website or pressure every visitor into the same action.
**Independent Test**: This can be tested by starting on the homepage hero and verifying that the visible CTA pair leads into real, maintained downstream routes with clear intent.
**Acceptance Scenarios**:
1. **Given** a visitor wants deeper product detail, **When** they use the secondary hero CTA, **Then** they reach a real downstream informational surface such as `/product` or `/trust`.
2. **Given** a visitor is ready to engage, **When** they use the primary hero CTA, **Then** they reach the intended primary next-step route without competing primary actions.
### Edge Cases
- What happens when a publishable real screenshot is not yet ready? The hero may use a curated or stylized product-near visual, but it must still reflect real product structure and must not fall back to generic dashboard wallpaper.
- How does the hero handle trust or compliance language that cannot be substantiated publicly? The claim must be softened or removed instead of implied as a guarantee.
- What happens when mobile space compresses the layout? The meaning order must remain headline, supporting copy, CTA, product-near visual, and optional trust signals, without hiding the CTA or product reality.
- What happens when too many chips, badges, or CTA ideas compete for space? The hero must reduce visible signals rather than turning into several mini-sections at once.
- What happens when a secondary CTA would lead to a weak or placeholder route? The hero must route to a stronger maintained surface or suppress that CTA until the downstream page is real.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes only the public homepage hero in `apps/website`. It introduces no Microsoft Graph calls, no queueing, no long-running operations, no authorization changes, and no Filament or operator-facing surface changes. Its contract is explicitly local to `apps/website` and must not create obligations for `apps/platform`.
**Constitution alignment (UI-SEM-001 / LAYER-001 / BLOAT-001):** This feature intentionally introduces a website-local hero semantic layer because ad hoc hero styling and copy decisions are insufficient to preserve product truth, CTA hierarchy, and trust boundaries. The layer remains narrow, homepage-only, and must not expand into a shared website-platform design contract.
**Implementation boundary:** Any implementation under this specification MUST preserve the existing website working contract by keeping `@tenantatlas/website`, `WEBSITE_PORT`, and the root `dev:website` / `build:website` workflows intact, and it MUST NOT introduce runtime or package coupling to `apps/platform`.
### Functional Requirements
- **FR-001**: The homepage hero MUST act as a positioning, product-near, and trust-capable entry point for `apps/website`, not as a decorative splash screen.
- **FR-002**: Within the hero alone, a first-time visitor MUST be able to answer what the product roughly is, why it matters, who it is plausibly for, why it is worth exploring further, and what the next sensible step is.
- **FR-003**: The hero MUST functionally include category context, a primary headline, supporting copy, one dominant primary CTA, one secondary CTA, and a product-near visual. Trust subclaims MAY be included when supportable.
- **FR-004**: The hero MUST prioritize product and problem understanding first, a clear next step second, product reality third, early trust signals fourth, and stylistic finish fifth.
- **FR-005**: The hero MUST include a positioning eyebrow, category context, or equivalent descriptor that anchors the product category or problem space, and it MUST NOT be pure marketing filler or the only place where category context exists.
- **FR-006**: The primary headline MUST frame the product category, problem space, or intended outcome clearly enough for quick orientation, and it MUST NOT rely on buzzwords, stacked messages, or unsupported superlatives.
- **FR-007**: Supporting copy MUST translate the headline into clearer product and problem language, MUST sharpen relevance, and MUST NOT become a miniature product page or a simple paraphrase of the headline.
- **FR-008**: The hero MUST present exactly one dominant primary CTA aligned to the current maturity of the website and product story. It MUST NOT present several equally loud primary sales actions in parallel.
- **FR-009**: The hero MUST present one secondary CTA that deepens understanding through a real maintained downstream route. It MUST remain lower-emphasis than the primary CTA and MUST NOT point to an immature or empty surface.
- **FR-010**: The hero MUST include a product-near visual. That visual MUST be credible, relevant to the product, compositionally integrated into the hero, and stronger than pure abstraction as a proof-of-product signal.
- **FR-011**: If the hero uses a screenshot, crop, or stylized product shot, it SHOULD be derived from real product reality and truthful simplification. It MUST NOT invent fantasy product UI, fake metrics, or generic analytics-template dashboards.
- **FR-012**: Optional trust subclaims or trust chips MAY appear only when they are factually correct, concise, and supportable by deeper public context such as `/trust`. They MUST NOT turn into a badge wall or imply legal, compliance, or security guarantees that the website cannot responsibly substantiate.
- **FR-013**: The hero MUST keep information density controlled: one main headline, one supporting-copy block, one dominant primary CTA, one secondary CTA, one visual focus, and only a small number of trust signals.
- **FR-014**: The hero MUST include a clear text core and a clear visual focus. It MAY use left-right or top-bottom composition, but text clarity and CTA visibility MUST remain primary.
- **FR-015**: The product-near visual SHOULD feel like part of the same hero composition rather than a detached block. The hero MUST NOT become image-only, text-only where product-near material is available, or readability-damaging decorative layering.
- **FR-016**: Any design or Stitch exploration based on this specification MUST preserve the semantic hero structure of category context, headline, supporting copy, primary CTA, secondary CTA, product-near visual, and optional trust chips. Exploration MUST NOT re-decide homepage IA or product positioning from scratch.
- **FR-017**: On mobile, the hero MUST preserve the meaning order of headline, supporting copy, CTA, product-near visual, and optional trust signals.
- **FR-018**: Mobile simplification MAY crop, reduce, or reorder the visual, but it MUST NOT remove a product-near visual entirely when that visual is a key credibility signal, and it MUST NOT bury the CTA.
- **FR-019**: The hero MUST avoid the following disallowed patterns: generic startup hero, abstract-only hero, dashboard-wallpaper hero, badge-overload hero, sales-pressure hero, and compliance-theater hero.
- **FR-020**: The hero MUST support both buyer-oriented clarity and first-pass technical plausibility by signaling that governance, audit, recovery, drift, or similar operational concerns are real parts of the product without trying to explain the full product in one screen.
- **FR-021**: This specification MUST remain strictly local to `apps/website` and MUST NOT create implementation, design, routing, or runtime obligations for `apps/platform`.
- **FR-022**: This specification MUST define, at minimum, the hero's functional role, mandatory elements, copy rules, CTA rules, product-visual rules, trust-subclaim rules, information-density rules, prohibited anti-patterns, and the hero semantic structure required for downstream design exploration.
#### Out of Scope
- Full homepage composition beyond the hero
- Final pixel-level visual design
- Final production copy
- Full Product page, Trust page, Pricing page, Docs surface, or Contact flow specification
- Platform UI, Filament theming, or `apps/platform` behavior
- Astro, Tailwind, animation, or implementation-detail prescriptions
- A full screenshot strategy beyond hero truthfulness and credibility boundaries
### Key Entities *(include if feature involves data)*
- **Category Context**: The short eyebrow, descriptor, or context cue that anchors the hero in a believable product category or problem space.
- **Product-Near Visual**: A screenshot, crop, or truthful stylized product shot that signals real product existence and supports positioning.
- **Hero CTA Pair**: The primary action and lower-emphasis secondary deepening route that move visitors into the correct next step.
- **Trust Subclaim**: A concise early trust signal that is factual, bounded, and supportable by a deeper public trust surface.
- **Hero Semantic Structure**: The ordered set of content roles that design exploration must preserve even as visual execution evolves.
## Assumptions & Dependencies
- This specification builds on [Spec 214](../214-website-visual-foundation/spec.md), [Spec 215](../215-website-core-pages/spec.md), and [Spec 217](../217-homepage-structure/spec.md).
- `/product`, `/trust`, `/changelog`, and `/contact` remain the canonical downstream routes surfaced from the homepage hero.
- A publishable product-near screenshot or truthful visual approximation can be prepared without inventing product functionality.
- Trust, hosting, residency, governance, and compliance-adjacent language will stay limited to claims the team can support publicly.
- Later hero copy exploration, screenshot strategy, and Stitch design work must treat this specification as a semantic boundary rather than as optional inspiration.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A first-time visitor can state what the product roughly is, why it matters, and the next sensible action within 60 seconds of reading the homepage hero.
- **SC-002**: The released hero includes all required functional roles: category context, primary headline, supporting copy, one dominant primary CTA, one secondary CTA, and a product-near visual on both desktop and mobile presentations.
- **SC-003**: Zero released hero variants contain unsupported compliance or security-guarantee language, fake trust badges, or fabricated product metrics.
- **SC-004**: From the hero alone, a visitor can reach at least one deeper informational surface and one primary next-step surface in one click, with no competing equally dominant primary actions.
- **SC-005**: On representative desktop and mobile widths, the primary CTA and the product-near visual remain visible without horizontal scrolling or loss of headline-first reading order.
- **SC-006**: Reviewers can map the shipped hero to the allowed semantic structure and confirm that it does not match any prohibited anti-pattern family defined by this specification.
## Planned Follow-on Work
- Product-visual and screenshot strategy
- Final hero copy exploration
- Stitch-based hero design exploration
- Downstream homepage-section detail work that assumes this hero contract rather than redefining it
## Spec 223 Rebuild Status
- **Classification**: continuing
- **Forward owner**: `../223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md`
- **Material drift**: none logged. AstroDeck can provide the hero shell, but it does not change the allowed semantic structure or anti-pattern rules.
- **Forward-work rule**: mapped AstroDeck hero primitives must keep one clear anchor, one CTA pair, product-near visual truth, and bounded trust cues on desktop and mobile.

View File

@ -0,0 +1,208 @@
# Tasks: Website Homepage Hero
**Input**: Design documents from `/specs/218-homepage-hero/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/homepage-hero.openapi.yaml`
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
## Historical Status
This implementation record is retained for traceability and is superseded by AstroDeck rebuild.
Forward planning now lives in `../223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md`. Do not reset or reopen the checkbox state below; new work belongs to the Spec 223 mapping and follow-up slices.
## Test Governance Checklist
- [X] Lane assignment stays `Browser` in `fast-feedback`, which is the narrowest sufficient proof for this homepage-hero-only change.
- [X] New or changed tests stay in the existing website smoke suite instead of widening into a heavier family.
- [X] Shared helpers stay cheap by default; no backend, auth, database, or provider fixtures are introduced.
- [X] Planned validation commands remain the feature-local website build proof and focused Playwright smoke coverage.
- [X] No additional budget, baseline, or escalation path is required beyond `document-in-feature` for this slice.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Establish stable hero-specific render hooks before story work begins.
- [X] T001 Add stable homepage-hero element markers and an explicit hero root hook in `apps/website/src/components/sections/PageHero.astro`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the shared hero contract and smoke helpers that every story slice depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T002 [P] Extend reusable hero smoke assertions for semantic structure, CTA hierarchy, product-near visual presence, and mobile ordering in `apps/website/tests/smoke/smoke-helpers.ts`
- [X] T003 [P] Tighten the shared hero content object for CTA pair, product visual metadata, and trust-subclaim availability in `apps/website/src/content/pages/home.ts`
**Checkpoint**: Homepage-hero foundations are ready. User-story work can proceed.
---
## Phase 3: User Story 1 - Understand the Product from the First Screen (Priority: P1) 🎯 MVP
**Goal**: Make the homepage hero explain what TenantAtlas is, why it matters, and what the next step is in the first reading pass.
**Independent Test**: Visit `/` and confirm the hero shows category context, a precise headline, supporting copy, and exactly one dominant primary CTA plus one lower-emphasis secondary CTA without opening any other route.
### Tests for User Story 1
> **NOTE**: Write this test first and confirm it fails before implementing the story.
- [X] T004 [P] [US1] Write failing hero-clarity smoke assertions for category context, headline and supporting-copy separation, and one CTA pair in `apps/website/tests/smoke/home-product.spec.ts`
### Implementation for User Story 1
- [X] T005 [P] [US1] Refine the hero eyebrow, headline, supporting copy, and CTA labels to meet Spec 218 positioning rules in `apps/website/src/content/pages/home.ts`
- [X] T006 [US1] Render the explicit hero text core and CTA cluster in `apps/website/src/components/sections/PageHero.astro`
**Checkpoint**: The homepage hero delivers the MVP story of product clarity, buyer relevance, and one clear next step.
---
## Phase 4: User Story 2 - Establish Credibility Without Overclaiming (Priority: P2)
**Goal**: Show product reality and bounded early trust inside the hero without collapsing into badge theater or generic SaaS visuals.
**Independent Test**: Visit `/` and confirm the hero visual reads as product-near, trust cues stay concise and supportable, and the hero feels credible without requiring the full Trust page.
### Tests for User Story 2
> **NOTE**: Write this test first and confirm it fails before implementing the story.
- [X] T007 [P] [US2] Write failing hero smoke assertions for product-near visual truth and bounded trust subclaims in `apps/website/tests/smoke/home-product.spec.ts`
### Implementation for User Story 2
- [X] T008 [P] [US2] Tighten the hero visual asset, alt text, and bounded trust-subclaim copy in `apps/website/public/images/hero-product-visual.svg` and `apps/website/src/content/pages/home.ts`
- [X] T009 [US2] Refine hero visual and trust-subclaim presentation so credibility cues stay secondary and supportable in `apps/website/src/components/sections/PageHero.astro`
**Checkpoint**: The hero shows believable product reality and early trust without fake proof systems or inflated claims.
---
## Phase 5: User Story 3 - Preserve Correct Next-Step Routing on Desktop and Mobile (Priority: P3)
**Goal**: Keep the hero meaning order, CTA visibility, and route intent intact across narrow and wide screens.
**Independent Test**: Visit `/` on a narrow viewport and confirm the hero preserves headline and copy first, keeps the CTA pair visible, keeps the product-near visual visible, and routes into the intended hero destinations.
### Tests for User Story 3
> **NOTE**: Write this test first and confirm it fails before implementing the story.
- [X] T010 [P] [US3] Write failing hero smoke assertions for narrow-screen meaning order and hero route reachability in `apps/website/tests/smoke/home-product.spec.ts`
### Implementation for User Story 3
- [X] T011 [P] [US3] Tighten the hero responsive composition so the CTA pair and product-near visual remain visible on narrow screens in `apps/website/src/components/sections/PageHero.astro`
- [X] T012 [US3] Finalize the hero CTA routing and secondary deepening intent in `apps/website/src/content/pages/home.ts`
**Checkpoint**: The hero preserves correct mobile meaning order and intentional next-step routing without hiding the CTA or product reality.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Validate proof commands, tighten anti-pattern compliance, and capture close-out notes.
- [X] T013 [P] Review hero copy, trust wording, and anti-pattern compliance in `apps/website/src/content/pages/home.ts`, `apps/website/src/content/pages/trust.ts`, and `apps/website/public/images/hero-product-visual.svg`
- [X] T014 [P] Run `corepack pnpm build:website` from `package.json` and verify hero proof steps in `specs/218-homepage-hero/quickstart.md`
- [X] T015 [P] Run `cd apps/website && corepack pnpm exec playwright test tests/smoke/home-product.spec.ts` against the hero smoke coverage in `apps/website/tests/smoke/home-product.spec.ts`
- [X] T016 Record the focused hero smoke-coverage close-out and any helper-growth notes in `specs/218-homepage-hero/plan.md` and `specs/218-homepage-hero/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately.
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories.
- **User Stories (Phases 3-5)**: Depend on Foundational. They remain independently testable, but shared edits to `apps/website/src/components/sections/PageHero.astro`, `apps/website/src/content/pages/home.ts`, and `apps/website/tests/smoke/home-product.spec.ts` should land sequentially in story order.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: Starts after Foundational and is the MVP slice.
- **User Story 2 (P2)**: Starts after Foundational for test and content work, and remains independently valuable because it upgrades credibility and product-near truth inside the hero. Shared `PageHero.astro` work should follow US1.
- **User Story 3 (P3)**: Starts after Foundational for responsive and routing work, and remains independently valuable because it hardens mobile meaning order and hero route intent. Shared `PageHero.astro` and `home.ts` work should follow US2.
### Within Each User Story
- Write the story-specific browser smoke assertions first and confirm they fail.
- Update content and supporting truth sources before final hero render adjustments in `apps/website/src/components/sections/PageHero.astro`.
- Treat `apps/website/src/components/sections/PageHero.astro`, `apps/website/src/content/pages/home.ts`, and `apps/website/tests/smoke/home-product.spec.ts` as shared assembly points: helper or asset work may branch, but shared file edits should land sequentially.
- Finish the hero render or content wiring before running the story proof commands.
---
## Parallel Opportunities
- `T002` and `T003` can run in parallel after `T001`.
- In US1, `T004` and `T005` can run in parallel before `T006`.
- In US2, `T007` and `T008` can run in parallel before `T009`.
- In US3, `T010` and `T011` can run in parallel before `T012`.
- In polish, `T013`, `T014`, and `T015` can run in parallel before `T016`.
---
## Parallel Example: User Story 1
```bash
# After the hero foundations are complete, split the first slice into test + content work:
Task: "T004 [US1] Write failing hero-clarity smoke assertions for category context, headline and supporting-copy separation, and one CTA pair"
Task: "T005 [US1] Refine the hero eyebrow, headline, supporting copy, and CTA labels"
# Then finish the shared hero render:
Task: "T006 [US1] Render the explicit hero text core and CTA cluster"
```
## Parallel Example: User Story 2
```bash
# Safe split inside US2 is limited to assertions plus content and asset prep before shared hero render work:
Task: "T007 [US2] Write failing hero smoke assertions for product-near visual truth and bounded trust subclaims"
Task: "T008 [US2] Tighten the hero visual asset, alt text, and bounded trust-subclaim copy"
# Then finish the shared hero presentation work:
Task: "T009 [US2] Refine hero visual and trust-subclaim presentation"
```
## Parallel Example: User Story 3
```bash
# Split mobile assertions from responsive render work first:
Task: "T010 [US3] Write failing hero smoke assertions for narrow-screen meaning order and hero route reachability"
Task: "T011 [US3] Tighten the hero responsive composition so the CTA pair and product-near visual remain visible on narrow screens"
# Then finalize route intent in the shared hero content:
Task: "T012 [US3] Finalize the hero CTA routing and secondary deepening intent"
```
---
## Implementation Strategy
### MVP First
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Run the website build proof and the homepage hero smoke coverage.
5. Demo the homepage hero MVP with clear product explanation and one dominant next step.
### Incremental Delivery
1. Foundations establish stable render hooks, the shared hero data contract, and smoke helpers.
2. US1 locks in immediate product clarity and CTA discipline.
3. US2 upgrades hero credibility through truthful product-near media and bounded trust cues.
4. US3 hardens mobile meaning order and route intent without changing the broader homepage section model.
5. Polish runs the proof commands, validates anti-pattern compliance, and records close-out notes before merge.
### Suggested MVP Scope
- Deliver through **User Story 1** for the smallest independently valuable slice.
- Add **User Story 2** next for stronger visual truth and bounded credibility cues.
- Finish with **User Story 3** for deliberate narrow-screen behavior and route integrity.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Findings Intake & Team Queue V1
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-21
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation passed on 2026-04-21 in the first review iteration.
- The repository template requires route, authorization, and surface-contract metadata; the spec still avoids code-level implementation, language, and architecture detail beyond those mandatory contract fields.
- No clarification markers remain, so the spec is ready for `/speckit.plan`.

View File

@ -0,0 +1,470 @@
openapi: 3.1.0
info:
title: Findings Intake & Team Queue Surface Contract
version: 1.1.0
description: >-
Internal reference contract for the canonical Findings intake queue,
the narrow Claim finding shortcut, and intake continuity into tenant
finding detail and My Findings. The application continues to return
rendered HTML through Filament and Livewire. The vendor media types
below document the structured page and action models that must be
derivable before rendering. This is not a public API commitment.
paths:
/admin/findings/intake:
get:
summary: Canonical shared findings intake queue
description: >-
Returns the rendered admin-plane intake queue for visible unassigned
findings in the current workspace. The page always keeps the fixed
intake scope and may apply an active-tenant prefilter. Queue views are
limited to Unassigned and Needs triage.
responses:
'302':
description: Redirects into the existing workspace chooser flow when workspace context is not yet established
'200':
description: Rendered Findings intake page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.findings-intake+json:
schema:
$ref: '#/components/schemas/FindingsIntakePage'
'403':
description: Workspace membership exists but no currently viewable findings scope exists for intake disclosure anywhere in the workspace
'404':
description: Workspace scope is not visible because membership is missing or out of scope
/admin/findings/intake/{finding}/claim:
post:
summary: Claim a visible intake finding into personal execution
description: >-
Logical contract for the Livewire-backed Claim finding row action after
the operator has reviewed the lightweight preview/confirmation content.
The server must re-check entitlement, assign capability, and current
assignee under lock before mutating the record.
parameters:
- name: finding
in: path
required: true
schema:
type: integer
responses:
'200':
description: Claim succeeded and the finding left intake
content:
application/vnd.tenantpilot.findings-intake-claim+json:
schema:
$ref: '#/components/schemas/ClaimFindingResult'
'403':
description: Viewer is in scope but lacks the existing findings assign capability
'404':
description: Workspace or tenant scope is not visible for the referenced finding
'409':
description: The row is no longer claimable because another operator claimed it first or it otherwise left intake scope before mutation
/admin/t/{tenant}/findings/{finding}:
get:
summary: Tenant finding detail with intake continuity support
description: >-
Returns the rendered tenant finding detail page. The logical contract
below documents only the continuity inputs required when the page is
opened from Findings intake.
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: finding
in: path
required: true
schema:
type: integer
- name: nav
in: query
required: false
style: deepObject
explode: true
schema:
$ref: '#/components/schemas/CanonicalNavigationContext'
responses:
'200':
description: Rendered tenant finding detail page
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.finding-detail-from-intake+json:
schema:
$ref: '#/components/schemas/FindingDetailContinuation'
'403':
description: Viewer is in scope but lacks the existing findings capability for the tenant detail destination
'404':
description: Tenant or finding is not visible because workspace or tenant entitlement is missing
components:
schemas:
FindingsIntakePage:
type: object
required:
- header
- appliedScope
- queueViews
- summaryCounts
- rows
- emptyState
properties:
header:
$ref: '#/components/schemas/IntakeHeader'
appliedScope:
$ref: '#/components/schemas/IntakeAppliedScope'
queueViews:
type: array
items:
$ref: '#/components/schemas/QueueViewDefinition'
summaryCounts:
$ref: '#/components/schemas/IntakeSummaryCounts'
rows:
description: >-
Rows are ordered overdue first, reopened second, new third, then
remaining unassigned backlog. Within each bucket, rows with due
dates sort by dueAt ascending, rows without due dates sort last,
and remaining ties sort by findingId descending.
type: array
items:
$ref: '#/components/schemas/IntakeFindingRow'
emptyState:
oneOf:
- $ref: '#/components/schemas/IntakeEmptyState'
- type: 'null'
IntakeHeader:
type: object
required:
- title
- description
properties:
title:
type: string
enum:
- Findings intake
description:
type: string
clearTenantFilterAction:
oneOf:
- $ref: '#/components/schemas/ActionLink'
- type: 'null'
IntakeAppliedScope:
type: object
required:
- workspaceScoped
- fixedScope
- queueView
- tenantPrefilterSource
properties:
workspaceScoped:
type: boolean
fixedScope:
type: string
enum:
- visible_unassigned_open_findings_only
queueView:
type: string
enum:
- unassigned
- needs_triage
tenantPrefilterSource:
type: string
enum:
- active_tenant_context
- explicit_filter
- none
tenantLabel:
type:
- string
- 'null'
QueueViewDefinition:
type: object
required:
- key
- label
- fixed
- active
properties:
key:
type: string
enum:
- unassigned
- needs_triage
label:
type: string
fixed:
type: boolean
active:
type: boolean
badgeCount:
type:
- integer
- 'null'
minimum: 0
IntakeSummaryCounts:
type: object
description: Counts derived only from visible intake rows.
required:
- visibleUnassigned
- visibleNeedsTriage
- visibleOverdue
properties:
visibleUnassigned:
type: integer
minimum: 0
visibleNeedsTriage:
type: integer
minimum: 0
visibleOverdue:
type: integer
minimum: 0
IntakeFindingRow:
type: object
required:
- findingId
- tenantId
- tenantLabel
- summary
- severity
- status
- intakeReason
- dueState
- detailUrl
properties:
findingId:
type: integer
tenantId:
type: integer
tenantLabel:
type: string
summary:
type: string
severity:
$ref: '#/components/schemas/Badge'
status:
$ref: '#/components/schemas/Badge'
dueAt:
type:
- string
- 'null'
format: date-time
dueState:
$ref: '#/components/schemas/DueState'
ownerLabel:
type:
- string
- 'null'
intakeReason:
type: string
enum:
- Unassigned
- Needs triage
claimAction:
oneOf:
- $ref: '#/components/schemas/ClaimFindingAffordance'
- type: 'null'
detailUrl:
type: string
navigationContext:
oneOf:
- $ref: '#/components/schemas/CanonicalNavigationContext'
- type: 'null'
ClaimFindingAffordance:
type: object
required:
- actionId
- label
- enabled
- requiresConfirmation
properties:
actionId:
type: string
enum:
- claim
label:
type: string
enum:
- Claim finding
enabled:
type: boolean
requiresConfirmation:
type: boolean
enum:
- true
confirmationTitle:
type:
- string
- 'null'
confirmationBody:
type:
- string
- 'null'
disabledReason:
type:
- string
- 'null'
DueState:
type: object
required:
- label
- tone
properties:
label:
type: string
tone:
type: string
enum:
- calm
- warning
- danger
IntakeEmptyState:
type: object
required:
- title
- body
- action
properties:
title:
type: string
body:
type: string
reason:
type: string
enum:
- no_visible_intake_work
- active_tenant_prefilter_excludes_rows
action:
oneOf:
- $ref: '#/components/schemas/OpenMyFindingsActionLink'
- $ref: '#/components/schemas/ClearTenantFilterActionLink'
ClaimFindingResult:
type: object
required:
- findingId
- tenantId
- assigneeUserId
- auditActionId
- queueOutcome
- nextPrimaryAction
properties:
findingId:
type: integer
tenantId:
type: integer
assigneeUserId:
type: integer
auditActionId:
type: string
enum:
- finding.assigned
queueOutcome:
type: string
enum:
- removed_from_intake
nextPrimaryAction:
$ref: '#/components/schemas/OpenMyFindingsActionLink'
nextInspectAction:
oneOf:
- $ref: '#/components/schemas/OpenFindingActionLink'
- type: 'null'
FindingDetailContinuation:
type: object
description: >-
Continuity payload for tenant finding detail when it is opened from the
Findings intake queue. The backLink is present whenever canonical intake
navigation context is provided and may be null only for direct entry
without intake continuity context.
required:
- findingId
- tenantId
properties:
findingId:
type: integer
tenantId:
type: integer
backLink:
oneOf:
- $ref: '#/components/schemas/BackToFindingsIntakeActionLink'
- type: 'null'
CanonicalNavigationContext:
type: object
required:
- source_surface
- canonical_route_name
properties:
source_surface:
type: string
canonical_route_name:
type: string
tenant_id:
type:
- integer
- 'null'
back_label:
type:
- string
- 'null'
back_url:
type:
- string
- 'null'
ActionLink:
type: object
required:
- label
- url
properties:
label:
type: string
url:
type: string
OpenMyFindingsActionLink:
allOf:
- $ref: '#/components/schemas/ActionLink'
- type: object
properties:
label:
type: string
enum:
- Open my findings
OpenFindingActionLink:
allOf:
- $ref: '#/components/schemas/ActionLink'
- type: object
properties:
label:
type: string
enum:
- Open finding
ClearTenantFilterActionLink:
allOf:
- $ref: '#/components/schemas/ActionLink'
- type: object
properties:
label:
type: string
enum:
- Clear tenant filter
BackToFindingsIntakeActionLink:
allOf:
- $ref: '#/components/schemas/ActionLink'
- type: object
properties:
label:
type: string
enum:
- Back to findings intake
Badge:
type: object
required:
- label
properties:
label:
type: string
color:
type:
- string
- 'null'

View File

@ -0,0 +1,204 @@
# Data Model: Findings Intake & Team Queue V1
## Overview
This feature does not add or modify persisted entities. It introduces three derived models:
- the canonical admin-plane `Findings intake` queue at `/admin/findings/intake`
- the fixed `Unassigned` and `Needs triage` queue-view state
- the post-claim handoff result that points the operator into the existing `My Findings` surface
All three remain projections over existing finding, tenant membership, workspace context, and audit truth.
## Existing Persistent Inputs
### 1. Finding
- Purpose: Tenant-owned workflow record representing current governance or remediation work.
- Key persisted fields used by this feature:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `severity`
- `due_at`
- `subject_display_name`
- `owner_user_id`
- `assignee_user_id`
- `reopened_at`
- `triaged_at`
- `in_progress_at`
- Relationships used by this feature:
- `tenant()`
- `ownerUser()`
- `assigneeUser()`
Relevant existing semantics:
- `Finding::openStatuses()` defines intake inclusion and intentionally excludes `acknowledged`.
- `Finding::openStatusesForQuery()` remains relevant for `My Findings`, but not for intake.
- Spec 219 defines owner-versus-assignee meaning.
- Spec 221 defines the post-claim destination when a finding becomes assigned to the current user.
### 2. Tenant
- Purpose: Tenant boundary for queue disclosure, claim authorization, and tenant-plane detail drilldown.
- Key persisted fields used by this feature:
- `id`
- `workspace_id`
- `name`
- `external_id`
- `status`
### 3. TenantMembership And Capability Truth
- Purpose: Per-tenant entitlement and capability boundary for queue visibility and claim.
- Sources:
- `tenant_memberships`
- existing `CapabilityResolver`
- Key values used by this feature:
- tenant membership presence
- role-derived `TENANT_FINDINGS_VIEW`
- role-derived `TENANT_FINDINGS_ASSIGN`
Queue disclosure, tab badges, filter options, and claim affordances must only materialize for tenants where the actor remains a member and is authorized for the relevant finding capability.
### 4. Workspace Context
- Purpose: Active workspace selection in the admin plane.
- Source: Existing workspace session context, not a new persisted model for this feature.
- Effect on this feature:
- gates entry into the admin intake page
- constrains visible tenants to the current workspace
- feeds the default active-tenant prefilter through `CanonicalAdminTenantFilterState`
### 5. AuditLog
- Purpose: Existing audit record for security- and workflow-relevant mutations.
- Effect on this feature:
- successful claims write an audit entry through the existing `finding.assigned` action ID
- the audit payload records before/after assignment state, workspace, tenant, actor, and target finding
## Derived Presentation Entities
### 1. IntakeFindingRow
Logical row model for `/admin/findings/intake`.
| Field | Meaning | Source |
|---|---|---|
| `findingId` | Target finding identifier | `Finding.id` |
| `tenantId` | Tenant route scope for detail drilldown | `Finding.tenant_id` |
| `tenantLabel` | Tenant name visible in the queue | `Tenant.name` |
| `summary` | Operator-facing finding summary | `Finding.subject_display_name` plus existing fallback logic |
| `severity` | Severity badge value | `Finding.severity` |
| `status` | Lifecycle badge value | `Finding.status` |
| `dueAt` | Due date if present | `Finding.due_at` |
| `dueState` | Derived urgency label such as overdue or due soon | existing findings due-state logic |
| `ownerLabel` | Accountable owner when present | `ownerUser.name` |
| `intakeReason` | Why the row still belongs in shared intake | derived from lifecycle plus assignment truth |
| `detailUrl` | Tenant finding detail route | derived from tenant finding view route |
| `navigationContext` | Return-path payload back to intake | derived from `CanonicalNavigationContext` |
| `claimEnabled` | Whether the current actor may claim now | derived from assign capability and current claimable state |
Validation rules:
- Row inclusion requires all of the following:
- finding belongs to the current workspace
- finding belongs to a tenant the current user may inspect
- finding status is in `Finding::openStatuses()`
- `assignee_user_id` is `null`
- Already-assigned findings are excluded even if overdue or reopened.
- `acknowledged` findings are excluded.
- Hidden-tenant or capability-blocked findings produce no row, no count, no tab badge contribution, and no tenant filter option.
Derived queue reason:
- `Needs triage` when status is `new` or `reopened`
- `Unassigned` when status is `triaged` or `in_progress`
### 2. FindingsIntakeState
Logical state model for the intake page.
| Field | Meaning |
|---|---|
| `workspaceId` | Current admin workspace scope |
| `queueView` | Fixed queue mode: `unassigned` or `needs_triage` |
| `tenantFilter` | Optional active-tenant prefilter, defaulted from canonical admin tenant context |
| `fixedScope` | Constant indicator that the page remains restricted to unassigned intake rows |
Rules:
- `queueView` is limited to `unassigned` and `needs_triage`.
- `tenantFilter` is clearable; `fixedScope` is not.
- `tenantFilter` values may only reference entitled tenants.
- Invalid or stale tenant filter state is discarded rather than widening visibility.
- Summary counts and tab badges reflect only visible intake rows after the active queue view and tenant prefilter are applied.
### 3. ClaimOutcome
Logical mutation result for `Claim finding`.
| Field | Meaning | Source |
|---|---|---|
| `findingId` | Claimed finding identifier | `Finding.id` |
| `tenantId` | Tenant scope of the claimed finding | `Finding.tenant_id` |
| `assigneeUserId` | New assignee after success | current user ID |
| `auditActionId` | Stable audit action identifier | existing `finding.assigned` |
| `nextPrimaryAction` | Primary handoff after success | `Open my findings` |
| `nextInspectAction` | Optional inspect fallback | existing tenant finding detail route |
Validation rules:
- Actor must remain a tenant member for the target finding.
- Actor must have `TENANT_FINDINGS_ASSIGN`.
- The locked record must still have `assignee_user_id = null` at mutation time.
- Claim leaves `owner_user_id` unchanged.
- Claim leaves workflow status unchanged.
- Success removes the row from intake immediately because the assignee is no longer null.
- Conflict does not mutate the row and must return honest feedback so the queue can refresh.
## State And Ordering Rules
### Intake inclusion order
1. Restrict to the current workspace.
2. Restrict to visible tenant IDs.
3. Restrict to `assignee_user_id IS NULL`.
4. Restrict to `Finding::openStatuses()`.
5. Apply the fixed queue view:
- `unassigned` keeps all included rows
- `needs_triage` keeps only `new` and `reopened`
6. Apply optional tenant prefilter.
7. Sort overdue rows first, reopened rows second, new rows third, then remaining backlog.
8. Within each bucket, rows with due dates sort by `dueAt` ascending, rows without due dates sort last, and remaining ties sort by `findingId` descending.
### Urgency semantics
- Overdue rows are the highest-priority bucket.
- Reopened non-overdue rows are the next bucket.
- New rows follow reopened rows.
- Triaged and in-progress unassigned rows remain visible in `Unassigned`, but never in `Needs triage`.
### Claim semantics
- Claim is not a lifecycle status transition.
- Claim performs one responsibility transition only: `assignee_user_id` moves from `null` to the current user.
- Owner accountability remains unchanged.
- Successful claim makes the finding eligible for `My Findings` immediately because the record is now assigned.
- Stale-row conflicts must fail before save when the locked record already has an assignee.
### Empty-state semantics
- If no visible intake rows exist anywhere in scope, the page shows a calm empty state with one CTA into `My Findings`.
- If the active tenant prefilter causes the empty state while other visible tenants still contain intake rows, the empty state must explain the tenant boundary and offer `Clear tenant filter`.
- Neither branch may reveal hidden tenant names or hidden queue quantities.
## Authorization-Sensitive Output
- Tenant labels, tab badges, filter values, rows, and counts are only derived from entitled tenants.
- Queue visibility remains workspace-context dependent.
- Claim affordances remain visible only inside in-scope membership context and must still enforce `403` server-side for members missing assign capability.
- Detail navigation remains tenant-scoped and must preserve existing `404` and `403` semantics on the destination.
- The derived queue state remains useful without revealing hidden tenant names, row counts, or empty-state hints.

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