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
This commit is contained in:
ahmido 2026-04-22 12:26:18 +00:00
parent ccd4a17209
commit 12fb5ebb30
28 changed files with 4041 additions and 81 deletions

View File

@ -234,9 +234,10 @@ ## Active Technologies
- 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) - 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) - 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) - 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) - 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) - Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -272,7 +273,9 @@ ## Code Style
## Recent Changes ## Recent Changes
- 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`) - 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`)
- 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 --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check ### 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 Sync Impact Report
- Version change: 2.6.0 -> 2.7.0 - Version change: 2.7.0 -> 2.8.0
- Modified principles: None - Modified principles: None
- Added sections: - Added sections:
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases, - Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases,
migration shims, dual-write logic, and compatibility fixtures in a migration shims, dual-write logic, and compatibility fixtures in a
pre-production codebase; includes AI-agent verification checklist, pre-production codebase; includes AI-agent verification checklist,
review rule, and explicit exit condition at first production deploy 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 - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- .specify/templates/spec-template.md: added "Compatibility posture" - .specify/templates/spec-template.md: added "Compatibility posture"
default block ✅ 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 - .github/agents/copilot-instructions.md: added "Pre-production
compatibility check" agent checklist ✅ compatibility check" agent checklist ✅
- Commands checked: - 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. - Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures.
- Presentation helpers SHOULD remain optional adapters, not mandatory architecture. - 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) ### 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. - 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. - 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. - [ ] 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. - [ ] 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 ## 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`. - [ ] CHK010 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. - [ ] 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.
- [ ] CHK009 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`. - [ ] CHK012 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. - [ ] 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 ## Review Outcome
- [ ] CHK011 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`. - [ ] CHK014 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`. - [ ] CHK015 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. - [ ] 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 ## Notes
@ -48,7 +54,7 @@ ## Notes
- `keep`: the current scope, guardrail handling, and proof depth are justified. - `keep`: the current scope, guardrail handling, and proof depth are justified.
- `split`: the intent is valid, but the scope should narrow before merge. - `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. - `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. - `reject-or-split`: hidden drift, unresolved exception spread, or wrong proof depth blocks merge as proposed.
- Check items off as completed: `[x]` - Check items off as completed: `[x]`
- Add comments or findings inline - 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] - **Exception path and spread control**: [none / describe the named exception boundary]
- **Active feature PR close-out entry**: [Guardrail / Exception / Smoke Coverage / N/A] - **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 ## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* *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 - 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 - 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 - 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 - 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 - 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 - 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] - **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks] - **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`)* ## 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 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, 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. 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: **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 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, - 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, - 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, - 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. - 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: **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, - 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`), - 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

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

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

@ -30,6 +30,18 @@ public function __construct(
private readonly FindingNotificationService $findingNotificationService, 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 public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
{ {
$this->authorize($finding, $tenant, $actor, [ $this->authorize($finding, $tenant, $actor, [
@ -491,6 +503,26 @@ public function reopenBySystem(
return $reopenedFinding; 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];
}
/** /**
* @param array<int, string> $capabilities * @param array<int, string> $capabilities
*/ */
@ -557,6 +589,27 @@ private function validatedReason(string $reason, string $field): string
return $reason; 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 * @param array<string, mixed> $context
*/ */

View File

@ -76,7 +76,7 @@ public function handle(Request $request, Closure $next): Response
return $next($request); return $next($request);
} }
if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake'], true)) { if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true)) {
$this->configureNavigationForRequest($panel); $this->configureNavigationForRequest($panel);
return $next($request); 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/w/')
|| str_starts_with($path, '/admin/workspaces') || str_starts_with($path, '/admin/workspaces')
|| str_starts_with($path, '/admin/operations') || 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', '/admin/findings/intake'], 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); $this->configureNavigationForRequest($panel);
@ -265,6 +265,10 @@ private function adminPathRequiresTenantSelection(string $path): bool
return false; 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; return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
} }
} }

View File

@ -6,6 +6,7 @@
use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\MyFindingsInbox; use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
@ -20,6 +21,7 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Findings\FindingAssignmentHygieneService;
use App\Services\Auth\WorkspaceRoleCapabilityMap; use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment; use App\Support\BackupHealth\TenantBackupHealthAssessment;
@ -49,6 +51,7 @@ final class WorkspaceOverviewBuilder
public function __construct( public function __construct(
private WorkspaceCapabilityResolver $workspaceCapabilityResolver, private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
private CapabilityResolver $capabilityResolver, private CapabilityResolver $capabilityResolver,
private FindingAssignmentHygieneService $findingAssignmentHygieneService,
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver, private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
private TenantBackupHealthResolver $tenantBackupHealthResolver, private TenantBackupHealthResolver $tenantBackupHealthResolver,
private RestoreSafetyResolver $restoreSafetyResolver, private RestoreSafetyResolver $restoreSafetyResolver,
@ -134,6 +137,7 @@ public function build(Workspace $workspace, User $user): array
]; ];
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user); $myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user);
$findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $user);
$zeroTenantState = null; $zeroTenantState = null;
@ -174,6 +178,7 @@ public function build(Workspace $workspace, User $user): array
'workspace_name' => (string) $workspace->name, 'workspace_name' => (string) $workspace->name,
'accessible_tenant_count' => $accessibleTenants->count(), 'accessible_tenant_count' => $accessibleTenants->count(),
'my_findings_signal' => $myFindingsSignal, 'my_findings_signal' => $myFindingsSignal,
'findings_hygiene_signal' => $findingsHygieneSignal,
'summary_metrics' => $summaryMetrics, 'summary_metrics' => $summaryMetrics,
'triage_review_progress' => $triageReviewProgress['families'], 'triage_review_progress' => $triageReviewProgress['families'],
'attention_items' => $attentionItems, 'attention_items' => $attentionItems,
@ -263,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 * @param Collection<int, Tenant> $accessibleTenants
* @return list<array<string, mixed>> * @return list<array<string, mixed>>

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

@ -3,6 +3,7 @@
$workspace = $overview['workspace'] ?? ['name' => 'Workspace']; $workspace = $overview['workspace'] ?? ['name' => 'Workspace'];
$quickActions = $overview['quick_actions'] ?? []; $quickActions = $overview['quick_actions'] ?? [];
$myFindingsSignal = $overview['my_findings_signal'] ?? null; $myFindingsSignal = $overview['my_findings_signal'] ?? null;
$findingsHygieneSignal = $overview['findings_hygiene_signal'] ?? null;
$zeroTenantState = $overview['zero_tenant_state'] ?? null; $zeroTenantState = $overview['zero_tenant_state'] ?? null;
@endphp @endphp
@ -101,6 +102,52 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
</section> </section>
@endif @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)) @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"> <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"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">

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

@ -3,7 +3,7 @@ # Product Roadmap
> Strategic thematic blocks and release trajectory. > Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs. > 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) ## 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 ### R2 Completion — Evidence & Exception Workflows
- Review pack export (Spec 109 — done) - Review pack export (Spec 109 — done)
- Exception/risk-acceptance workflow for Findings → Spec 154 (draft) - 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. **Scope direction**: start with one-way handoff and internal visibility, not full bidirectional sync or full ITSM modeling.
### Compliance Readiness & Executive Review Packs ### 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. **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. **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. **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 **Layering**:
- Map controls to evidence sources, evaluation rules, and manual attestations when automation is partial - **S1**: framework-neutral Canonical Control Catalog plus TenantPilot technical interpretations as the normative control core
- Keep BSI / NIS2 / CIS views as reporting layers on top of the shared control model - **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 - Avoid framework-specific one-off reports that bypass the common evidence, findings, exception, and export pipeline
### Entra Role Governance ### Entra Role Governance

View File

@ -5,7 +5,7 @@ # Spec Candidates
> >
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec` > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
**Last reviewed**: 2026-04-22 (promoted `Findings Notifications & Escalation v1` to Spec 224 and aligned the list) **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)
--- ---
@ -222,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. - **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 - **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 ### Baseline Snapshot Fidelity Semantics
- **Type**: hardening - **Type**: hardening
- **Source**: semantic clarity & operator-language audit 2026-03-21 - **Source**: semantic clarity & operator-language audit 2026-03-21
@ -350,14 +419,14 @@ ### 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 - 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 - 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 - 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 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 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 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. - **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) - **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), 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 - **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 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. - **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 - **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 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.
@ -374,6 +443,25 @@ ### Assignment Hygiene & Stale Work Detection
- **Strategic sequencing**: Shortly after ownership semantics, ideally alongside or immediately after notifications. - **Strategic sequencing**: Shortly after ownership semantics, ideally alongside or immediately after notifications.
- **Priority**: high - **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 ### Finding Outcome Taxonomy & Verification Semantics
- **Type**: workflow semantics / reporting hardening - **Type**: workflow semantics / reporting hardening
- **Source**: findings execution layer candidate pack 2026-04-17; status/outcome reporting gap analysis - **Source**: findings execution layer candidate pack 2026-04-17; status/outcome reporting gap analysis
@ -419,58 +507,105 @@ ### Cross-Tenant Findings Workboard v1
- **Roadmap fit**: MSP portfolio and operations. - **Roadmap fit**: MSP portfolio and operations.
- **Priority**: medium-low - **Priority**: medium-low
### Compliance Control Catalog & Interpretation Foundation ### Canonical Control Catalog Foundation
- **Type**: 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 - **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. - **Layer position**: **S1** — normative control core
- **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. - **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**: - **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 - Introduce a framework-neutral canonical control catalog centered on control themes and objectives rather than framework clauses or raw Microsoft API objects
- Separate three independent version layers: framework source version, TenantPilot interpretation version, and customer/MSP profile version - Define canonical domains and subdomains plus stable product-wide control keys that outlive individual APIs, workloads, or framework versions
- Allow each control to declare automation posture such as fully automatable, partially automatable, manual-attestation-required, or outside-product-scope - Classify each control by control class, detectability class, evaluation strategy, evidence archetypes, and artifact suitability for baseline, drift, findings, exceptions, reports, and evidence packs
- Map one control to multiple evidence sources and evaluation rules, and allow one evidence source to support multiple controls - 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
- Treat risk exceptions, findings, stored reports, and readiness views as downstream consumers of the control model rather than the place where framework logic lives - 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
- 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 - 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
- Start with lightweight, product-owned control metadata and interpretation summaries rather than assuming full storage of normative framework text inside the product - 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**: - **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 - **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**: 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 - **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**: - **Explicit non-goals**:
- Not a certification engine or legal interpretation layer - 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 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**: - **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 - The platform can represent canonical domains, subdomains, and controls with stable keys independent of framework source versions
- A single control can map to multiple evidence requirements and evaluation rules, with optional manual attestation where automation is incomplete - Every seed control declares control class, detectability class, evaluation strategy, and at least one evidence archetype
- The model can express whether a control is fully automatable, partially automatable, manual-only, or outside current product scope - Every seed control can declare whether it is baseline-capable, drift-capable, finding-capable, exception-capable, and report or evidence-pack-capable
- A framework-pack update can preview new, changed, and retired controls before activation - The model can bind one canonical control to multiple Microsoft subject families or signal sources without redefining the control per workload
- Framework-oriented readiness/reporting work can consume the shared model without introducing hardcoded BSI/NIS2/CIS rule paths in presentation features - 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
- **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. - 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 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 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 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. - **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.
- **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 - **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.
- **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 - **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.
- **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. - **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.
- **Priority**: medium - **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 ### Compliance Readiness & Executive Review Packs
- **Type**: feature - **Type**: feature
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance - **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. - **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. - **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**: - **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 - 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 - 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 - 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 - 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 - 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. - **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. - **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) - **Priority**: medium (high strategic value, but depends on evidence foundation maturity)
### Enterprise App / Service Principal Governance ### Enterprise App / Service Principal Governance
@ -976,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) - **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) - **Priority**: medium (deferred until normalization is complete)
### Tenant App Status False-Truth Removal > Repository cleanup strand from the strict read-only legacy audit 2026-04-22:
- **Type**: hardening > 1. **Dead Transitional Residue Cleanup**
- **Source**: legacy / orphaned truth audit 2026-03-16 > 2. **Onboarding State Fallback Retirement**
- **Classification**: quick removal > 3. **Canonical Operation Type Source of Truth**
- **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. > 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.
- **Target model**: `Tenant`
- **Canonical source of truth**: `ProviderConnection.consent_status` and `ProviderConnection.verification_status` ### Dead Transitional Residue Cleanup
- **Must stop being read**: `Tenant.app_status` in `TenantResource` table columns, infolist/details, filters, and badge-domain mapping. - **Type**: hardening / cleanup
- **Can be removed immediately**: - **Source**: strict read-only legacy / compatibility audit 2026-04-22; orphaned-truth residue review
- TenantResource reads of `app_status` - **Absorbs / broadens**: the earlier `Tenant App Status False-Truth Removal` slice plus adjacent dead-symbol cleanup
- tenant app-status badge domain / badge mapping usage - **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.
- factory defaults that seed `app_status` - **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.
- **Remove only after cutover**: - **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.
- the `tenants.app_status` column itself, once all UI/report/export reads are confirmed gone - **In scope**:
- **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. - remove unused deprecated `BaselineProfile::STATUS_*` constants
- **UI / resource / policy / test impact**: - remove orphaned tenant app-status badge, factory, fixture, and test residue
- UI/resources: remove misleading badge and filter from tenant surfaces - verify that no hidden runtime, UI, filter, cast, or API dependency still exists before removal
- Policy: none - document the remaining active domain language after cleanup
- Tests: update `TenantFactory`, remove assertions that treat `app_status` as live truth - **Out of scope**: operation-type dual semantics, onboarding state fallbacks, provider identity or migration review, Baseline Scope V2, and spec-backed legacy redirect paths.
- **Scope boundaries**: - **Key requirements**:
- In scope: remove stale tenant app-status reads and schema field - dead deprecated constants must be removed when no productive reference remains
- Out of scope: provider connection UX redesign, credential migration, broader tenant health redesign - orphaned badge, status, factory, and fixture residue must not survive as silent compatibility lore
- **Dependencies**: None required if the immediate operator-facing action is removal rather than replacement with a new tenant-level derived badge. - cleanup must include tests and fixtures in the same change
- **Risks**: Low rollout risk. Main risk is short-term operator confusion about where to view connection health after removal. - removal must prove there is no hidden runtime, UI, filter, cast, or API dependency
- **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. - 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 - **Priority**: high
### Provider Connection Status Vocabulary Cutover ### Provider Connection Status Vocabulary Cutover

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Assignment Hygiene & Stale Work Detection
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-22
**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
- Repository spec governance intentionally requires route, RBAC, and surface-contract metadata; the spec avoids code-level implementation choices and keeps the feature framed around operator workflow, trust, and bounded scope.
- Validation pass completed on 2026-04-22 with no open clarification markers.

View File

@ -0,0 +1,276 @@
openapi: 3.1.0
info:
title: Assignment Hygiene Surface Contract
version: 1.0.0
summary: Logical internal contract for Spec 225 report rendering, overview discoverability, and finding drilldown.
description: |
This contract documents the structured payloads and UI-facing surfaces that Spec 225 must satisfy.
It is intentionally logical rather than public-API only: the feature reuses existing Filament pages,
workspace overview builders, and finding detail routes instead of introducing a new public controller namespace.
servers:
- url: https://logical.internal
description: Non-routable placeholder used to describe internal repository contracts.
paths:
/internal/findings/hygiene-issues:
get:
summary: Evaluate derived findings hygiene issues for the current visible workspace scope.
description: |
Logical internal contract implemented by the narrow findings hygiene service.
It derives `broken_assignment` and `stale_in_progress` from current finding, audit,
tenant-entitlement, and user lifecycle truth without persisting a second workflow state.
operationId: listFindingHygieneIssues
x-not-public-http: true
parameters:
- name: workspaceId
in: query
required: true
schema:
type: integer
- name: tenantId
in: query
required: false
schema:
type:
- integer
- 'null'
- name: reason
in: query
required: false
schema:
$ref: '#/components/schemas/HygieneReasonFilter'
responses:
'200':
description: Derived findings hygiene issues and visible-scope summary counts.
content:
application/vnd.tenantpilot.findings-hygiene-report+json:
schema:
$ref: '#/components/schemas/FindingsHygieneReportSurface'
/admin/findings/hygiene:
get:
summary: Canonical workspace-context findings hygiene report.
operationId: viewFindingsHygieneReport
parameters:
- name: tenant
in: query
required: false
schema:
type:
- string
- 'null'
- name: reason
in: query
required: false
schema:
$ref: '#/components/schemas/HygieneReasonFilter'
responses:
'200':
description: Existing Filament page renders the read-first findings hygiene report.
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.findings-hygiene-report+json:
schema:
$ref: '#/components/schemas/FindingsHygieneReportSurface'
'404':
description: Non-member or out-of-scope workspace remains deny-as-not-found; hidden tenants inside an entitled workspace are suppressed from rows, counts, filters, and hints rather than causing a route-level `404`.
/admin:
get:
summary: Workspace overview includes one findings hygiene signal.
operationId: viewWorkspaceOverviewWithFindingsHygieneSignal
responses:
'200':
description: Existing workspace overview renders the hygiene summary signal in attention or calm state for the current visible scope.
content:
text/html:
schema:
type: string
application/vnd.tenantpilot.findings-hygiene-overview-signal+json:
schema:
$ref: '#/components/schemas/FindingsHygieneOverviewSignal'
/admin/t/{tenant}/findings/{finding}:
get:
summary: Report drilldown opens the existing tenant finding detail route.
operationId: openFindingFromHygieneReport
parameters:
- name: tenant
in: path
required: true
schema:
type: integer
- name: finding
in: path
required: true
schema:
type: integer
responses:
'200':
description: Existing finding detail renders for an entitled tenant operator.
content:
text/html:
schema:
type: string
'403':
description: In-scope member lacks the current capability to inspect the finding.
'404':
description: Recipient no longer has tenant or record visibility.
components:
schemas:
HygieneReason:
type: string
enum:
- broken_assignment
- stale_in_progress
HygieneReasonFilter:
type: string
enum:
- all
- broken_assignment
- stale_in_progress
FindingsHygieneIssue:
type: object
required:
- findingId
- tenantId
- tenantName
- findingSummary
- status
- lastWorkflowActivityAt
- reasons
- detailUrl
properties:
findingId:
type: integer
workspaceId:
type: integer
tenantId:
type: integer
tenantName:
type: string
findingSummary:
type: string
status:
type: string
ownerUserId:
type:
- integer
- 'null'
ownerName:
type:
- string
- 'null'
assigneeUserId:
type:
- integer
- 'null'
assigneeName:
type:
- string
- 'null'
dueAt:
type:
- string
- 'null'
format: date-time
overdue:
type: boolean
lastWorkflowActivityAt:
type:
- string
- 'null'
format: date-time
reasons:
type: array
minItems: 1
items:
$ref: '#/components/schemas/HygieneReason'
detailUrl:
type: string
FindingsHygieneSummary:
type: object
required:
- uniqueIssueCount
- brokenAssignmentCount
- staleInProgressCount
- isCalm
properties:
uniqueIssueCount:
type: integer
minimum: 0
brokenAssignmentCount:
type: integer
minimum: 0
staleInProgressCount:
type: integer
minimum: 0
isCalm:
type: boolean
FindingsHygieneReportSurface:
type: object
required:
- appliedReasonFilter
- appliedTenantFilter
- issues
- summary
properties:
appliedReasonFilter:
$ref: '#/components/schemas/HygieneReasonFilter'
appliedTenantFilter:
type:
- string
- 'null'
issues:
type: array
items:
$ref: '#/components/schemas/FindingsHygieneIssue'
summary:
$ref: '#/components/schemas/FindingsHygieneSummary'
emptyState:
type:
- object
- 'null'
properties:
title:
type: string
description:
type: string
ctaLabel:
type:
- string
- 'null'
ctaUrl:
type:
- string
- 'null'
FindingsHygieneOverviewSignal:
type: object
required:
- headline
- description
- uniqueIssueCount
- brokenAssignmentCount
- staleInProgressCount
- isCalm
- ctaLabel
- ctaUrl
properties:
headline:
type: string
description:
type: string
description: Short operator-facing summary derived from broken-assignment and stale-in-progress counts.
uniqueIssueCount:
type: integer
minimum: 0
brokenAssignmentCount:
type: integer
minimum: 0
staleInProgressCount:
type: integer
minimum: 0
isCalm:
type: boolean
ctaLabel:
type: string
ctaUrl:
type: string

View File

@ -0,0 +1,176 @@
# Data Model: Assignment Hygiene & Stale Work Detection
## Overview
This feature introduces no new persisted business entity. Existing finding truth, audit logs, tenant memberships, and user lifecycle truth remain canonical. The new work is one bounded derived hygiene layer over those existing records.
## Existing Persistent Entities
### Finding
**Purpose**: Canonical tenant-scoped findings workflow truth for ownership, lifecycle, and timing.
**Key fields used by this feature**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `owner_user_id`
- `assignee_user_id`
- `due_at`
- `in_progress_at`
- `reopened_at`
- `resolved_at`
- `closed_at`
- `last_seen_at`
- `times_seen`
**Relationships**:
- belongs to one workspace
- belongs to one tenant
- may reference one current owner user
- may reference one current assignee user
**Rules relevant to hygiene**:
- Only non-terminal findings participate in hygiene evaluation.
- Owner-only findings are not hygiene issues by themselves.
- Unassigned intake findings are not hygiene issues by themselves.
- `last_seen_at` remains observation truth and must not reset the stale operator-work window.
### AuditLog
**Purpose**: Existing audit truth for workflow actions and responsibility changes.
**Key fields used by this feature**:
- `id`
- `workspace_id`
- `tenant_id`
- `auditable_type`
- `auditable_id`
- `action`
- `created_at`
**Rules relevant to hygiene**:
- Existing finding workflow audit actions provide one source of workflow-activity timestamps.
- No new audit action id is required for this feature because the report is read-only.
### User
**Purpose**: Canonical operator identity record.
**Key fields used by this feature**:
- `id`
- `deleted_at`
**Rules relevant to hygiene**:
- Soft-deleted users are treated as unavailable for current assignment execution.
- The feature does not add a separate availability state.
### Tenant Membership / Tenant Entitlement Truth
**Purpose**: Current authorization truth for whether an assignee can still work inside the tenant.
**Key inputs used by this feature**:
- membership existence for the tenant
- current tenant visibility
- current findings-view capability in the referenced tenant
**Rules relevant to hygiene**:
- Broken assignment is derived from current workability, not historical assignment legitimacy.
- Hidden tenants remain excluded from rows, counts, and filters.
## Derived Models
### FindingHygieneIssue
**Purpose**: Canonical derived issue envelope used by both the report and the overview signal.
**Fields**:
- `finding_id`
- `workspace_id`
- `tenant_id`
- `tenant_name`
- `finding_summary`
- `status`
- `owner_user_id`
- `owner_name`
- `assignee_user_id`
- `assignee_name`
- `reasons`: list of hygiene reasons
- `last_workflow_activity_at`
- `due_at`
- `is_overdue`
**Validation rules**:
- The finding must be non-terminal.
- `finding_summary`, `owner_name`, and `assignee_name` are display-ready values resolved from the current finding and loaded user relationships; they remain derived, not persisted.
- `reasons` contains one or more of the allowed hygiene reasons.
- `last_workflow_activity_at` is derived from workflow anchors only and must not come from observation freshness.
- One finding produces one issue row even when multiple reasons apply.
### HygieneReason
**Purpose**: Bounded derived reason vocabulary for this feature.
**Allowed values**:
- `broken_assignment`
- `stale_in_progress`
**Rules**:
- Reasons are derived labels, not persisted finding status.
- Reasons exist because they change operator routing and filter behavior on the hygiene surface.
### FindingsHygieneOverviewSignal
**Purpose**: Workspace overview summary projection for fast discovery.
**Fields**:
- `headline`
- `description`
- `unique_issue_count`
- `broken_assignment_count`
- `stale_in_progress_count`
- `is_calm`
- `cta_label`
- `cta_url`
**Rules**:
- Counts are based on unique visible finding issues, not duplicated reason rows.
- `description` is a short operator-facing summary derived from broken-assignment and stale-in-progress counts; no separate severity ordering is introduced.
- When `unique_issue_count = 0`, the signal remains visible in a calm state with zero issues, calm descriptive copy, and the canonical CTA into the report.
- The signal uses the same source query and tenant visibility truth as the canonical report.
## Classification Matrix
| Reason | Trigger | Required finding state | Required visibility truth | Exclusions |
|--------|---------|------------------------|---------------------------|------------|
| `broken_assignment` | Assignee can no longer act | non-terminal, `assignee_user_id` present | current user may see the finding and the assigned user is either soft-deleted or no longer tenant-entitled | owner-only, unassigned, hidden tenant |
| `stale_in_progress` | No meaningful workflow movement for 7 days | `status = in_progress` and non-terminal | current user may see the finding | merely overdue, recently reassigned, recently reopened, or recently moved into progress |
## Workflow Activity Anchor Rules
- `in_progress_at` is the baseline activity anchor for work that actually started.
- `reopened_at` can reset the stale window when the finding re-enters active lifecycle.
- Existing workflow audit rows for `finding.assigned`, `finding.in_progress`, and `finding.reopened` can supersede the baseline anchor when they are newer.
- `last_seen_at` and `times_seen` remain observation truth only and must not reset the stale-work window.
## Persistence Boundaries
- No new table, enum-backed persistence, or report snapshot is introduced.
- The hygiene report and overview signal are recalculated from current truth at read time.
- Repair continues through the existing finding detail mutation path and its current audit behavior.

View File

@ -0,0 +1,271 @@
# Implementation Plan: Assignment Hygiene & Stale Work Detection
**Branch**: `225-assignment-hygiene` | **Date**: 2026-04-22 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/225-assignment-hygiene/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/225-assignment-hygiene/spec.md`
**Note**: This plan keeps the work inside the existing findings workflow, canonical admin-plane findings pages, workspace overview builder, and current tenant finding detail repair actions. The intended implementation adds one new read-first findings hygiene page, one small overview signal, and one narrow derived-query service. It does not add a new table, a new workflow state, a notification channel, an automation engine, or a second repair surface.
## Summary
Introduce a canonical `/admin/findings/hygiene` report plus a matching workspace overview signal that surface unhealthy findings workflow items, including broken assignments and stale in-progress work. Back both surfaces with one narrow `FindingAssignmentHygieneService` that derives `broken assignment` and `stale in progress` from existing `Finding` lifecycle fields, current tenant entitlement, user soft-delete truth, and existing finding workflow audit timestamps. Reuse the current tenant finding detail route for repair, preserve `My Findings` and intake as the healthy-work and unassigned-work surfaces, and keep the new hygiene slice strictly read-first.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade
**Primary Dependencies**: `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives
**Storage**: PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned
**Testing**: Pest v4 feature tests with Filament/Livewire assertions
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Dockerized Laravel web application via Sail locally and Linux containers in deployment
**Project Type**: Laravel monolith inside the `wt-plattform` monorepo
**Performance Goals**: Keep report and overview queries scoped to visible tenants, avoid N+1 owner or assignee resolution, and keep summary counts cheap enough for the existing `/admin` overview render path
**Constraints**: No new persistence, no hidden-tenant leakage, no automatic reassignment, no new findings lifecycle state, no new assets, and no inline repair mutation on the hygiene report
**Scale/Scope**: One new canonical admin-plane report, one new overview signal, one narrow findings service, one Blade view, one middleware route allow-list update, and three focused feature suites
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament page, table, empty-state, and workspace summary primitives only
- **Shared-family relevance**: findings workflow family (`My Findings`, intake, finding detail) and workspace overview signal family
- **State layers in scope**: shell, 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, global-context-shell
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none; the feature adds one bounded findings page and one overview signal rather than a new shared UI family
- **Active feature PR close-out entry**: Guardrail
## Constitution Check
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | Hygiene remains derived from live finding workflow truth, not from snapshots or exports |
| Read/write separation | PASS | PASS | The report and overview signal are read-only; repair stays on the existing finding detail actions and audit path |
| Graph contract path | PASS | PASS | No Microsoft Graph call path or contract-registry change is introduced |
| Deterministic capabilities / RBAC-UX | PASS | PASS | Admin-plane report rows and counts remain tenant-safe, non-members stay `404`, and current finding-view capability stays authoritative for disclosure |
| Workspace / tenant isolation | PASS | PASS | `/admin/findings/hygiene` is tenantless by URL but every row, count, and filter remains tenant-entitlement checked |
| Run observability / Ops-UX | PASS | PASS | No new `OperationRun`, queued workflow, or notification family is introduced |
| Proportionality / no premature abstraction | PASS | PASS | One narrow service is justified because both the report and overview signal need the same derived issue truth and counting rules |
| Persisted truth / few layers | PASS | PASS | No table, materialized hygiene state, or UI-only lifecycle is introduced |
| Behavioral state discipline | PASS | PASS | `broken assignment` and `stale in progress` remain derived hygiene reasons, not new persisted finding statuses |
| Filament-native UI (UI-FIL-001) | PASS | PASS | The feature uses a native Filament page, native table filters, native empty-state affordances, and existing workspace-summary primitives |
| Decision-first / action-surface contract | PASS | PASS | The report is the one primary repair surface, the overview signal is a secondary drill-in, and repair still happens on the existing finding detail |
| Test governance (TEST-GOV-001) | PASS | PASS | Proof stays in focused feature suites for hygiene classification, visibility, overview counts, and drilldown safety |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The feature adds a Filament v5 page and stays inside Livewire v4-compatible patterns |
| Provider registration / global search / assets | PASS | PASS | Panel providers remain in `apps/platform/bootstrap/providers.php`; no new globally searchable resource or asset family is added |
## Test Governance Check
- **Test purpose / classification by changed surface**: `Feature` for the hygiene report, derived classification, overview signal consistency, and tenant-safe disclosure behavior
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: The primary risks are integrated operator outcomes: which findings appear, why they appear, whether hidden tenants leak into counts, whether the overview matches the canonical report, and whether row drilldown preserves the existing authorization contract. Focused feature tests prove that without adding browser or heavy-governance breadth.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php`
- **Fixture / helper / factory / seed / context cost risks**: Moderate. Tests need mixed visible versus hidden tenant memberships, soft-deleted or entitlement-lost assignees, in-progress timestamps, existing finding workflow audit rows, and active tenant prefilter state.
- **Expensive defaults or shared helper growth introduced?**: no; any hygiene-specific fixtures should stay local to the new tests and reuse existing findings workflow helpers
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: `standard-native-filament` for the canonical report and `global-context-shell` for the workspace overview signal plus tenantless route safety
- **Closing validation and reviewer handoff**: Reviewers should rely on the exact commands above and verify that healthy assigned work stays out of the hygiene report, ordinary intake backlog stays out, one finding with two reasons counts once, the active tenant prefilter can be cleared back to all visible tenants, and the overview signal uses the same unique issue truth as the report.
- **Budget / baseline / trend follow-up**: none
- **Review-stop questions**: Did the implementation introduce a persisted hygiene flag or table? Did stale detection rely on observation fields such as `last_seen_at` or generic `updated_at` instead of workflow activity? Did any route or count leak hidden-tenant findings? Did the report gain inline repair actions or other mutation drift?
- **Escalation path**: document-in-feature unless a new persistence model, automation engine, or broad availability framework is proposed, in which case split or follow up with a dedicated spec
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: This feature remains bounded to one report, one signal, and one narrow derived-query seam over current findings workflow truth
## Project Structure
### Documentation (this feature)
```text
specs/225-assignment-hygiene/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── assignment-hygiene.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ └── Pages/
│ │ └── Findings/
│ │ ├── FindingsHygieneReport.php
│ │ ├── FindingsIntakeQueue.php
│ │ └── MyFindingsInbox.php
│ ├── Models/
│ │ ├── AuditLog.php
│ │ ├── Finding.php
│ │ └── User.php
│ ├── Services/
│ │ └── Findings/
│ │ ├── FindingAssignmentHygieneService.php
│ │ └── FindingWorkflowService.php
│ ├── Support/
│ │ ├── Middleware/
│ │ │ └── EnsureFilamentTenantSelected.php
│ │ └── Workspaces/
│ │ └── WorkspaceOverviewBuilder.php
├── resources/
│ └── views/
│ └── filament/
│ └── pages/
│ └── findings/
│ └── findings-hygiene-report.blade.php
└── tests/
└── Feature/
└── Findings/
├── FindingsAssignmentHygieneClassificationTest.php
├── FindingsAssignmentHygieneOverviewSignalTest.php
└── FindingsAssignmentHygieneReportTest.php
```
**Structure Decision**: Standard Laravel monolith. The feature stays inside the existing findings page family, workspace overview builder, and findings workflow service seams. No new base directory, panel, or persisted model is required.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| none | — | — |
## Proportionality Review
- **Current operator problem**: Assigned findings can silently become unworkable or stale after assignment, and no existing cross-tenant surface exposes those failures in one repair context.
- **Existing structure is insufficient because**: `My Findings` only answers healthy assigned work for the current user, intake only answers unassigned backlog, and the overview today has no workflow-hygiene signal. Duplicating derived issue logic in both a new page and the overview would drift quickly.
- **Narrowest correct implementation**: Add one new report page and one summary signal backed by one narrow findings hygiene service that centralizes classification and count rules while keeping repair on the existing finding detail page.
- **Ownership cost created**: One small service, one page, one Blade view, one overview builder extension, one middleware allow-list update, and three focused feature suites.
- **Alternative intentionally rejected**: A generic workflow health engine or a persisted hygiene table. Both add more machinery than current release truth needs because only the findings workflow consumes this derived issue model today.
- **Release truth**: Current-release truth. The feature repairs an active workflow trust gap now rather than preparing a later autonomous work-routing platform.
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/225-assignment-hygiene/research.md`.
Key decisions:
- Add a dedicated canonical report at `/admin/findings/hygiene` instead of stretching `My Findings` or intake beyond their current decision role.
- Reuse the existing workspace overview signal pattern in `WorkspaceOverviewBuilder` instead of creating a new widget family.
- Add one narrow `FindingAssignmentHygieneService` because both the report and overview signal need the same classification and counting truth.
- Derive broken assignment from current tenant entitlement plus existing user soft-delete truth rather than introducing a new availability model.
- Derive stale work from finding workflow timestamps plus existing finding workflow audit events, not from observation fields such as `last_seen_at` or generic `updated_at`.
- Keep repair on the existing tenant finding detail route and current assignment actions; the hygiene report stays read-first.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/225-assignment-hygiene/`:
- `research.md`: report-shape, classification-source, and reuse decisions
- `data-model.md`: existing entities plus the derived hygiene issue and overview-signal models
- `contracts/assignment-hygiene.logical.openapi.yaml`: internal logical contract for the canonical report, overview signal, and finding drilldown
- `quickstart.md`: focused validation workflow for implementation and review
Design decisions:
- No schema migration is required; hygiene remains derived from current `Finding`, `AuditLog`, user, and membership truth.
- The canonical new seam is one narrow `FindingAssignmentHygieneService`, not a broad workflow-health framework.
- Stale detection uses workflow activity anchors, not observation freshness fields.
- The canonical repair surface remains the existing tenant finding detail route.
- Existing `My Findings` and intake inclusion rules remain unchanged.
## Phase 1 Agent Context Update
Run:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Constitution Check — Post-Design Re-evaluation
- PASS — the design stays inside current findings, audit, workspace overview, and Filament page seams with no new persistence, no Graph work, no new capability family, and no new assets.
- PASS — Livewire v4.0+ and Filament v5 constraints remain satisfied, panel provider registration stays in `apps/platform/bootstrap/providers.php`, no globally searchable resource behavior changes, and no destructive action path is added on the new report.
## Implementation Strategy
### Phase A — Add the canonical hygiene report shell and route treatment
**Goal**: Create the new admin-plane report without disturbing the current findings page family.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php` | Add the new Filament page with slug `findings/hygiene`, canonical row drilldown into `FindingResource`, fixed reason filters, active-tenant prefilter behavior, and read-first table semantics |
| A.2 | `apps/platform/resources/views/filament/pages/findings/findings-hygiene-report.blade.php` | Add the page shell using the same findings page family layout patterns as `MyFindingsInbox` and `FindingsIntakeQueue` |
| A.3 | `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php` | Extend the tenant-selection bypass and workspace-scoped navigation handling so `/admin/findings/hygiene` behaves like the existing tenantless canonical findings pages |
### Phase B — Add one narrow derived hygiene service over existing findings truth
**Goal**: Centralize hygiene classification and count truth for both the report and the overview signal.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php` | Add a focused service that scopes findings to visible tenants, derives hygiene reasons, returns the canonical report query, and computes unique summary counts |
| B.2 | `apps/platform/app/Models/Finding.php` | Reuse existing open-status and responsibility fields; add only narrow query helpers if they materially simplify the hygiene service without introducing a new semantic layer |
| B.3 | `apps/platform/app/Models/AuditLog.php` or existing audit query seam | Reuse existing finding workflow audit rows as one stale-activity input rather than persisting a new timestamp |
### Phase C — Implement broken-assignment and stale-work classification on real existing truth
**Goal**: Make the derived reasons honest without inventing new persistence or generic availability models.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php` | Classify `broken assignment` for non-terminal findings whose assignee no longer has current tenant entitlement or whose user record is soft-deleted |
| C.2 | `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php` | Classify `stale in progress` for non-terminal findings in `in_progress` whose last meaningful workflow activity is older than seven days |
| C.3 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` and existing audit action ids | Reuse `finding.assigned`, `finding.in_progress`, and `finding.reopened` audit history plus `in_progress_at` and `reopened_at` as activity anchors, explicitly avoiding `last_seen_at` or generic `updated_at` as the stale-work source |
### Phase D — Reuse the workspace overview summary pattern without adding a second work surface
**Goal**: Make hygiene issues discoverable from `/admin` while keeping the report canonical.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Add one findings hygiene summary signal with unique issue counts, calm-state copy, and a CTA into `/admin/findings/hygiene` |
| D.2 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Reuse the same visible-tenant scope rules and counting truth as the hygiene service so the overview never drifts from the canonical report |
### Phase E — Preserve report filtering, empty-state truth, and existing repair drilldown
**Goal**: Keep the new report decision-first and tenant-safe.
| Step | File | Change |
|------|------|--------|
| E.1 | `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php` | Implement fixed filters for `All issues`, `Broken assignment`, and `Stale in progress` without exposing a free-form workflow taxonomy |
| E.2 | `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php` and Blade view | Add the filtered-empty-state explanation and one `Clear tenant filter` CTA when tenant prefiltering hides otherwise visible hygiene issues |
| E.3 | `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php` | Keep row click as the sole inspect affordance and point it to the existing tenant finding detail repair flow |
### Phase F — Lock down classification, visibility, and overview consistency with focused regression coverage
**Goal**: Prove the report stays honest, tenant-safe, and aligned with the overview signal.
| Step | File | Change |
|------|------|--------|
| F.1 | `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php` | Cover report visibility, tenant-prefilter behavior, hidden-tenant suppression, and row drilldown behavior |
| F.2 | `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php` | Cover broken-assignment classification, stale-work classification, exclusion of healthy assigned and ordinary intake backlog, and one-row multi-reason behavior |
| F.3 | `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php` | Cover overview signal calm versus attention states, unique count alignment with the canonical report, and CTA routing |
| F.4 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` plus the focused Pest commands above | Run formatting and the narrowest proving suites before closing implementation |
## Implementation Close-out
- **Status**: Implemented and verified on 2026-04-22.
- **Implemented scope**:
- Added the canonical admin-plane report at `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php` with a matching Blade view, fixed reason views, row-click drilldown only, and tenant-prefilter recovery.
- Added `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php` as the single derived-query and count truth for report rows plus workspace overview signal counts.
- Extended `apps/platform/app/Services/Findings/FindingWorkflowService.php` to expose shared meaningful workflow activity anchors.
- Wired `/admin/findings/hygiene` into `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, and `apps/platform/resources/views/filament/pages/workspace-overview.blade.php`.
- Added focused Pest coverage in `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php`, `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php`, and `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php`.
- **Proof commands run**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php`
- **Guardrail outcome**:
- Livewire v4.0+ / Filament v5 compliance remains intact.
- Provider registration location remains unchanged in `bootstrap/providers.php`.
- No global-search resource behavior changed.
- No destructive action was added on the new report.
- No new asset family was introduced, so existing deployment behavior stays sufficient.

View File

@ -0,0 +1,82 @@
# Quickstart: Assignment Hygiene & Stale Work Detection
## Prerequisites
1. Start the local platform stack.
```bash
cd apps/platform && ./vendor/bin/sail up -d
```
2. Work with a workspace that has at least two visible tenants, several open findings, and at least two tenant users.
3. Ensure at least one scenario can represent each hygiene state:
- a finding assigned to a user who later loses tenant access or is soft-deleted
- a finding left `in_progress` beyond the stale window without later workflow movement
4. Remember that `/admin/findings/hygiene` is a workspace-context canonical page, so tenant selection should not be required to open it.
## Automated Validation
Run formatting and the narrowest proving suites for this feature:
```bash
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php
```
## Manual Validation Flow
### 1. Confirm the canonical report opens in workspace context
1. Open `/admin/findings/hygiene` without selecting an active tenant.
2. Confirm the page opens as a workspace-context findings surface.
3. Confirm row drilldown still lands on the existing tenant finding detail page.
### 2. Validate broken-assignment detection
1. Start with an open assigned finding in a visible tenant.
2. Remove the assignee's tenant membership or soft-delete the assigned user.
3. Reload the hygiene report.
4. Confirm the finding appears once with `Broken assignment` and still shows owner context.
### 3. Validate stale in-progress detection
1. Start with an open finding moved to `In progress` more than seven days ago.
2. Ensure no later assignment or reopen workflow activity exists for that finding.
3. Reload the hygiene report.
4. Confirm the finding appears with `Stale in progress`.
5. Move a comparable finding to `In progress` recently or reassign it recently and confirm it does not appear as stale.
6. Reopen a comparable finding after it would otherwise qualify as stale and confirm it no longer appears as stale until seven days pass from the reopen activity.
### 4. Validate multi-reason and unique-count behavior
1. Prepare one finding that is both assigned to an unworkable assignee and stale in progress.
2. Reload the report and workspace overview.
3. Confirm:
- the report shows one row with both reasons visible
- the workspace overview counts one unique hygiene issue, not two separate rows
### 5. Validate overview discoverability
1. Open `/admin` with visible hygiene issues present.
2. Confirm the findings hygiene signal appears with one CTA into the report.
3. Confirm the signal description is a short summary derived from broken-assignment and stale-in-progress counts rather than an undefined severity ordering.
4. Open the CTA and verify the canonical report reflects the same visible issue truth.
5. Repeat the check with no visible hygiene issues and confirm the same signal remains visible in a calm state with zero issues and the canonical CTA into the report.
### 6. Validate tenant-prefilter and hidden-tenant safety
1. Open the report with an active tenant selected.
2. Confirm the report is prefiltered to that tenant while keeping the fixed hygiene scope.
3. If the prefilter hides issues that exist in other visible tenants, confirm the empty state explains that boundary and offers exactly one `Clear tenant filter` CTA.
4. Confirm hidden-tenant findings never appear in rows, counts, filter values, or empty-state hints.
## Reviewer Notes
- The feature is Livewire v4.0+ compatible and stays on existing Filament v5 primitives.
- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`.
- No globally searchable resource behavior changes in this feature.
- No new destructive action is introduced on the hygiene report, so no new confirmation flow is required there.
- Asset strategy is unchanged: no new panel or shared assets, and the existing deploy `filament:assets` step remains sufficient.

View File

@ -0,0 +1,68 @@
# Research: Assignment Hygiene & Stale Work Detection
## Decision 1: Add a dedicated hygiene report instead of stretching `My Findings` or intake
**Decision**: Create a new canonical admin-plane page at `/admin/findings/hygiene` rather than adding one more fixed view to `My Findings` or the intake queue.
**Rationale**: `My Findings` already answers the personal execution question and intake already answers the shared unassigned-backlog question. Hygiene is a third operator question: which assigned or in-progress findings are no longer in a healthy workflow path across visible tenants. Mixing that into either existing surface would blur their current decision role.
**Alternatives considered**:
- Add a `Needs repair` filter to `My Findings`. Rejected because broken assignments often no longer belong to the current operator, so the personal queue would still miss the cross-tenant repair problem.
- Add another fixed mode to intake. Rejected because intake is explicitly about pre-assignment backlog, not unhealthy assigned work.
## Decision 2: Reuse the existing workspace overview signal pattern
**Decision**: Extend `WorkspaceOverviewBuilder` with one findings hygiene signal rather than creating a new dashboard widget family.
**Rationale**: The repository already exposes `my_findings_signal` from `WorkspaceOverviewBuilder`, and `/admin` is already the accepted discoverability surface for workspace-wide findings follow-up. A second signal in the same builder keeps counts, phrasing, and drill-in behavior in one place.
**Alternatives considered**:
- Build a dedicated Livewire widget. Rejected because the current overview already has a signal builder pattern and this feature does not need a new widget framework.
- Make the report discoverable only through navigation. Rejected because the spec explicitly wants a small overview signal that exposes hidden workflow decay before operators start browsing deeper pages.
## Decision 3: Use one narrow `FindingAssignmentHygieneService`
**Decision**: Add one focused service that owns hygiene classification, filtering, and unique-count aggregation for both the report and the overview signal.
**Rationale**: This feature has two real concrete consumers of the same derived truth: the canonical report and the overview signal. Duplicating issue classification in both places would drift quickly. A narrow service is the smallest justified shared seam and does not require a generic workflow-health framework.
**Alternatives considered**:
- Duplicate the queries in the report page and `WorkspaceOverviewBuilder`. Rejected because tenant-visible scope, multi-reason counting, and stale-work anchoring would diverge under maintenance.
- Introduce a broader workflow-health engine. Rejected because findings are the only real consumer today.
## Decision 4: Keep broken-assignment truth bounded to current entitlement and existing user lifecycle
**Decision**: In v1, classify `broken assignment` from two existing truths only: the assignee no longer has current tenant entitlement, or the assignee user record is soft-deleted. Do not introduce absence, capacity, or shift scheduling models.
**Rationale**: The repository already enforces tenant membership as the true work boundary, and `User` already uses `SoftDeletes`. Those are real current truths. Anything broader would require new product modeling and would violate the spec's narrow scope.
**Alternatives considered**:
- Add an explicit operator-availability state or calendar. Rejected because it introduces a new workflow domain and new persistence unrelated to the current slice.
- Treat every owner-only finding as broken. Rejected because owner-only accountability is already a valid state under Spec 219 and belongs outside this hygiene slice unless assignment is actually broken.
## Decision 5: Derive stale work from workflow activity, not observation freshness
**Decision**: Derive `stale in progress` from workflow activity anchors such as `in_progress_at`, `reopened_at`, and the latest existing finding workflow audit event (`finding.assigned`, `finding.in_progress`, `finding.reopened`) rather than from `last_seen_at` or generic `updated_at`.
**Rationale**: Observation pipelines update `last_seen_at` and may touch the record during recurring detection, which would falsely calm truly stale operator work. The stale question here is whether workflow moved, not whether the problem was re-observed by the system.
**Alternatives considered**:
- Use `last_seen_at`. Rejected because recurring detection would reset the stale window even when no operator work happened.
- Use `updated_at`. Rejected because model updates are broader than workflow activity and would make stale classification depend on incidental writes.
- Add a new `last_workflow_activity_at` column. Rejected because the spec explicitly prefers derivation over new persistence.
## Decision 6: Keep repair on the existing finding detail surface
**Decision**: The hygiene report remains read-first and uses row click to drill into the existing tenant finding detail route for repair.
**Rationale**: The current findings workflow already has audited assignment and lifecycle actions on the detail surface. Recreating those actions on the hygiene report would create a second mutation surface and complicate action hierarchy, RBAC, and audit semantics.
**Alternatives considered**:
- Add inline reassignment actions to the report. Rejected because the report would stop being a pure repair-identification surface and would duplicate the existing finding workflow UI.
- Add a bulk repair action. Rejected because the spec explicitly keeps automation and redistribution out of scope for v1.

View File

@ -0,0 +1,244 @@
# Feature Specification: Assignment Hygiene & Stale Work Detection
**Feature Branch**: `225-assignment-hygiene`
**Created**: 2026-04-22
**Status**: Draft
**Input**: User description: "Assignment Hygiene & Stale Work Detection"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Findings can enter a hidden decay path after assignment. Work disappears from the assignee's personal queue when access changes, remains pointed at assignees who no longer have tenant access or were soft-deleted, or stays `in_progress` long after meaningful work stopped.
- **Today's failure**: Operators can trust `My Findings`, intake, and notifications only until assignment truth drifts. Broken or stalled work then becomes harder to see than normal backlog, so accountability gaps survive longer than they should.
- **User-visible improvement**: One workspace-safe hygiene report and one overview signal expose broken assignments and stale in-progress work with clear reasons, owner context, and one drilldown into the existing finding detail for repair.
- **Smallest enterprise-capable version**: Add one canonical read-first hygiene report, one bounded derived hygiene reason vocabulary over existing finding and membership truth, and one workspace overview drill-in signal. Reuse existing finding detail actions for repair instead of inventing a new fixing workflow.
- **Explicit non-goals**: No automatic reassignment, no load balancing, no absence-management model, no new notification channel, no comments or ticketing, no bulk repair workflow, and no second findings state store.
- **Permanent complexity imported**: One canonical hygiene report page, one overview summary signal, one bounded derived hygiene-query contract, one small hygiene reason vocabulary, and focused regression coverage for cross-tenant visibility, classification, and drill-in safety.
- **Why now**: Spec 219 clarified owner versus assignee, Spec 221 created `My Findings`, Spec 222 created shared intake, and Spec 224 added notifications. The next missing slice is to keep those workflow surfaces trustworthy after work is assigned and begins.
- **Why not local**: A tenant-local filter or one extra badge would still leave hidden backlog distributed across many tenants and would not answer the cross-tenant question "which assigned work is no longer healthy right now?"
- **Approval class**: Core Enterprise
- **Red flags triggered**: One bounded derived reason vocabulary and one new canonical cross-tenant report surface. Scope remains acceptable because the feature derives from existing finding, membership, and lifecycle truth instead of adding persistence or automation.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- `/admin/findings/hygiene` as the new canonical findings hygiene report
- `/admin` as the workspace overview where the hygiene signal links into the report
- `/admin/t/{tenant}/findings/{finding}` as the existing repair drilldown destination
- **Data Ownership**: Tenant-owned findings remain the only source of truth. The hygiene report is a derived cross-tenant view over existing finding lifecycle, responsibility, due-state, workflow-activity, tenant-entitlement, and assignee user soft-delete truth. No new persisted hygiene table or flag is introduced.
- **RBAC**: Workspace membership is required for the canonical hygiene report in the admin plane. The report route itself remains available to workspace members and renders only rows, counts, filter values, and drill-in CTAs for tenants they may already inspect; a workspace member with zero visible hygiene records receives an empty report rather than a route-level `403`. Repair actions continue to reuse the existing tenant findings assign workflow and authorization on the finding detail surface. Non-members and out-of-scope users remain deny-as-not-found. Members missing the required capability remain forbidden on protected drilldown destinations.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: The hygiene report always keeps its fixed hygiene scope. When an active tenant context exists, the page additionally applies that tenant as a default prefilter while allowing the operator to clear only the tenant prefilter, not the hygiene scope itself.
- **Explicit entitlement checks preventing cross-tenant leakage**: Rows, counts, filter values, summary drill-ins, and empty-state hints are derived only from findings in tenants the current user may already inspect. Hidden tenants contribute nothing to the hygiene report or the overview signal.
## 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 |
|---|---|---|---|---|---|---|
| Findings hygiene report | yes | Native Filament page + existing table, filter, text-column, and empty-state primitives | Same findings workflow family as `My Findings`, intake, and finding detail | table, fixed hygiene reason views, row drill-in, tenant prefilter, summary counts | no | Read-first report only; no new mutation family |
| Workspace overview hygiene signal | yes | Native Filament widget or summary primitives | Same workspace-overview attention family as other `/admin` signals | embedded summary, drill-in CTA, visible-scope counts | no | Small entry signal only; not a second report |
## 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 |
|---|---|---|---|---|---|---|---|
| Findings hygiene report | Primary Decision Surface | A workspace operator reviews decayed assignments or stalled in-progress work and decides what needs reassignment or manual follow-up first | Tenant, finding summary, hygiene reason, owner, assignee, due or overdue state, and last meaningful workflow activity | Full finding detail, evidence, audit trail, exception context, and existing assignment actions after opening the record | Primary because this is the first dedicated cross-tenant repair surface for work that has fallen between intake, personal execution, and normal notifications | Follows the operator's repair workflow instead of forcing tenant-by-tenant hunting for broken work | Removes repeated search across `My Findings`, intake, tenant lists, and overdue-only scans |
| Workspace overview hygiene signal | Secondary Context Surface | An operator lands on `/admin` and needs to know whether workflow hygiene already requires attention before choosing another work surface | Current unique hygiene issue count, a short description derived from broken-assignment and stale-in-progress counts, and one CTA into the report | Full report and finding detail after drill-in | Secondary because it points to the real repair surface rather than becoming its own queue | Keeps workspace home aligned with findings workflow integrity | Avoids opening multiple findings pages just to discover whether hidden backlog exists |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Findings hygiene report | List / Table / Bulk | Read-only Registry / Report Surface | Open the affected finding and repair responsibility from the existing detail workflow | Finding | required | Utility filters and tenant-prefilter clear affordances stay outside row action noise | none on the report; dangerous actions remain on the existing finding detail | /admin/findings/hygiene | /admin/t/{tenant}/findings/{finding} | Active workspace, optional active-tenant prefilter, tenant column, hygiene reason filters | Findings / Finding | Which findings have broken assignment truth or stalled in-progress work, and why | none |
| Workspace overview hygiene signal | Utility / System | Read-only Registry / Report Surface | Open the hygiene report | Explicit summary CTA | forbidden | Summary strip only | none | /admin | /admin/findings/hygiene | Active workspace and visible-scope counts | Findings hygiene | Whether any visible findings workflow hygiene issue already needs attention | Embedded summary drill-in that stays read-only and points into the canonical report rather than becoming its own queue surface |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Findings hygiene report | Workspace operator, tenant manager, or findings workflow steward | Decide which broken or stalled finding needs repair first | Read-first workflow hygiene report | Which visible findings are no longer in a healthy execution path, and what kind of repair do they need? | Tenant, finding summary, owner, assignee when present, hygiene reason, due or overdue state, and last meaningful workflow activity | Raw evidence, run context, audit trail, and full lifecycle history after opening the finding | lifecycle, responsibility validity, work freshness, due urgency | none on the report itself; existing tenant finding surfaces keep their current mutation scopes | Open finding, apply filters, clear tenant prefilter | none |
| Workspace overview hygiene signal | Workspace member with findings visibility | Decide whether findings workflow hygiene already needs review now | Summary drill-in | Do I need to repair hidden or stalled findings work before I continue elsewhere? | Unique hygiene issue count, a short description derived from broken-assignment and stale-in-progress counts, and one CTA | none | issue presence, reason composition, visible scope | none | Open findings hygiene | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes — one bounded derived hygiene-query contract over existing finding lifecycle, responsibility, tenant-entitlement, and assignee user soft-delete truth
- **New enum/state/reason family?**: yes — one bounded derived hygiene reason vocabulary for `broken assignment` and `stale in progress`
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Assigned findings can disappear from personal execution surfaces or remain stuck in `in_progress` without one truthful repair view, which weakens trust in the broader findings workflow.
- **Existing structure is insufficient because**: `My Findings` answers "what is assigned to me," intake answers "what is still unassigned," and notifications answer "what just happened." None of those surfaces answers "what assigned or in-progress work is now unhealthy across my visible tenants?"
- **Narrowest correct implementation**: Add one derived cross-tenant hygiene report and one overview signal, reuse existing detail actions for repair, and keep hygiene truth derived rather than persisted.
- **Ownership cost**: Ongoing maintenance for one small reason vocabulary, one canonical report query, overview summary assertions, and regression coverage for tenant-safe classification.
- **Alternative intentionally rejected**: Automatic reassignment, reminder loops, or a new workflow engine were rejected because they act on the problem before operators have one trustworthy view of the problem.
- **Release truth**: Current-release truth. This feature makes the current findings workflow honest and repairable now rather than preparing a future autonomous routing system.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## 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 feature is proven by visible operator behavior on one canonical cross-tenant report and one workspace summary signal. Focused feature coverage is sufficient to prove classification truth, visibility boundaries, tenant-prefilter behavior, and drill-in safety without introducing browser or heavy-governance cost.
- **New or expanded test families**: Add focused hygiene-report visibility tests, hygiene-classification tests for broken assignment and stale work, overview-signal count tests, and tenant-prefilter empty-state tests.
- **Fixture / helper cost impact**: Moderate. Tests need findings with explicit owner and assignee combinations, stale and fresh workflow timestamps, entitlement-lost and soft-deleted assignee cases, and mixed visible versus hidden tenant memberships.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: global-context-shell
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, plus explicit proof that healthy unassigned intake backlog does not leak into the hygiene report, hidden-tenant findings never appear in rows or counts, and one finding with multiple hygiene reasons is counted once.
- **Reviewer handoff**: Reviewers must confirm that broken-assignment classification is derived from current tenant entitlement and assignee soft-delete truth rather than stale cached assumptions, that stale work does not collapse into overdue-only logic, that report rows remain read-first, and that the overview signal matches the canonical report under the same visible scope.
- **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/Findings/FindingsAssignmentHygieneReportTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See broken assignments in one repair view (Priority: P1)
As a workspace operator, I want one cross-tenant report of findings whose assignment is no longer actionable, so I can repair hidden backlog before it silently disappears from personal work surfaces.
**Why this priority**: This is the smallest slice that closes the trust gap left after assignment. If broken assignments remain invisible, the newer findings queues and notifications are not reliable enough to operate on.
**Independent Test**: Can be fully tested by seeding assigned findings where the assignee no longer has current tenant access or the assignee user record is soft-deleted, then verifying that the report shows those rows with owner context and safe drilldown.
**Acceptance Scenarios**:
1. **Given** an open finding is still assigned but the assignee no longer has current entitlement to inspect that tenant, **When** an entitled workspace operator opens the hygiene report, **Then** the finding appears with the hygiene reason `broken assignment`.
2. **Given** an open finding is assigned to a user whose account was soft-deleted, **When** an entitled workspace operator opens the hygiene report, **Then** the finding appears with the hygiene reason `broken assignment`.
3. **Given** a finding belongs to a hidden tenant, **When** the current user opens the hygiene report, **Then** that finding contributes nothing to rows, counts, filter values, or empty-state hints.
---
### User Story 2 - Detect stalled in-progress work before it becomes hidden backlog (Priority: P1)
As a findings workflow steward, I want stale `in_progress` work surfaced separately from normal overdue work, so I can intervene on work that stopped moving without assuming every overdue finding is stale.
**Why this priority**: Stale work detection is the second half of hygiene. Without it, the product can only detect broken assignees, not stalled execution.
**Independent Test**: Can be fully tested by seeding fresh and stale `in_progress` findings across visible tenants and verifying that only stale findings appear with the correct reason while fresh or merely overdue findings stay out of the report.
**Acceptance Scenarios**:
1. **Given** an open finding has remained `in_progress` with no meaningful workflow activity for seven consecutive days, **When** an entitled workspace operator opens the hygiene report, **Then** the finding appears with the hygiene reason `stale in progress`.
2. **Given** an open finding is overdue but has recent meaningful workflow activity, **When** an entitled workspace operator opens the hygiene report, **Then** the finding does not appear solely because it is overdue.
3. **Given** one finding has both a broken assignment and stale in-progress work, **When** the hygiene report is rendered, **Then** the finding appears as a single row with both reasons visible and contributes one unique issue to summary counts.
---
### User Story 3 - Discover hygiene issues from the workspace overview (Priority: P2)
As a workspace member with findings visibility, I want the workspace overview to show whether findings workflow hygiene already needs attention, so I can enter the repair report without scanning multiple pages.
**Why this priority**: The canonical report is valuable only if operators can discover it in normal workspace navigation. A small summary signal is the narrowest discoverability improvement.
**Independent Test**: Can be fully tested by seeding both visible hygiene issues and a zero-issue visible scope, loading `/admin`, verifying the attention-state and calm-state signal variants, and following the CTA into the canonical report.
**Acceptance Scenarios**:
1. **Given** the current user has visible hygiene issues across one or more tenants, **When** the user opens `/admin`, **Then** the workspace overview shows one findings hygiene signal with the correct unique issue count.
2. **Given** the current user has no visible hygiene issues, **When** the user opens `/admin`, **Then** the workspace overview shows the same findings hygiene signal in a calm state with zero visible issues, calm descriptive copy, and the canonical CTA into the report.
3. **Given** the current user opens that signal, **When** the CTA is used, **Then** the user lands on `/admin/findings/hygiene` with the same visible-scope truth.
### Edge Cases
- A single finding may have multiple hygiene reasons at once; the report must show one row per finding and surface all applicable reasons without duplicating counts.
- An unassigned finding in normal intake scope is not a hygiene issue by itself; it remains handled by the intake workflow unless it independently qualifies as stale `in_progress` work.
- A resolved, closed, or otherwise terminal finding must leave the hygiene report immediately even if it was previously stale or broken.
- An active tenant prefilter may produce an empty hygiene report while other visible tenants still contain issues; the empty state must explain the filter boundary instead of claiming no issues exist anywhere.
- A finding may remain accountable to an owner even when its assignee is broken; owner context must stay visible so repair can start from the correct person.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds no Microsoft Graph calls, no new write path, and no new `OperationRun`. It introduces one canonical admin-plane report and one overview signal derived from existing finding, lifecycle, membership, and assignee user soft-delete truth. Repair continues through existing tenant findings actions and their current audit coverage.
**Constitution alignment (TEST-GOV-001):** The proof burden is focused feature coverage for hygiene classification, visibility boundaries, tenant-prefilter behavior, workspace overview signal consistency, and drill-in safety. No browser or heavy-governance lane is required.
**Constitution alignment (RBAC-UX):** The feature operates in the admin `/admin` plane for the canonical report and overview signal, with tenant entitlement enforced per finding before disclosure and before drilldown to `/admin/t/{tenant}/findings/{finding}`. Non-members or out-of-scope users continue to receive `404`. In-scope users lacking the existing findings view capability continue to receive `403` on protected drilldown destinations, while the workspace report itself renders only the rows they may inspect. Repair actions continue to reuse the existing findings assign authorization on the tenant detail surface. No raw capability strings, role checks, or second permission system may be introduced.
**Constitution alignment (UI-FIL-001):** The report and overview signal must use native Filament page, table, filter, stat, and empty-state primitives or existing shared UI helpers. Hygiene reasons remain plain-text reason labels in the report and do not introduce a new BADGE-001 status mapping. No local status language, ad hoc color system, or custom badge markup may be introduced for hygiene reasons.
**Constitution alignment (UI-NAMING-001):** The canonical operator-facing vocabulary is `Findings hygiene`, `Broken assignment`, `Stale in progress`, `Open finding`, and `Open findings hygiene`. Terms such as `repair record`, `workflow defect`, or `queue health object` must not replace the finding domain language in primary labels.
**Constitution alignment (DECIDE-001):** The hygiene report is a primary repair surface because it answers one operator question in one place: which findings workflow items are no longer healthy? The overview signal is a secondary drill-in only and must not become a second report.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / HDR-001):** The hygiene report has exactly one primary inspect model: the finding. Row click is required. There is no redundant `View` action. Utility controls such as tenant filter and hygiene reason filters stay outside row action noise. Dangerous lifecycle actions remain on the existing finding detail instead of being promoted into the report. The workspace overview signal remains a summary drill-in surface with one explicit CTA.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from current queues is insufficient because healthy assigned work, unassigned intake work, and unhealthy hidden backlog are different operator questions. This feature solves that by adding one derived hygiene interpretation over existing domain truth rather than a persisted workflow mirror. Tests must prove visible workflow consequences, not thin presentational indirection.
### Functional Requirements
- **FR-001**: The system MUST provide a canonical findings hygiene report at `/admin/findings/hygiene` for the current user's visible tenant scope.
- **FR-002**: The hygiene report MUST include only non-terminal findings that satisfy at least one hygiene reason. It MUST NOT duplicate healthy assigned work or ordinary intake backlog.
- **FR-003**: The feature MUST derive hygiene truth from existing finding lifecycle, responsibility, tenant-entitlement, and assignee user soft-delete truth without introducing a persisted hygiene flag, table, or workflow state.
- **FR-004**: The system MUST classify `broken assignment` when a non-terminal finding still has an assignee but that assignee cannot currently act on the finding because they no longer have current tenant entitlement or the assignee user record is soft-deleted.
- **FR-005**: The system MUST classify `stale in progress` when a non-terminal finding remains `in_progress` with no meaningful workflow activity for seven consecutive days.
- **FR-006**: Meaningful workflow activity for stale detection MUST reset when responsibility or lifecycle state changes in a way that materially advances the finding, including assignment changes, start-progress transitions, or reopen-driven lifecycle resets.
- **FR-007**: Overdue status and stale-work status MUST remain separate dimensions. A finding MUST NOT enter the hygiene report solely because it is due soon or overdue.
- **FR-008**: If one finding satisfies multiple hygiene reasons, the report MUST render that finding once and show all applicable reasons without duplicating unique issue counts.
- **FR-009**: The report MUST show by default the tenant, finding summary, accountable owner, current assignee when present, hygiene reason or reasons, due or overdue state, and last meaningful workflow activity timestamp.
- **FR-010**: The hygiene report MUST remain read-first. Its only primary inspect model is the finding, reached through row click into the existing tenant finding detail route.
- **FR-011**: The hygiene report MUST NOT introduce inline reassignment, bulk reassignment, auto-fix actions, or a second repair workflow. Existing tenant finding actions remain the place where repair happens.
- **FR-012**: The workspace overview at `/admin` MUST expose one findings hygiene summary signal that shows the current unique visible issue count, a short description derived from broken-assignment and stale-in-progress counts, and drills into the canonical hygiene report. When the current user has no visible hygiene issues, the same signal MUST render calm-state copy with zero visible issues and the same canonical CTA.
- **FR-013**: When tenant context is active, the hygiene report MUST prefilter to that tenant while preserving the fixed hygiene scope. Operators MUST be able to clear only the tenant prefilter.
- **FR-014**: Hidden tenants MUST contribute nothing to hygiene rows, counts, filter values, or empty-state hints.
- **FR-015**: Existing `My Findings` and intake contracts MUST remain unchanged. Healthy assigned work stays discoverable through `My Findings`, normal unassigned backlog stays discoverable through intake, and the hygiene report exists only for findings workflow items that would otherwise become hidden or misleading.
- **FR-016**: The report MUST provide fixed reason filters for `All issues`, `Broken assignment`, and `Stale in progress`.
- **FR-017**: The filtered-empty state MUST explain when the current tenant prefilter is narrowing visible issues and provide exactly one CTA that clears the tenant prefilter back to all visible tenants.
- **FR-018**: The feature MUST NOT add automatic reassignment, scheduled escalation, or a second notification channel. Any later automation MUST be a separate spec built on top of this repair surface.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Findings hygiene report | `/admin/findings/hygiene` | none | Full-row open into finding detail | none; row click is the only primary inspect affordance | none | `Clear tenant filter` only when a tenant prefilter causes the empty state; otherwise none | n/a | n/a | No new mutation audit because the surface stays read-only | Action Surface Contract satisfied. No redundant `View` action, no empty action groups, and no dangerous action promoted into the report. |
| Workspace overview hygiene signal | `/admin` | none | Explicit summary CTA into the report | none | none | none | n/a | n/a | No new mutation audit because the surface stays read-only | Embedded summary drill-in only. It points to the canonical report and does not introduce a second work surface. |
### Key Entities *(include if feature involves data)*
- **Finding hygiene issue**: A derived workflow integrity issue attached to a visible non-terminal finding when assignment is broken or in-progress work has stalled.
- **Broken assignment**: A derived reason indicating that the assigned operator can no longer act on the finding according to current tenant-entitlement or assignee user soft-delete truth.
- **Stale in-progress work**: A derived reason indicating that a finding has remained `in_progress` without meaningful workflow activity for the defined stale window.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In acceptance review, an operator can identify why a finding appears on the hygiene report and open the correct finding in one interaction.
- **SC-002**: 100% of covered automated classification tests include broken-assignment and stale findings while excluding healthy assigned work, ordinary intake backlog, and merely overdue-but-active findings.
- **SC-003**: 100% of covered visibility tests show that hidden-tenant findings contribute nothing to hygiene rows, counts, filter values, or overview signals.
- **SC-004**: 100% of covered summary tests show that the workspace overview hygiene count matches the canonical report's unique visible issue count under the same scope.
## Assumptions
- Existing finding lifecycle and audit-related timestamps expose enough signal to derive last meaningful workflow activity without introducing new persistence.
- The v1 stale threshold is fixed at seven days of inactivity for `in_progress` findings.
- Broken-assignment truth in v1 uses only current tenant entitlement and assignee user soft-delete truth already modeled by the product; no broader availability, absence, or capacity model is introduced.
- Repair continues through the existing finding detail and responsibility actions rather than directly from the hygiene report.
## Non-Goals
- Automatically reassign findings, clear assignees, or mutate workflow state from the hygiene report.
- Add reminder loops or notification behavior beyond the separate notifications and escalation spec.
- Introduce team-capacity balancing, absence management, or load-distribution logic.
- Fold resolved-versus-verified outcome semantics into this spec.
- Create a second findings history, audit, or workflow-state store.
## Dependencies
- Spec 111 remains the source of truth for finding lifecycle, due state, and open versus terminal semantics.
- Spec 219 remains the source of truth for owner-versus-assignee meaning.
- Spec 221 remains the source of truth for healthy personal assigned-work discovery in `My Findings`.
- Spec 222 remains the source of truth for normal unassigned findings intake.
- Spec 224 remains the adjacent notification slice that points operators toward work changes but does not replace a repair view for decayed assignments or stalled work.

View File

@ -0,0 +1,216 @@
# Tasks: Assignment Hygiene & Stale Work Detection
**Input**: Design documents from `/specs/225-assignment-hygiene/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/assignment-hygiene.logical.openapi.yaml`, `quickstart.md`
**Tests**: Required. This feature changes runtime behavior in a new canonical Filament findings page, a shared derived-query service, workspace overview signal rendering, and tenant-safe disclosure behavior, so Pest coverage must be added in `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php`, `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php`, and `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php`.
**Operations**: No new `OperationRun` is introduced. The feature remains read-only and reuses the existing tenant finding detail repair path plus current audit truth.
**RBAC**: The canonical hygiene report and overview signal stay on the admin `/admin` plane, while repair drilldown stays on `/admin/t/{tenant}/findings/{finding}`. The implementation must preserve non-member and out-of-scope workspace `404`, tenant-safe suppression of hidden-tenant rows and counts inside an otherwise available report, in-scope missing-capability `403` on protected drilldown destinations, and the existing findings assign authorization on the tenant detail surface.
**UI / Surface Guardrails**: `Findings hygiene report` is a `standard-native-filament` surface and the workspace overview signal is a `global-context-shell` seam. Both must keep one calm drill-in path only, with no inline mutation drift.
**Filament UI Action Surfaces**: `FindingsHygieneReport` is a new read-only Filament page with one inspect model only: the finding. There must be no redundant `View` action, no inline repair action, no bulk action group, and no destructive action on the report surface.
**Badges**: Existing finding lifecycle and severity semantics remain authoritative. Hygiene reasons remain plain-text reason labels in the report, so BADGE-001 is not extended and no page-local status mapping is introduced.
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1 -> US2 -> US3`, because broken-assignment visibility is the smallest MVP repair slice, stale-work detection builds on the same canonical report second, and overview discoverability is safest after the report truth is established.
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
- [x] The declared surface test profile or `standard-native-filament` relief is explicit.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Phase 1: Setup (Report And Test Scaffolding)
**Purpose**: Prepare the shared page, service, and focused regression files used across all stories.
- [x] T001 [P] Create the findings hygiene page scaffold in `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php`
- [x] T002 [P] Create the findings hygiene Blade shell in `apps/platform/resources/views/filament/pages/findings/findings-hygiene-report.blade.php`
- [x] T003 [P] Create the shared hygiene service scaffold in `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php`
- [x] T004 [P] Create focused Pest scaffolding in `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php`, `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php`, and `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php`
**Checkpoint**: The shared page, Blade view, service, and focused test files exist and are ready for implementation work.
---
## Phase 2: Foundational (Blocking Report Route And Shared Classification Seam)
**Purpose**: Establish the canonical route treatment, shared visible-tenant scope, and baseline report contract every story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [x] T005 Add `/admin/findings/hygiene` to workspace-scoped tenant-selection bypass and navigation handling in `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`
- [x] T006 Implement visible-tenant scoping, unique issue counting, and fixed reason filter baseline in `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php`
- [x] T007 Implement the canonical report table with default-visible tenant, finding summary, owner, assignee, due-state, hygiene reasons, last meaningful workflow activity columns, row drilldown into `FindingResource`, and read-first inspect behavior in `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php`
- [x] T008 Add foundational tenant-safe report access, empty-report behavior for workspace members with zero visible hygiene scope, default-visible finding summary and last-activity coverage, and drilldown coverage in `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php`
**Checkpoint**: The canonical report route, shared hygiene query seam, and base tenant-safe read path are available for all stories.
---
## Phase 3: User Story 1 - See broken assignments in one repair view (Priority: P1) 🎯 MVP
**Goal**: Show cross-tenant assigned findings that are no longer actionable because the current assignee cannot work them anymore.
**Independent Test**: Seed assigned findings where the assignee lost tenant entitlement or the user was soft-deleted, open `/admin/findings/hygiene`, and verify one visible row per broken finding with owner context and safe drilldown.
### Tests for User Story 1
- [x] T009 [P] [US1] Add broken-assignment report visibility and hidden-tenant rows, counts, filter values, and empty-state suppression coverage in `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php`
- [x] T010 [P] [US1] Add broken-assignment classification coverage for entitlement loss and soft-deleted users in `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php`
### Implementation for User Story 1
- [x] T011 [US1] Implement `broken assignment` classification from current tenant entitlement and soft-deleted assignee truth in `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php`
- [x] T012 [US1] Render finding summary, owner, assignee, due-state, last meaningful workflow activity, and plain-text `Broken assignment` reason visibility on the canonical report in `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php` and `apps/platform/resources/views/filament/pages/findings/findings-hygiene-report.blade.php`
- [x] T013 [US1] Keep the report drill-in aligned to the existing tenant findings repair path in `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php`
**Checkpoint**: User Story 1 is independently functional and operators can see broken assignments in one cross-tenant repair view.
---
## Phase 4: User Story 2 - Detect stalled in-progress work before it becomes hidden backlog (Priority: P1)
**Goal**: Surface genuinely stalled `in_progress` work separately from healthy or merely overdue findings.
**Independent Test**: Seed fresh and stale `in_progress` findings, open `/admin/findings/hygiene`, and verify that only stale work appears, overdue-but-active findings stay out, and one finding with two reasons remains one row.
### Tests for User Story 2
- [x] T014 [P] [US2] Add stale-work versus overdue-but-active and reopen-driven stale-reset classification coverage in `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php`
- [x] T015 [P] [US2] Add regression coverage that healthy assigned work and ordinary intake backlog remain excluded from the hygiene report in `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php`
- [x] T016 [P] [US2] Add fixed reason filter coverage for `All issues`, `Broken assignment`, and `Stale in progress` in `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php`
- [x] T017 [P] [US2] Add multi-reason single-row, unique-count, and filtered-empty-state `Clear tenant filter` CTA coverage in `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php` and `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php`
### Implementation for User Story 2
- [x] T018 [US2] Derive last meaningful workflow activity from `in_progress_at`, `reopened_at`, and existing finding workflow audit events in `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php` and `apps/platform/app/Services/Findings/FindingWorkflowService.php`
- [x] T019 [US2] Implement `stale in progress` classification and multi-reason aggregation in `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php`
- [x] T020 [US2] Render finding summary, last meaningful workflow activity, and plain-text `Stale in progress` combined-reason visibility without introducing a new workflow taxonomy in `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php` and `apps/platform/resources/views/filament/pages/findings/findings-hygiene-report.blade.php`
- [x] T021 [US2] Add the tenant-prefilter empty-state explanation and `Clear tenant filter` CTA in `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php` and `apps/platform/resources/views/filament/pages/findings/findings-hygiene-report.blade.php`
**Checkpoint**: User Story 2 is independently functional and stale in-progress work is distinguished from healthy or merely overdue findings.
---
## Phase 5: User Story 3 - Discover hygiene issues from the workspace overview (Priority: P2)
**Goal**: Expose the canonical findings hygiene truth from `/admin` so operators can reach the repair surface without scanning multiple pages first.
**Independent Test**: Seed visible hygiene issues and a zero-issue visible scope, open `/admin`, verify the attention-state summary, calm-state behavior, and CTA, and follow the signal into `/admin/findings/hygiene` with matching visible-scope truth.
### Tests for User Story 3
- [x] T022 [P] [US3] Add workspace overview signal count, descriptive summary from broken/stale counts, calm-state, visible-scope, and CTA routing coverage in `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php`
### Implementation for User Story 3
- [x] T023 [US3] Add the findings hygiene overview signal with unique issue counts, a short description derived from broken/stale counts, and CTA into the canonical report in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [x] T024 [US3] Reuse the canonical hygiene service count truth for the overview signal in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` and `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php`
**Checkpoint**: User Story 3 is independently functional and the workspace overview reliably points operators into the canonical hygiene report.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finish guardrail alignment, formatting, and focused verification across the full feature.
- [x] T025 Review operator-facing vocabulary, UI Action Matrix conformance, and guardrail alignment in `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php`, `apps/platform/resources/views/filament/pages/findings/findings-hygiene-report.blade.php`, and `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- [x] T026 Run formatting for `apps/platform/app/Filament/Pages/Findings/FindingsHygieneReport.php`, `apps/platform/resources/views/filament/pages/findings/findings-hygiene-report.blade.php`, `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php`, `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php`, `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php`, and `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php` with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [x] T027 Run the focused verification workflow from `specs/225-assignment-hygiene/quickstart.md` against `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php`, `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php`, and `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php`
- [x] T028 Record the active feature PR close-out entry and proof commands in `specs/225-assignment-hygiene/plan.md` after implementation validation completes
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and prepares the shared page, view, service, and focused Pest files.
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until the canonical report route, shared hygiene query seam, and base tenant-safe report contract exist.
- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the recommended MVP cut.
- **User Story 2 (Phase 4)**: Depends on Foundational completion and extends the same canonical report with stale-work truth.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and reuses the same service truth to expose overview discoverability.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **US1**: No dependencies beyond Foundational.
- **US2**: No hard dependency on US1 after Foundational, but it reuses the same report and service seams and should preserve the broken-assignment visibility already established there.
- **US3**: No hard dependency on US1 or US2 after Foundational, but it must stay aligned with the canonical report truth they establish.
### Within Each User Story
- Write the story tests first and confirm they fail before implementation is considered complete.
- Keep `FindingAssignmentHygieneService.php` authoritative for classification and counting before duplicating logic in the page or overview builder.
- Finish story-level verification before moving to the next priority slice.
### Parallel Opportunities
- `T001`, `T002`, `T003`, and `T004` can run in parallel during Setup.
- `T009` and `T010` can run in parallel for User Story 1.
- `T014`, `T015`, `T016`, and `T017` can run in parallel for User Story 2.
- `T022` and `T023` can run in parallel for User Story 3 once the foundational service seam is stable.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel
T009 apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php
T010 apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel
T014 apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php
T015 apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php
T016 apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php
T017 apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneReportTest.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 signal work in parallel
T022 apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php
T023 apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate the feature against the focused US1 tests before widening the slice.
### Incremental Delivery
1. Ship US1 to close the hidden broken-assignment visibility gap.
2. Add US2 to distinguish stale execution from ordinary overdue follow-up.
3. Add US3 to make the canonical report discoverable from `/admin`.
4. Finish with guardrail review, formatting, and the focused verification pack.
### Parallel Team Strategy
1. One contributor can scaffold the page and view while another prepares the focused Pest suites.
2. After Foundational work lands, one contributor can implement broken-assignment classification while another prepares stale-work tests.
3. Overview-signal work can proceed in parallel with final report polish once the shared hygiene service and canonical report truth are stable.
---
## Notes
- `[P]` tasks target different files and can be worked independently once upstream blockers are cleared.
- `[US1]`, `[US2]`, and `[US3]` map directly to the feature specification user stories.
- The suggested MVP scope is Phase 1 through Phase 3 only.
- All implementation tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths.