Compare commits
2 Commits
dev
...
222-findin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1af4af25f | ||
|
|
a2e855bd81 |
18
.github/agents/copilot-instructions.md
vendored
18
.github/agents/copilot-instructions.md
vendored
@ -230,14 +230,6 @@ ## Active Technologies
|
|||||||
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned (221-findings-operator-inbox)
|
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned (221-findings-operator-inbox)
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement` (222-findings-intake-team-queue)
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement` (222-findings-intake-team-queue)
|
||||||
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, `audit_logs`, and workspace session context; no schema changes planned (222-findings-intake-team-queue)
|
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, `audit_logs`, and workspace session context; no schema changes planned (222-findings-intake-team-queue)
|
||||||
- TypeScript 5.9, Astro 6, Node.js 20+ + Astro, astro-icon, Tailwind CSS v4, Playwright 1.59 (223-astrodeck-website-rebuild)
|
|
||||||
- File-based route files, Astro content collections under `src/content`, public assets, and planning documents under `specs/223-astrodeck-website-rebuild`; no database (223-astrodeck-website-rebuild)
|
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource` (224-findings-notifications-escalation)
|
|
||||||
- PostgreSQL via existing `findings`, `alert_rules`, `alert_deliveries`, `notifications`, `tenant_memberships`, and `audit_logs`; no schema changes planned (224-findings-notifications-escalation)
|
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives (225-assignment-hygiene)
|
|
||||||
- PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned (225-assignment-hygiene)
|
|
||||||
- Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) (226-astrodeck-inventory-planning)
|
|
||||||
- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
|
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -272,10 +264,14 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## 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`)
|
|
||||||
- 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`
|
- 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`
|
||||||
|
- 221-findings-operator-inbox: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext`
|
||||||
|
- 220-governance-run-summaries: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders
|
||||||
|
- 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
|
||||||
|
- 218-homepage-hero: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, existing Astro content modules and section primitives, Playwright browser smoke tests
|
||||||
|
- 217-homepage-structure: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests
|
||||||
|
- 216-provider-dispatch-gate: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
|
||||||
|
- 215-website-core-pages: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
295
.github/skills/browsertest/SKILL.md
vendored
295
.github/skills/browsertest/SKILL.md
vendored
@ -1,295 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@ -1,30 +1,17 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.7.0 -> 2.8.0
|
- Version change: 2.6.0 -> 2.7.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:
|
||||||
@ -83,14 +70,6 @@ ### 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.
|
||||||
|
|||||||
@ -26,24 +26,18 @@ ## 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
|
||||||
|
|
||||||
- [ ] CHK010 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
- [ ] CHK007 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
||||||
- [ ] CHK011 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
|
- [ ] 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.
|
||||||
- [ ] CHK012 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
- [ ] CHK009 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||||
- [ ] CHK013 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
- [ ] 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.
|
||||||
|
|
||||||
## Review Outcome
|
## Review Outcome
|
||||||
|
|
||||||
- [ ] CHK014 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
- [ ] CHK011 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||||
- [ ] CHK015 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
- [ ] CHK012 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
||||||
- [ ] CHK016 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
|
- [ ] 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.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
@ -54,7 +48,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. For already-implemented historical drift, prefer a follow-up spec or active feature note instead of retroactively rewriting closed specs.
|
- `follow-up-spec`: the issue is recurring or structural and needs dedicated governance follow-up.
|
||||||
- `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
|
||||||
|
|||||||
@ -43,17 +43,6 @@ ## 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.*
|
||||||
@ -81,7 +70,6 @@ ## 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
|
||||||
|
|||||||
@ -35,18 +35,6 @@ ## 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
|
||||||
@ -226,14 +214,6 @@ ## 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,
|
||||||
|
|||||||
@ -46,11 +46,6 @@ # 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`),
|
||||||
|
|||||||
@ -1,659 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -234,8 +234,7 @@ public static function table(Table $table): Table
|
|||||||
->searchable(),
|
->searchable(),
|
||||||
TextColumn::make('event_type')
|
TextColumn::make('event_type')
|
||||||
->label('Event')
|
->label('Event')
|
||||||
->badge()
|
->badge(),
|
||||||
->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)),
|
|
||||||
TextColumn::make('severity')
|
TextColumn::make('severity')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||||
|
|||||||
@ -380,10 +380,6 @@ public static function eventTypeOptions(): array
|
|||||||
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||||
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||||
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Alerts\AlertDispatchService;
|
use App\Services\Alerts\AlertDispatchService;
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Settings\SettingsResolver;
|
use App\Services\Settings\SettingsResolver;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -22,7 +21,6 @@
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class EvaluateAlertsJob implements ShouldQueue
|
class EvaluateAlertsJob implements ShouldQueue
|
||||||
@ -34,11 +32,7 @@ public function __construct(
|
|||||||
public ?int $operationRunId = null,
|
public ?int $operationRunId = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(
|
public function handle(AlertDispatchService $dispatchService, OperationRunService $operationRuns): void
|
||||||
AlertDispatchService $dispatchService,
|
|
||||||
OperationRunService $operationRuns,
|
|
||||||
FindingNotificationService $findingNotificationService,
|
|
||||||
): void
|
|
||||||
{
|
{
|
||||||
$workspace = Workspace::query()->whereKey($this->workspaceId)->first();
|
$workspace = Workspace::query()->whereKey($this->workspaceId)->first();
|
||||||
|
|
||||||
@ -73,8 +67,6 @@ public function handle(
|
|||||||
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
||||||
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
||||||
];
|
];
|
||||||
$dueSoonFindings = $this->dueSoonFindings((int) $workspace->getKey());
|
|
||||||
$overdueFindings = $this->overdueFindings((int) $workspace->getKey());
|
|
||||||
|
|
||||||
$createdDeliveries = 0;
|
$createdDeliveries = 0;
|
||||||
|
|
||||||
@ -82,33 +74,13 @@ public function handle(
|
|||||||
$createdDeliveries += $dispatchService->dispatchEvent($workspace, $event);
|
$createdDeliveries += $dispatchService->dispatchEvent($workspace, $event);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($dueSoonFindings as $finding) {
|
|
||||||
$result = $findingNotificationService->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
$createdDeliveries += $result['external_delivery_count'];
|
|
||||||
|
|
||||||
if ($result['direct_delivery_status'] === 'sent') {
|
|
||||||
$createdDeliveries++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($overdueFindings as $finding) {
|
|
||||||
$result = $findingNotificationService->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
$createdDeliveries += $result['external_delivery_count'];
|
|
||||||
|
|
||||||
if ($result['direct_delivery_status'] === 'sent') {
|
|
||||||
$createdDeliveries++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$processedEventCount = count($events) + $dueSoonFindings->count() + $overdueFindings->count();
|
|
||||||
|
|
||||||
$operationRuns->updateRun(
|
$operationRuns->updateRun(
|
||||||
$operationRun,
|
$operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
summaryCounts: [
|
summaryCounts: [
|
||||||
'total' => $processedEventCount,
|
'total' => count($events),
|
||||||
'processed' => $processedEventCount,
|
'processed' => count($events),
|
||||||
'created' => $createdDeliveries,
|
'created' => $createdDeliveries,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -129,45 +101,6 @@ public function handle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Finding>
|
|
||||||
*/
|
|
||||||
private function dueSoonFindings(int $workspaceId): Collection
|
|
||||||
{
|
|
||||||
$now = CarbonImmutable::now('UTC');
|
|
||||||
|
|
||||||
return Finding::query()
|
|
||||||
->with('tenant')
|
|
||||||
->withSubjectDisplayName()
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->openWorkflow()
|
|
||||||
->whereNotNull('due_at')
|
|
||||||
->where('due_at', '>', $now)
|
|
||||||
->where('due_at', '<=', $now->addHours(24))
|
|
||||||
->orderBy('due_at')
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Finding>
|
|
||||||
*/
|
|
||||||
private function overdueFindings(int $workspaceId): Collection
|
|
||||||
{
|
|
||||||
$now = CarbonImmutable::now('UTC');
|
|
||||||
|
|
||||||
return Finding::query()
|
|
||||||
->with('tenant')
|
|
||||||
->withSubjectDisplayName()
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->openWorkflow()
|
|
||||||
->whereNotNull('due_at')
|
|
||||||
->where('due_at', '<', $now)
|
|
||||||
->orderBy('due_at')
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun
|
private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun
|
||||||
{
|
{
|
||||||
if (is_int($this->operationRunId) && $this->operationRunId > 0) {
|
if (is_int($this->operationRunId) && $this->operationRunId > 0) {
|
||||||
|
|||||||
@ -28,14 +28,6 @@ class AlertRule extends Model
|
|||||||
|
|
||||||
public const string EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
|
public const string EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
|
||||||
|
|
||||||
public const string EVENT_FINDINGS_ASSIGNED = 'findings.assigned';
|
|
||||||
|
|
||||||
public const string EVENT_FINDINGS_REOPENED = 'findings.reopened';
|
|
||||||
|
|
||||||
public const string EVENT_FINDINGS_DUE_SOON = 'findings.due_soon';
|
|
||||||
|
|
||||||
public const string EVENT_FINDINGS_OVERDUE = 'findings.overdue';
|
|
||||||
|
|
||||||
public const string TENANT_SCOPE_ALL = 'all';
|
public const string TENANT_SCOPE_ALL = 'all';
|
||||||
|
|
||||||
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';
|
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Notifications\Findings;
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Notifications\Notification;
|
|
||||||
|
|
||||||
final class FindingEventNotification extends Notification
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private readonly Finding $finding,
|
|
||||||
private readonly Tenant $tenant,
|
|
||||||
private readonly array $event,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function via(object $notifiable): array
|
|
||||||
{
|
|
||||||
return ['database'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toDatabase(object $notifiable): array
|
|
||||||
{
|
|
||||||
$message = OperationUxPresenter::findingDatabaseNotificationMessage(
|
|
||||||
$this->finding,
|
|
||||||
$this->tenant,
|
|
||||||
$this->event,
|
|
||||||
);
|
|
||||||
|
|
||||||
$message['finding_event'] = [
|
|
||||||
'event_type' => (string) ($this->event['event_type'] ?? ''),
|
|
||||||
'finding_id' => (int) $this->finding->getKey(),
|
|
||||||
'recipient_reason' => data_get($this->event, 'metadata.recipient_reason'),
|
|
||||||
'fingerprint_key' => (string) ($this->event['fingerprint_key'] ?? ''),
|
|
||||||
'due_cycle_key' => $this->event['due_cycle_key'] ?? null,
|
|
||||||
'tenant_name' => $this->tenant->getFilamentName(),
|
|
||||||
'severity' => (string) ($this->event['severity'] ?? ''),
|
|
||||||
];
|
|
||||||
|
|
||||||
return $message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,8 +3,12 @@
|
|||||||
namespace App\Notifications;
|
namespace App\Notifications;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
@ -23,7 +27,25 @@ public function via(object $notifiable): array
|
|||||||
|
|
||||||
public function toDatabase(object $notifiable): array
|
public function toDatabase(object $notifiable): array
|
||||||
{
|
{
|
||||||
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
|
$tenant = $this->run->tenant;
|
||||||
|
$runUrl = match (true) {
|
||||||
|
$notifiable instanceof PlatformUser => SystemOperationRunLinks::view($this->run),
|
||||||
|
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
||||||
|
default => OperationRunLinks::tenantlessView($this->run),
|
||||||
|
};
|
||||||
|
|
||||||
|
$notification = OperationUxPresenter::terminalDatabaseNotification(
|
||||||
|
run: $this->run,
|
||||||
|
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$notification->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label(OperationRunLinks::openLabel())
|
||||||
|
->url($runUrl),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$message = $notification->getDatabaseMessage();
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
if ($reasonEnvelope !== null) {
|
||||||
|
|||||||
@ -3,7 +3,10 @@
|
|||||||
namespace App\Notifications;
|
namespace App\Notifications;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
@ -28,6 +31,31 @@ public function via(object $notifiable): array
|
|||||||
*/
|
*/
|
||||||
public function toDatabase(object $notifiable): array
|
public function toDatabase(object $notifiable): array
|
||||||
{
|
{
|
||||||
return OperationUxPresenter::queuedDatabaseNotificationMessage($this->run, $notifiable);
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
|
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||||
|
$wizard = $context['wizard'] ?? null;
|
||||||
|
|
||||||
|
$isManagedTenantOnboardingWizardRun = is_array($wizard)
|
||||||
|
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
|
||||||
|
|
||||||
|
$operationLabel = OperationCatalog::label((string) $this->run->type);
|
||||||
|
|
||||||
|
$runUrl = match (true) {
|
||||||
|
$isManagedTenantOnboardingWizardRun => OperationRunLinks::tenantlessView($this->run),
|
||||||
|
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return FilamentNotification::make()
|
||||||
|
->title("{$operationLabel} queued")
|
||||||
|
->body('Queued for execution. Open the operation for progress and next steps.')
|
||||||
|
->info()
|
||||||
|
->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label(OperationRunLinks::openLabel())
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->getDatabaseMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
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;
|
||||||
@ -179,7 +178,6 @@ 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,
|
||||||
|
|||||||
@ -186,8 +186,6 @@ private function buildPayload(array $event): array
|
|||||||
return [
|
return [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
'event_type' => trim((string) ($event['event_type'] ?? '')),
|
|
||||||
'fingerprint_key' => trim((string) ($event['fingerprint_key'] ?? '')),
|
|
||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,306 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,389 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Findings;
|
|
||||||
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Services\Alerts\AlertDispatchService;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Carbon\CarbonInterface;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final class FindingNotificationService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly AlertDispatchService $alertDispatchService,
|
|
||||||
private readonly CapabilityResolver $capabilityResolver,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return array{
|
|
||||||
* event_type: string,
|
|
||||||
* fingerprint_key: string,
|
|
||||||
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
|
|
||||||
* external_delivery_count: int
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function dispatch(Finding $finding, string $eventType, array $context = []): array
|
|
||||||
{
|
|
||||||
$finding = $this->reloadFinding($finding);
|
|
||||||
$tenant = $finding->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return $this->dispatchResult(
|
|
||||||
eventType: $eventType,
|
|
||||||
fingerprintKey: '',
|
|
||||||
directDeliveryStatus: 'no_recipient',
|
|
||||||
externalDeliveryCount: 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->shouldSuppressEvent($finding, $eventType, $context)) {
|
|
||||||
return $this->dispatchResult(
|
|
||||||
eventType: $eventType,
|
|
||||||
fingerprintKey: $this->fingerprintFor($finding, $eventType, $context),
|
|
||||||
directDeliveryStatus: 'suppressed',
|
|
||||||
externalDeliveryCount: 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolution = $this->resolveRecipient($finding, $eventType, $context);
|
|
||||||
$event = $this->buildEventEnvelope($finding, $tenant, $eventType, $resolution['reason'], $context);
|
|
||||||
|
|
||||||
$directDeliveryStatus = $this->dispatchDirectNotification($finding, $tenant, $event, $resolution['user_id']);
|
|
||||||
$externalDeliveryCount = $this->dispatchExternalCopies($finding, $event);
|
|
||||||
|
|
||||||
return $this->dispatchResult(
|
|
||||||
eventType: $eventType,
|
|
||||||
fingerprintKey: (string) $event['fingerprint_key'],
|
|
||||||
directDeliveryStatus: $directDeliveryStatus,
|
|
||||||
externalDeliveryCount: $externalDeliveryCount,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return array{user_id: ?int, reason: ?string}
|
|
||||||
*/
|
|
||||||
private function resolveRecipient(Finding $finding, string $eventType, array $context): array
|
|
||||||
{
|
|
||||||
return match ($eventType) {
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => [
|
|
||||||
'user_id' => $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id),
|
|
||||||
'reason' => 'new_assignee',
|
|
||||||
],
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => $this->preferredRecipient(
|
|
||||||
preferredUserId: $this->normalizeId($finding->assignee_user_id),
|
|
||||||
preferredReason: 'current_assignee',
|
|
||||||
fallbackUserId: $this->normalizeId($finding->owner_user_id),
|
|
||||||
fallbackReason: 'current_owner',
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => $this->preferredRecipient(
|
|
||||||
preferredUserId: $this->normalizeId($finding->assignee_user_id),
|
|
||||||
preferredReason: 'current_assignee',
|
|
||||||
fallbackUserId: $this->normalizeId($finding->owner_user_id),
|
|
||||||
fallbackReason: 'current_owner',
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => $this->preferredRecipient(
|
|
||||||
preferredUserId: $this->normalizeId($finding->owner_user_id),
|
|
||||||
preferredReason: 'current_owner',
|
|
||||||
fallbackUserId: $this->normalizeId($finding->assignee_user_id),
|
|
||||||
fallbackReason: 'current_assignee',
|
|
||||||
),
|
|
||||||
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function buildEventEnvelope(
|
|
||||||
Finding $finding,
|
|
||||||
Tenant $tenant,
|
|
||||||
string $eventType,
|
|
||||||
?string $recipientReason,
|
|
||||||
array $context,
|
|
||||||
): array {
|
|
||||||
$severity = strtolower(trim((string) $finding->severity));
|
|
||||||
$summary = $finding->resolvedSubjectDisplayName() ?? 'Finding #'.(int) $finding->getKey();
|
|
||||||
$title = $this->eventLabel($eventType);
|
|
||||||
$fingerprintKey = $this->fingerprintFor($finding, $eventType, $context);
|
|
||||||
$dueCycleKey = $this->dueCycleKey($finding, $eventType);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'event_type' => $eventType,
|
|
||||||
'workspace_id' => (int) $finding->workspace_id,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'finding_id' => (int) $finding->getKey(),
|
|
||||||
'severity' => $severity,
|
|
||||||
'title' => $title,
|
|
||||||
'body' => $this->eventBody($eventType, $tenant, $summary, ucfirst($severity)),
|
|
||||||
'fingerprint_key' => $fingerprintKey,
|
|
||||||
'due_cycle_key' => $dueCycleKey,
|
|
||||||
'metadata' => [
|
|
||||||
'tenant_name' => $tenant->getFilamentName(),
|
|
||||||
'summary' => $summary,
|
|
||||||
'recipient_reason' => $recipientReason,
|
|
||||||
'owner_user_id' => $this->normalizeId($finding->owner_user_id),
|
|
||||||
'assignee_user_id' => $this->normalizeId($finding->assignee_user_id),
|
|
||||||
'due_at' => $this->optionalIso8601($finding->due_at),
|
|
||||||
'reopened_at' => $this->optionalIso8601($finding->reopened_at),
|
|
||||||
'severity_label' => ucfirst($severity),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
private function dispatchDirectNotification(Finding $finding, Tenant $tenant, array $event, ?int $userId): string
|
|
||||||
{
|
|
||||||
if (! is_int($userId) || $userId <= 0) {
|
|
||||||
return 'no_recipient';
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = User::query()->find($userId);
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return 'no_recipient';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return 'suppressed';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)) {
|
|
||||||
return 'suppressed';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->alreadySentDirectNotification($user, (string) $event['fingerprint_key'])) {
|
|
||||||
return 'deduped';
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->notify(new FindingEventNotification($finding, $tenant, $event));
|
|
||||||
|
|
||||||
return 'sent';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
private function dispatchExternalCopies(Finding $finding, array $event): int
|
|
||||||
{
|
|
||||||
$workspace = Workspace::query()->whereKey((int) $finding->workspace_id)->first();
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->alertDispatchService->dispatchEvent($workspace, $event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function alreadySentDirectNotification(User $user, string $fingerprintKey): bool
|
|
||||||
{
|
|
||||||
if ($fingerprintKey === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->where('data->finding_event->fingerprint_key', $fingerprintKey)
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reloadFinding(Finding $finding): Finding
|
|
||||||
{
|
|
||||||
$fresh = Finding::query()
|
|
||||||
->with('tenant')
|
|
||||||
->withSubjectDisplayName()
|
|
||||||
->find($finding->getKey());
|
|
||||||
|
|
||||||
if ($fresh instanceof Finding) {
|
|
||||||
return $fresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
$finding->loadMissing('tenant');
|
|
||||||
|
|
||||||
return $finding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private function shouldSuppressEvent(Finding $finding, string $eventType, array $context): bool
|
|
||||||
{
|
|
||||||
return match ($eventType) {
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => ! $finding->hasOpenStatus()
|
|
||||||
|| $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) === null,
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON, AlertRule::EVENT_FINDINGS_OVERDUE => ! $finding->hasOpenStatus()
|
|
||||||
|| ! $finding->due_at instanceof CarbonInterface,
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private function fingerprintFor(Finding $finding, string $eventType, array $context): string
|
|
||||||
{
|
|
||||||
$findingId = (int) $finding->getKey();
|
|
||||||
|
|
||||||
return match ($eventType) {
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
|
|
||||||
'finding:%d:%s:assignee:%d:updated:%s',
|
|
||||||
$findingId,
|
|
||||||
$eventType,
|
|
||||||
$this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) ?? 0,
|
|
||||||
$this->optionalIso8601($finding->updated_at) ?? 'none',
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
|
|
||||||
'finding:%d:%s:reopened:%s',
|
|
||||||
$findingId,
|
|
||||||
$eventType,
|
|
||||||
$this->optionalIso8601($finding->reopened_at) ?? 'none',
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON,
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
|
|
||||||
'finding:%d:%s:due:%s',
|
|
||||||
$findingId,
|
|
||||||
$eventType,
|
|
||||||
$this->dueCycleKey($finding, $eventType) ?? 'none',
|
|
||||||
),
|
|
||||||
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function dueCycleKey(Finding $finding, string $eventType): ?string
|
|
||||||
{
|
|
||||||
if (! in_array($eventType, [
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON,
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE,
|
|
||||||
], true)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->optionalIso8601($finding->due_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function eventLabel(string $eventType): string
|
|
||||||
{
|
|
||||||
return match ($eventType) {
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
|
|
||||||
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function eventBody(string $eventType, Tenant $tenant, string $summary, string $severityLabel): string
|
|
||||||
{
|
|
||||||
return match ($eventType) {
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
|
|
||||||
'%s in %s was assigned. %s severity.',
|
|
||||||
$summary,
|
|
||||||
$tenant->getFilamentName(),
|
|
||||||
$severityLabel,
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
|
|
||||||
'%s in %s reopened and needs follow-up. %s severity.',
|
|
||||||
$summary,
|
|
||||||
$tenant->getFilamentName(),
|
|
||||||
$severityLabel,
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => sprintf(
|
|
||||||
'%s in %s is due within 24 hours. %s severity.',
|
|
||||||
$summary,
|
|
||||||
$tenant->getFilamentName(),
|
|
||||||
$severityLabel,
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
|
|
||||||
'%s in %s is overdue. %s severity.',
|
|
||||||
$summary,
|
|
||||||
$tenant->getFilamentName(),
|
|
||||||
$severityLabel,
|
|
||||||
),
|
|
||||||
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{user_id: ?int, reason: ?string}
|
|
||||||
*/
|
|
||||||
private function preferredRecipient(
|
|
||||||
?int $preferredUserId,
|
|
||||||
string $preferredReason,
|
|
||||||
?int $fallbackUserId,
|
|
||||||
string $fallbackReason,
|
|
||||||
): array {
|
|
||||||
if (is_int($preferredUserId) && $preferredUserId > 0) {
|
|
||||||
return [
|
|
||||||
'user_id' => $preferredUserId,
|
|
||||||
'reason' => $preferredReason,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_int($fallbackUserId) && $fallbackUserId > 0) {
|
|
||||||
return [
|
|
||||||
'user_id' => $fallbackUserId,
|
|
||||||
'reason' => $fallbackReason,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'user_id' => null,
|
|
||||||
'reason' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeId(mixed $value): ?int
|
|
||||||
{
|
|
||||||
if (! is_numeric($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = (int) $value;
|
|
||||||
|
|
||||||
return $normalized > 0 ? $normalized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function optionalIso8601(mixed $value): ?string
|
|
||||||
{
|
|
||||||
if (! $value instanceof CarbonInterface) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value->toIso8601String();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* event_type: string,
|
|
||||||
* fingerprint_key: string,
|
|
||||||
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
|
|
||||||
* external_delivery_count: int
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function dispatchResult(
|
|
||||||
string $eventType,
|
|
||||||
string $fingerprintKey,
|
|
||||||
string $directDeliveryStatus,
|
|
||||||
int $externalDeliveryCount,
|
|
||||||
): array {
|
|
||||||
return [
|
|
||||||
'event_type' => $eventType,
|
|
||||||
'fingerprint_key' => $fingerprintKey,
|
|
||||||
'direct_delivery_status' => $directDeliveryStatus,
|
|
||||||
'external_delivery_count' => $externalDeliveryCount,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Services\Findings;
|
namespace App\Services\Findings;
|
||||||
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantMembership;
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -27,21 +26,8 @@ public function __construct(
|
|||||||
private readonly FindingSlaPolicy $slaPolicy,
|
private readonly FindingSlaPolicy $slaPolicy,
|
||||||
private readonly AuditLogger $auditLogger,
|
private readonly AuditLogger $auditLogger,
|
||||||
private readonly CapabilityResolver $capabilityResolver,
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
private readonly FindingNotificationService $findingNotificationService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function meaningfulActivityActionValues(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
AuditActionId::FindingAssigned->value,
|
|
||||||
AuditActionId::FindingInProgress->value,
|
|
||||||
AuditActionId::FindingReopened->value,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||||
{
|
{
|
||||||
$this->authorize($finding, $tenant, $actor, [
|
$this->authorize($finding, $tenant, $actor, [
|
||||||
@ -122,7 +108,6 @@ public function assign(
|
|||||||
throw new InvalidArgumentException('Only open findings can be assigned.');
|
throw new InvalidArgumentException('Only open findings can be assigned.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$beforeAssigneeUserId = is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null;
|
|
||||||
$this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id');
|
$this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id');
|
||||||
$this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id');
|
$this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id');
|
||||||
|
|
||||||
@ -139,7 +124,7 @@ public function assign(
|
|||||||
afterAssigneeUserId: $assigneeUserId,
|
afterAssigneeUserId: $assigneeUserId,
|
||||||
);
|
);
|
||||||
|
|
||||||
$updatedFinding = $this->mutateAndAudit(
|
return $this->mutateAndAudit(
|
||||||
finding: $finding,
|
finding: $finding,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
actor: $actor,
|
actor: $actor,
|
||||||
@ -157,16 +142,6 @@ public function assign(
|
|||||||
$record->owner_user_id = $ownerUserId;
|
$record->owner_user_id = $ownerUserId;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($assigneeUserId !== null && $assigneeUserId !== $beforeAssigneeUserId) {
|
|
||||||
$this->findingNotificationService->dispatch(
|
|
||||||
$updatedFinding,
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED,
|
|
||||||
['assignee_user_id' => $assigneeUserId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $updatedFinding;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function claim(Finding $finding, Tenant $tenant, User $actor): Finding
|
public function claim(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||||
@ -466,7 +441,7 @@ public function reopenBySystem(
|
|||||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
|
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
|
||||||
|
|
||||||
$reopenedFinding = $this->mutateAndAudit(
|
return $this->mutateAndAudit(
|
||||||
finding: $finding,
|
finding: $finding,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
actor: null,
|
actor: null,
|
||||||
@ -497,30 +472,6 @@ public function reopenBySystem(
|
|||||||
actorType: AuditActorType::System,
|
actorType: AuditActorType::System,
|
||||||
operationRunId: $operationRunId,
|
operationRunId: $operationRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->findingNotificationService->dispatch($reopenedFinding, AlertRule::EVENT_FINDINGS_REOPENED);
|
|
||||||
|
|
||||||
return $reopenedFinding;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lastMeaningfulActivityAt(Finding $finding, mixed $latestWorkflowAuditAt = null): ?CarbonImmutable
|
|
||||||
{
|
|
||||||
$timestamps = array_filter([
|
|
||||||
$this->normalizeActivityTimestamp($finding->in_progress_at),
|
|
||||||
$this->normalizeActivityTimestamp($finding->reopened_at),
|
|
||||||
$this->normalizeActivityTimestamp($latestWorkflowAuditAt),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($timestamps === []) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
usort(
|
|
||||||
$timestamps,
|
|
||||||
static fn (CarbonImmutable $left, CarbonImmutable $right): int => $left->greaterThan($right) ? -1 : ($left->equalTo($right) ? 0 : 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $timestamps[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -589,27 +540,6 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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', '/admin/findings/hygiene'], true)) {
|
if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake'], 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', '/admin/findings/hygiene'], 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'], true)
|
||||||
) {
|
) {
|
||||||
$this->configureNavigationForRequest($panel);
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
@ -265,10 +265,6 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Support\OpsUx;
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -16,13 +12,11 @@
|
|||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
use App\Support\Ui\DerivedState\DerivedStateKey;
|
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
|
|
||||||
final class OperationUxPresenter
|
final class OperationUxPresenter
|
||||||
@ -87,48 +81,6 @@ public static function scopeBusyToast(
|
|||||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function findingDatabaseNotificationMessage(Finding $finding, Tenant $tenant, array $event): array
|
|
||||||
{
|
|
||||||
return self::databaseNotificationMessage(
|
|
||||||
title: self::findingNotificationTitle($event),
|
|
||||||
body: self::findingNotificationBody($event),
|
|
||||||
status: self::findingNotificationStatus($event),
|
|
||||||
actionName: 'open_finding',
|
|
||||||
actionLabel: 'Open finding',
|
|
||||||
actionUrl: FindingResource::getUrl(
|
|
||||||
'view',
|
|
||||||
['record' => $finding],
|
|
||||||
panel: 'tenant',
|
|
||||||
tenant: $tenant,
|
|
||||||
),
|
|
||||||
actionTarget: 'finding_detail',
|
|
||||||
supportingLines: self::findingNotificationSupportingLines($event),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function queuedDatabaseNotificationMessage(OperationRun $run, object $notifiable): array
|
|
||||||
{
|
|
||||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
|
||||||
$primaryAction = self::operationRunPrimaryAction($run, $notifiable);
|
|
||||||
|
|
||||||
return self::databaseNotificationMessage(
|
|
||||||
title: "{$operationLabel} queued",
|
|
||||||
body: 'Queued for execution. Open the operation for progress and next steps.',
|
|
||||||
status: 'info',
|
|
||||||
actionName: 'view_run',
|
|
||||||
actionLabel: $primaryAction['label'],
|
|
||||||
actionUrl: $primaryAction['url'],
|
|
||||||
actionTarget: $primaryAction['target'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Terminal DB notification payload.
|
* Terminal DB notification payload.
|
||||||
*
|
*
|
||||||
@ -137,40 +89,44 @@ public static function queuedDatabaseNotificationMessage(OperationRun $run, obje
|
|||||||
*/
|
*/
|
||||||
public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification
|
public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification
|
||||||
{
|
{
|
||||||
$payload = self::terminalNotificationPayload($run);
|
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||||
$actionUrl = $tenant instanceof Tenant
|
$presentation = self::terminalPresentation($run);
|
||||||
? OperationRunUrl::view($run, $tenant)
|
$bodyLines = [$presentation['body']];
|
||||||
: OperationRunLinks::tenantlessView($run);
|
|
||||||
|
|
||||||
return self::makeDatabaseNotification(
|
$failureMessage = self::surfaceFailureDetail($run);
|
||||||
title: $payload['title'],
|
if ($failureMessage !== null) {
|
||||||
body: $payload['body'],
|
$bodyLines[] = $failureMessage;
|
||||||
status: $payload['status'],
|
|
||||||
actionName: 'view_run',
|
|
||||||
actionLabel: OperationRunLinks::openLabel(),
|
|
||||||
actionUrl: $actionUrl,
|
|
||||||
supportingLines: $payload['supportingLines'],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
$guidance = self::surfaceGuidance($run);
|
||||||
* @return array<string, mixed>
|
if ($guidance !== null) {
|
||||||
*/
|
$bodyLines[] = $guidance;
|
||||||
public static function terminalDatabaseNotificationMessage(OperationRun $run, object $notifiable): array
|
}
|
||||||
{
|
|
||||||
$payload = self::terminalNotificationPayload($run);
|
|
||||||
$primaryAction = self::operationRunPrimaryAction($run, $notifiable);
|
|
||||||
|
|
||||||
return self::databaseNotificationMessage(
|
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||||
title: $payload['title'],
|
if ($summary !== null) {
|
||||||
body: $payload['body'],
|
$bodyLines[] = $summary;
|
||||||
status: $payload['status'],
|
}
|
||||||
actionName: 'view_run',
|
|
||||||
actionLabel: $primaryAction['label'],
|
$integritySummary = RedactionIntegrity::noteForRun($run);
|
||||||
actionUrl: $primaryAction['url'],
|
if (is_string($integritySummary) && trim($integritySummary) !== '') {
|
||||||
actionTarget: $primaryAction['target'],
|
$bodyLines[] = trim($integritySummary);
|
||||||
supportingLines: $payload['supportingLines'],
|
}
|
||||||
);
|
|
||||||
|
$notification = FilamentNotification::make()
|
||||||
|
->title("{$operationLabel} {$presentation['titleSuffix']}")
|
||||||
|
->body(implode("\n", $bodyLines))
|
||||||
|
->status($presentation['status']);
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$notification->actions([
|
||||||
|
\Filament\Actions\Action::make('view')
|
||||||
|
->label(OperationRunLinks::openLabel())
|
||||||
|
->url(OperationRunUrl::view($run, $tenant)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $notification;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function surfaceGuidance(OperationRun $run): ?string
|
public static function surfaceGuidance(OperationRun $run): ?string
|
||||||
@ -389,59 +345,6 @@ private static function buildLifecycleAttentionSummary(OperationRun $run): ?stri
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function findingNotificationSupportingLines(array $event): array
|
|
||||||
{
|
|
||||||
$recipientReason = self::findingRecipientReasonCopy((string) data_get($event, 'metadata.recipient_reason', ''));
|
|
||||||
|
|
||||||
return $recipientReason !== '' ? [$recipientReason] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
private static function findingNotificationTitle(array $event): string
|
|
||||||
{
|
|
||||||
$title = trim((string) ($event['title'] ?? 'Finding update'));
|
|
||||||
|
|
||||||
return $title !== '' ? $title : 'Finding update';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
private static function findingNotificationBody(array $event): string
|
|
||||||
{
|
|
||||||
$body = trim((string) ($event['body'] ?? 'A finding needs follow-up.'));
|
|
||||||
|
|
||||||
return $body !== '' ? $body : 'A finding needs follow-up.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
private static function findingNotificationStatus(array $event): string
|
|
||||||
{
|
|
||||||
return match ((string) ($event['event_type'] ?? '')) {
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => 'warning',
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => 'danger',
|
|
||||||
default => 'info',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function findingRecipientReasonCopy(string $reason): string
|
|
||||||
{
|
|
||||||
return match ($reason) {
|
|
||||||
'new_assignee' => 'You are the new assignee.',
|
|
||||||
'current_assignee' => 'You are the current assignee.',
|
|
||||||
'current_owner' => 'You are the accountable owner.',
|
|
||||||
default => '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
|
public static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
|
||||||
{
|
{
|
||||||
return self::resolveGovernanceOperatorExplanation($run);
|
return self::resolveGovernanceOperatorExplanation($run);
|
||||||
@ -474,7 +377,7 @@ private static function terminalPresentation(OperationRun $run): array
|
|||||||
if ($freshnessState->isReconciledFailed()) {
|
if ($freshnessState->isReconciledFailed()) {
|
||||||
return [
|
return [
|
||||||
'titleSuffix' => 'was automatically reconciled',
|
'titleSuffix' => 'was automatically reconciled',
|
||||||
'body' => 'Automatically reconciled after infrastructure failure.',
|
'body' => $reasonEnvelope?->operatorLabel ?? 'Automatically reconciled after infrastructure failure.',
|
||||||
'status' => 'danger',
|
'status' => 'danger',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -492,198 +395,17 @@ private static function terminalPresentation(OperationRun $run): array
|
|||||||
],
|
],
|
||||||
'blocked' => [
|
'blocked' => [
|
||||||
'titleSuffix' => 'blocked by prerequisite',
|
'titleSuffix' => 'blocked by prerequisite',
|
||||||
'body' => 'Blocked by prerequisite.',
|
'body' => $reasonEnvelope?->operatorLabel ?? 'Blocked by prerequisite.',
|
||||||
'status' => 'warning',
|
'status' => 'warning',
|
||||||
],
|
],
|
||||||
default => [
|
default => [
|
||||||
'titleSuffix' => 'execution failed',
|
'titleSuffix' => 'execution failed',
|
||||||
'body' => 'Execution failed.',
|
'body' => $reasonEnvelope?->operatorLabel ?? 'Execution failed.',
|
||||||
'status' => 'danger',
|
'status' => 'danger',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* title:string,
|
|
||||||
* body:string,
|
|
||||||
* status:string,
|
|
||||||
* supportingLines:list<string>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private static function terminalNotificationPayload(OperationRun $run): array
|
|
||||||
{
|
|
||||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
|
||||||
$presentation = self::terminalPresentation($run);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'title' => "{$operationLabel} {$presentation['titleSuffix']}",
|
|
||||||
'body' => $presentation['body'],
|
|
||||||
'status' => $presentation['status'],
|
|
||||||
'supportingLines' => self::terminalSupportingLines($run),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function terminalSupportingLines(OperationRun $run): array
|
|
||||||
{
|
|
||||||
$lines = [];
|
|
||||||
$reasonLabel = trim((string) (self::reasonEnvelope($run)?->operatorLabel ?? ''));
|
|
||||||
|
|
||||||
if ($reasonLabel !== '') {
|
|
||||||
$lines[] = $reasonLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
$failureMessage = self::surfaceFailureDetail($run);
|
|
||||||
|
|
||||||
if ($failureMessage !== null) {
|
|
||||||
$lines[] = $failureMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
$guidance = self::surfaceGuidance($run);
|
|
||||||
if ($guidance !== null) {
|
|
||||||
$lines[] = $guidance;
|
|
||||||
}
|
|
||||||
|
|
||||||
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
|
||||||
if ($summary !== null) {
|
|
||||||
$lines[] = $summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
$integritySummary = RedactionIntegrity::noteForRun($run);
|
|
||||||
if (is_string($integritySummary) && trim($integritySummary) !== '') {
|
|
||||||
$lines[] = trim($integritySummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label:string, url:?string, target:string}
|
|
||||||
*/
|
|
||||||
private static function operationRunPrimaryAction(OperationRun $run, object $notifiable): array
|
|
||||||
{
|
|
||||||
if ($notifiable instanceof PlatformUser) {
|
|
||||||
return [
|
|
||||||
'label' => OperationRunLinks::openLabel(),
|
|
||||||
'url' => SystemOperationRunLinks::view($run),
|
|
||||||
'target' => 'system_operation_run',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self::isManagedTenantOnboardingWizardRun($run)) {
|
|
||||||
return [
|
|
||||||
'label' => OperationRunLinks::openLabel(),
|
|
||||||
'url' => OperationRunLinks::tenantlessView($run),
|
|
||||||
'target' => 'tenantless_operation_run',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
'label' => OperationRunLinks::openLabel(),
|
|
||||||
'url' => OperationRunLinks::view($run, $run->tenant),
|
|
||||||
'target' => 'admin_operation_run',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => OperationRunLinks::openLabel(),
|
|
||||||
'url' => OperationRunLinks::tenantlessView($run),
|
|
||||||
'target' => 'tenantless_operation_run',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function isManagedTenantOnboardingWizardRun(OperationRun $run): bool
|
|
||||||
{
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
|
||||||
$wizard = $context['wizard'] ?? null;
|
|
||||||
|
|
||||||
return is_array($wizard)
|
|
||||||
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $supportingLines
|
|
||||||
*/
|
|
||||||
private static function makeDatabaseNotification(
|
|
||||||
string $title,
|
|
||||||
string $body,
|
|
||||||
string $status,
|
|
||||||
string $actionName,
|
|
||||||
string $actionLabel,
|
|
||||||
?string $actionUrl,
|
|
||||||
array $supportingLines = [],
|
|
||||||
): FilamentNotification {
|
|
||||||
return FilamentNotification::make()
|
|
||||||
->title($title)
|
|
||||||
->body(self::composeDatabaseNotificationBody($body, $supportingLines))
|
|
||||||
->status($status)
|
|
||||||
->actions([
|
|
||||||
Action::make($actionName)
|
|
||||||
->label($actionLabel)
|
|
||||||
->url($actionUrl),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $supportingLines
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private static function databaseNotificationMessage(
|
|
||||||
string $title,
|
|
||||||
string $body,
|
|
||||||
string $status,
|
|
||||||
string $actionName,
|
|
||||||
string $actionLabel,
|
|
||||||
?string $actionUrl,
|
|
||||||
string $actionTarget,
|
|
||||||
array $supportingLines = [],
|
|
||||||
): array {
|
|
||||||
$message = self::makeDatabaseNotification(
|
|
||||||
title: $title,
|
|
||||||
body: $body,
|
|
||||||
status: $status,
|
|
||||||
actionName: $actionName,
|
|
||||||
actionLabel: $actionLabel,
|
|
||||||
actionUrl: $actionUrl,
|
|
||||||
supportingLines: $supportingLines,
|
|
||||||
)->getDatabaseMessage();
|
|
||||||
|
|
||||||
$message['supporting_lines'] = array_values(array_filter(
|
|
||||||
$supportingLines,
|
|
||||||
static fn (string $line): bool => trim($line) !== '',
|
|
||||||
));
|
|
||||||
|
|
||||||
if (is_array($message['actions'][0] ?? null)) {
|
|
||||||
$message['actions'][0]['target'] = $actionTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $supportingLines
|
|
||||||
*/
|
|
||||||
private static function composeDatabaseNotificationBody(string $body, array $supportingLines): string
|
|
||||||
{
|
|
||||||
$lines = [trim($body)];
|
|
||||||
|
|
||||||
foreach ($supportingLines as $line) {
|
|
||||||
$line = trim($line);
|
|
||||||
|
|
||||||
if ($line === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines[] = $line;
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode("\n", array_filter($lines, static fn (string $line): bool => $line !== ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function requiresFollowUp(OperationRun $run): bool
|
private static function requiresFollowUp(OperationRun $run): bool
|
||||||
{
|
{
|
||||||
if (self::firstNextStepLabel($run) !== null) {
|
if (self::firstNextStepLabel($run) !== null) {
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -21,7 +20,6 @@
|
|||||||
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;
|
||||||
@ -51,7 +49,6 @@ 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,
|
||||||
@ -137,7 +134,6 @@ 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;
|
||||||
|
|
||||||
@ -178,7 +174,6 @@ 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,
|
||||||
@ -268,66 +263,6 @@ 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>>
|
||||||
|
|||||||
@ -140,34 +140,6 @@ public function reopened(): static
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function ownedBy(?int $userId): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes): array => [
|
|
||||||
'owner_user_id' => $userId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function assignedTo(?int $userId): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes): array => [
|
|
||||||
'assignee_user_id' => $userId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function dueWithinHours(int $hours): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes): array => [
|
|
||||||
'due_at' => now()->addHours($hours),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function overdueByHours(int $hours = 1): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes): array => [
|
|
||||||
'due_at' => now()->subHours($hours),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for closed findings.
|
* State for closed findings.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,103 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -3,7 +3,6 @@
|
|||||||
$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
|
||||||
|
|
||||||
@ -102,52 +101,6 @@ 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">
|
||||||
|
|||||||
@ -1,284 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\AlertDeliveryResource;
|
|
||||||
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
|
||||||
use App\Filament\Resources\AlertRuleResource;
|
|
||||||
use App\Models\AlertDelivery;
|
|
||||||
use App\Models\AlertDestination;
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
it('exposes the four finding notification events in the existing alert rule options', function (): void {
|
|
||||||
expect(AlertRuleResource::eventTypeOptions())->toMatchArray([
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('delivers a direct finding notification without requiring a matching alert rule', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('sent')
|
|
||||||
->and($result['external_delivery_count'])->toBe(0)
|
|
||||||
->and($assignee->notifications()->where('type', FindingEventNotification::class)->count())->toBe(1)
|
|
||||||
->and(AlertDelivery::query()->count())->toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fans out matching external copies through the existing alert delivery pipeline', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
|
||||||
|
|
||||||
$destination = AlertDestination::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'is_enabled' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule = AlertRule::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
|
||||||
'minimum_severity' => Finding::SEVERITY_MEDIUM,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'cooldown_seconds' => 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule->destinations()->attach($destination->getKey(), [
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'severity' => Finding::SEVERITY_HIGH,
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
$delivery = AlertDelivery::query()->latest('id')->first();
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('sent')
|
|
||||||
->and($result['external_delivery_count'])->toBe(1)
|
|
||||||
->and($delivery)->not->toBeNull()
|
|
||||||
->and($delivery?->event_type)->toBe(AlertRule::EVENT_FINDINGS_OVERDUE)
|
|
||||||
->and(data_get($delivery?->payload, 'title'))->toBe('Finding overdue');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inherits minimum severity tenant scoping and cooldown suppression for finding alert copies', function (): void {
|
|
||||||
[$ownerA, $tenantA] = createUserWithTenant(role: 'owner');
|
|
||||||
$workspaceId = (int) $tenantA->workspace_id;
|
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
]);
|
|
||||||
[$ownerB] = createUserWithTenant(tenant: $tenantB, role: 'owner');
|
|
||||||
|
|
||||||
$destination = AlertDestination::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'is_enabled' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule = AlertRule::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
|
||||||
'minimum_severity' => Finding::SEVERITY_HIGH,
|
|
||||||
'tenant_scope_mode' => AlertRule::TENANT_SCOPE_ALLOWLIST,
|
|
||||||
'tenant_allowlist' => [(int) $tenantA->getKey()],
|
|
||||||
'is_enabled' => true,
|
|
||||||
'cooldown_seconds' => 3600,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule->destinations()->attach($destination->getKey(), [
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$mediumFinding = Finding::factory()->for($tenantA)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $ownerA->getKey(),
|
|
||||||
'severity' => Finding::SEVERITY_MEDIUM,
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$scopedOutFinding = Finding::factory()->for($tenantB)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $ownerB->getKey(),
|
|
||||||
'severity' => Finding::SEVERITY_CRITICAL,
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$trackedFinding = Finding::factory()->for($tenantA)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $ownerA->getKey(),
|
|
||||||
'severity' => Finding::SEVERITY_HIGH,
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(app(FindingNotificationService::class)->dispatch($mediumFinding, AlertRule::EVENT_FINDINGS_OVERDUE)['external_delivery_count'])->toBe(0)
|
|
||||||
->and(app(FindingNotificationService::class)->dispatch($scopedOutFinding, AlertRule::EVENT_FINDINGS_OVERDUE)['external_delivery_count'])->toBe(0);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($trackedFinding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
app(FindingNotificationService::class)->dispatch($trackedFinding->fresh(), AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
$deliveries = AlertDelivery::query()
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->where('event_type', AlertRule::EVENT_FINDINGS_OVERDUE)
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
expect($deliveries)->toHaveCount(2)
|
|
||||||
->and($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED)
|
|
||||||
->and($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inherits quiet hours deferral for finding alert copies', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
|
||||||
|
|
||||||
$destination = AlertDestination::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'is_enabled' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule = AlertRule::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'event_type' => AlertRule::EVENT_FINDINGS_ASSIGNED,
|
|
||||||
'minimum_severity' => Finding::SEVERITY_LOW,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'cooldown_seconds' => 0,
|
|
||||||
'quiet_hours_enabled' => true,
|
|
||||||
'quiet_hours_start' => '00:00',
|
|
||||||
'quiet_hours_end' => '23:59',
|
|
||||||
'quiet_hours_timezone' => 'UTC',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule->destinations()->attach($destination->getKey(), [
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $owner->getKey(),
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
$delivery = AlertDelivery::query()->latest('id')->first();
|
|
||||||
|
|
||||||
expect($result['external_delivery_count'])->toBe(1)
|
|
||||||
->and($delivery)->not->toBeNull()
|
|
||||||
->and($delivery?->status)->toBe(AlertDelivery::STATUS_DEFERRED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders finding event labels and filters in the existing alert deliveries viewer', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
|
||||||
|
|
||||||
$rule = AlertRule::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$destination = AlertDestination::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'is_enabled' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$delivery = AlertDelivery::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'alert_rule_id' => (int) $rule->getKey(),
|
|
||||||
'alert_destination_id' => (int) $destination->getKey(),
|
|
||||||
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
|
||||||
'payload' => [
|
|
||||||
'title' => 'Finding overdue',
|
|
||||||
'body' => 'A finding is overdue and needs follow-up.',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
Livewire::test(ListAlertDeliveries::class)
|
|
||||||
->filterTable('event_type', AlertRule::EVENT_FINDINGS_OVERDUE)
|
|
||||||
->assertCanSeeTableRecords([$delivery])
|
|
||||||
->assertSee('Finding overdue');
|
|
||||||
|
|
||||||
expect(AlertRuleResource::eventTypeLabel(AlertRule::EVENT_FINDINGS_OVERDUE))->toBe('Finding overdue');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves alerts read and mutation boundaries for the existing admin surfaces', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$viewer = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $viewer->getKey(),
|
|
||||||
'role' => 'readonly',
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
||||||
|
|
||||||
$this->actingAs($viewer)
|
|
||||||
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->actingAs($viewer)
|
|
||||||
->get(AlertDeliveryResource::getUrl(panel: 'admin'))
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->actingAs($viewer)
|
|
||||||
->get(AlertRuleResource::getUrl('create', panel: 'admin'))
|
|
||||||
->assertForbidden();
|
|
||||||
|
|
||||||
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
|
||||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
||||||
$resolver->shouldReceive('can')->andReturnFalse();
|
|
||||||
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
|
||||||
|
|
||||||
$this->actingAs($viewer)
|
|
||||||
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
|
||||||
->assertForbidden();
|
|
||||||
|
|
||||||
$this->actingAs($viewer)
|
|
||||||
->get(AlertDeliveryResource::getUrl(panel: 'admin'))
|
|
||||||
->assertForbidden();
|
|
||||||
|
|
||||||
$outsider = User::factory()->create();
|
|
||||||
app()->forgetInstance(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
$this->actingAs($outsider)
|
|
||||||
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
@ -194,41 +194,3 @@ function invokeSlaDueEvents(int $workspaceId, CarbonImmutable $windowStart): arr
|
|||||||
->and($first[0]['fingerprint_key'])->toBe($second[0]['fingerprint_key'])
|
->and($first[0]['fingerprint_key'])->toBe($second[0]['fingerprint_key'])
|
||||||
->and($first[0]['fingerprint_key'])->not->toBe($third[0]['fingerprint_key']);
|
->and($first[0]['fingerprint_key'])->not->toBe($third[0]['fingerprint_key']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps aggregate sla due alerts separate from finding-level due soon reminders', function (): void {
|
|
||||||
$now = CarbonImmutable::parse('2026-04-22T12:00:00Z');
|
|
||||||
CarbonImmutable::setTestNow($now);
|
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
|
||||||
|
|
||||||
Finding::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'severity' => Finding::SEVERITY_HIGH,
|
|
||||||
'due_at' => $now->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'severity' => Finding::SEVERITY_CRITICAL,
|
|
||||||
'due_at' => $now->addHours(6),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$events = invokeSlaDueEvents($workspaceId, $now->subDay());
|
|
||||||
|
|
||||||
expect($events)->toHaveCount(1)
|
|
||||||
->and($events[0]['event_type'])->toBe(AlertRule::EVENT_SLA_DUE)
|
|
||||||
->and($events[0]['metadata'])->toMatchArray([
|
|
||||||
'overdue_total' => 1,
|
|
||||||
'overdue_by_severity' => [
|
|
||||||
'critical' => 0,
|
|
||||||
'high' => 1,
|
|
||||||
'medium' => 0,
|
|
||||||
'low' => 0,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,232 +0,0 @@
|
|||||||
<?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,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
<?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');
|
|
||||||
});
|
|
||||||
@ -1,399 +0,0 @@
|
|||||||
<?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,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@ -1,249 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
|
||||||
function spec230ExpectedNotificationIcon(string $status): string
|
|
||||||
{
|
|
||||||
return (string) data_get(
|
|
||||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
|
||||||
'icon',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestFindingNotificationFor(User $user): ?\Illuminate\Notifications\DatabaseNotification
|
|
||||||
{
|
|
||||||
return $user->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function findingNotificationCountFor(User $user, string $eventType): int
|
|
||||||
{
|
|
||||||
return $user->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->get()
|
|
||||||
->filter(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === $eventType)
|
|
||||||
->count();
|
|
||||||
}
|
|
||||||
|
|
||||||
function runEvaluateAlertsForWorkspace(int $workspaceId): void
|
|
||||||
{
|
|
||||||
$operationRun = OperationRun::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'tenant_id' => null,
|
|
||||||
'type' => 'alerts.evaluate',
|
|
||||||
'status' => OperationRunStatus::Queued->value,
|
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId, (int) $operationRun->getKey());
|
|
||||||
$job->handle(
|
|
||||||
app(\App\Services\Alerts\AlertDispatchService::class),
|
|
||||||
app(\App\Services\OperationRunService::class),
|
|
||||||
app(FindingNotificationService::class),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('emits assignment notifications only when a new assignee is committed', function (): void {
|
|
||||||
[$owner, $tenant] = $this->actingAsFindingOperator();
|
|
||||||
$firstAssignee = User::factory()->create(['name' => 'First Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $firstAssignee, role: 'operator');
|
|
||||||
|
|
||||||
$secondAssignee = User::factory()->create(['name' => 'Second Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $secondAssignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_TRIAGED, [
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$workflow = app(FindingWorkflowService::class);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding,
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: (int) $firstAssignee->getKey(),
|
|
||||||
ownerUserId: (int) $owner->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$firstNotification = latestFindingNotificationFor($firstAssignee);
|
|
||||||
|
|
||||||
expect($firstNotification)->not->toBeNull()
|
|
||||||
->and(data_get($firstNotification?->data, 'title'))->toBe('Finding assigned')
|
|
||||||
->and(data_get($firstNotification?->data, 'status'))->toBe('info')
|
|
||||||
->and(data_get($firstNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'))
|
|
||||||
->and(data_get($firstNotification?->data, 'actions.0.label'))->toBe('Open finding')
|
|
||||||
->and(data_get($firstNotification?->data, 'actions.0.target'))->toBe('finding_detail')
|
|
||||||
->and(data_get($firstNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding->fresh(),
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: (int) $firstAssignee->getKey(),
|
|
||||||
ownerUserId: (int) $secondAssignee->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding->fresh(),
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: null,
|
|
||||||
ownerUserId: (int) $secondAssignee->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($firstAssignee, AlertRule::EVENT_FINDINGS_ASSIGNED))->toBe(1);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding->fresh(),
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: (int) $secondAssignee->getKey(),
|
|
||||||
ownerUserId: (int) $secondAssignee->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$secondNotification = latestFindingNotificationFor($secondAssignee);
|
|
||||||
|
|
||||||
expect($secondNotification)->not->toBeNull()
|
|
||||||
->and(data_get($secondNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
|
||||||
->and(findingNotificationCountFor($secondAssignee, AlertRule::EVENT_FINDINGS_ASSIGNED))->toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dedupes repeated reopen dispatches for the same reopen occurrence', function (): void {
|
|
||||||
$now = CarbonImmutable::parse('2026-04-22T09:30:00Z');
|
|
||||||
CarbonImmutable::setTestNow($now);
|
|
||||||
|
|
||||||
[$owner, $tenant] = $this->actingAsFindingOperator();
|
|
||||||
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED, [
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$reopened = app(FindingWorkflowService::class)->reopenBySystem(
|
|
||||||
finding: $finding,
|
|
||||||
tenant: $tenant,
|
|
||||||
reopenedAt: $now,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_REOPENED))->toBe(1);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($reopened->fresh(), AlertRule::EVENT_FINDINGS_REOPENED);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_REOPENED))->toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends due soon and overdue notifications once per due cycle and resets when due_at changes', function (): void {
|
|
||||||
$now = CarbonImmutable::parse('2026-04-22T10:00:00Z');
|
|
||||||
CarbonImmutable::setTestNow($now);
|
|
||||||
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
$this->actingAs($owner);
|
|
||||||
|
|
||||||
$assignee = User::factory()->create(['name' => 'Due Soon Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$dueSoonFinding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
'due_at' => $now->addHours(6),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$overdueFinding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'due_at' => $now->subHours(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$closedFinding = Finding::factory()->for($tenant)->closed()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
'due_at' => $now->subHours(1),
|
|
||||||
]);
|
|
||||||
|
|
||||||
runEvaluateAlertsForWorkspace($workspaceId);
|
|
||||||
runEvaluateAlertsForWorkspace($workspaceId);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(1)
|
|
||||||
->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1);
|
|
||||||
|
|
||||||
$dueSoonNotification = $assignee->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->get()
|
|
||||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
$overdueNotification = $owner->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->get()
|
|
||||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
expect($dueSoonNotification)->not->toBeNull()
|
|
||||||
->and(data_get($dueSoonNotification?->data, 'status'))->toBe('warning')
|
|
||||||
->and(data_get($dueSoonNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('warning'))
|
|
||||||
->and(data_get($dueSoonNotification?->data, 'actions.0.label'))->toBe('Open finding');
|
|
||||||
expect($overdueNotification)->not->toBeNull()
|
|
||||||
->and(data_get($overdueNotification?->data, 'status'))->toBe('danger')
|
|
||||||
->and(data_get($overdueNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('danger'))
|
|
||||||
->and(data_get($overdueNotification?->data, 'actions.0.label'))->toBe('Open finding');
|
|
||||||
|
|
||||||
expect($assignee->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->get()
|
|
||||||
->contains(fn ($notification): bool => (int) data_get($notification->data, 'finding_event.finding_id') === (int) $closedFinding->getKey()))
|
|
||||||
->toBeFalse();
|
|
||||||
|
|
||||||
$dueSoonFinding->forceFill([
|
|
||||||
'due_at' => $now->addHours(12),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$overdueFinding->forceFill([
|
|
||||||
'due_at' => $now->addDay()->subHour(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
runEvaluateAlertsForWorkspace($workspaceId);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(2)
|
|
||||||
->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1);
|
|
||||||
|
|
||||||
$dueSoonFinding->forceFill([
|
|
||||||
'due_at' => $now->addDays(5),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
CarbonImmutable::setTestNow($now->addDays(2));
|
|
||||||
$overdueFinding->forceFill([
|
|
||||||
'due_at' => CarbonImmutable::now('UTC')->subHour(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
runEvaluateAlertsForWorkspace($workspaceId);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(2);
|
|
||||||
});
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\TenantMembership;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
|
||||||
function spec230ExpectedNotificationIcon(string $status): string
|
|
||||||
{
|
|
||||||
return (string) data_get(
|
|
||||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
|
||||||
'icon',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Collection
|
|
||||||
{
|
|
||||||
return $user->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
it('uses the documented recipient precedence for assignment reopen due soon and overdue', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$service = app(FindingNotificationService::class);
|
|
||||||
|
|
||||||
$assignee = User::factory()->create(['name' => 'Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
'due_at' => now()->addHours(6),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
|
|
||||||
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
expect(dispatchedFindingNotificationsFor($assignee)
|
|
||||||
->pluck('data.finding_event.event_type')
|
|
||||||
->all())
|
|
||||||
->toContain(AlertRule::EVENT_FINDINGS_ASSIGNED, AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
|
|
||||||
expect(dispatchedFindingNotificationsFor($owner)
|
|
||||||
->pluck('data.finding_event.event_type')
|
|
||||||
->all())
|
|
||||||
->toContain(AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
$assignedNotification = dispatchedFindingNotificationsFor($assignee)
|
|
||||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
$overdueNotification = dispatchedFindingNotificationsFor($owner)
|
|
||||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
expect($assignedNotification)->not->toBeNull()
|
|
||||||
->and(data_get($assignedNotification?->data, 'status'))->toBe('info')
|
|
||||||
->and(data_get($assignedNotification?->data, 'actions.0.target'))->toBe('finding_detail');
|
|
||||||
expect($overdueNotification)->not->toBeNull()
|
|
||||||
->and(data_get($overdueNotification?->data, 'status'))->toBe('danger')
|
|
||||||
->and(data_get($overdueNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('danger'))
|
|
||||||
->and(data_get($overdueNotification?->data, 'actions.0.target'))->toBe('finding_detail');
|
|
||||||
|
|
||||||
$fallbackFinding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_REOPENED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'due_at' => now()->addHours(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_REOPENED);
|
|
||||||
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
|
|
||||||
$ownerEventTypes = dispatchedFindingNotificationsFor($owner)
|
|
||||||
->pluck('data.finding_event.event_type')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
expect($ownerEventTypes)->toContain(AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses direct delivery when the preferred recipient loses tenant access', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Removed Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_REOPENED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('user_id', (int) $assignee->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
app(CapabilityResolver::class)->clearCache();
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('suppressed')
|
|
||||||
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
|
|
||||||
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not broaden delivery to the owner when the assignee is present but no longer entitled', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Current Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
'due_at' => now()->addHours(3),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$resolver = \Mockery::mock(CapabilityResolver::class);
|
|
||||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
||||||
$resolver->shouldReceive('can')
|
|
||||||
->andReturnUsing(function (User $user): bool {
|
|
||||||
return $user->name !== 'Current Assignee';
|
|
||||||
});
|
|
||||||
app()->instance(CapabilityResolver::class, $resolver);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('suppressed')
|
|
||||||
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
|
|
||||||
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses owner-only assignment edits and assignee clears from creating direct notifications', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$replacementOwner = User::factory()->create(['name' => 'Replacement Owner']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$workflow = app(FindingWorkflowService::class);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding,
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: (int) $assignee->getKey(),
|
|
||||||
ownerUserId: (int) $replacementOwner->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding->fresh(),
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: null,
|
|
||||||
ownerUserId: (int) $replacementOwner->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
|
|
||||||
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0)
|
|
||||||
->and(dispatchedFindingNotificationsFor($replacementOwner))->toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses due notifications for terminal findings', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->closed()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $owner->getKey(),
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('suppressed')
|
|
||||||
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends one direct notification when owner and assignee are the same entitled user', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $owner->getKey(),
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('sent')
|
|
||||||
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(1)
|
|
||||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.finding_event.recipient_reason'))->toBe('current_owner')
|
|
||||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.status'))->toBe('danger')
|
|
||||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.actions.0.label'))->toBe('Open finding');
|
|
||||||
});
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\TenantMembership;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
|
||||||
function spec230ExpectedNotificationIcon(string $status): string
|
|
||||||
{
|
|
||||||
return (string) data_get(
|
|
||||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
|
||||||
'icon',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('stores a filament payload with one tenant finding deep link and recipient reason copy', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$notification = $assignee->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($notification)->not->toBeNull()
|
|
||||||
->and(data_get($notification?->data, 'format'))->toBe('filament')
|
|
||||||
->and(data_get($notification?->data, 'title'))->toBe('Finding assigned')
|
|
||||||
->and(data_get($notification?->data, 'status'))->toBe('info')
|
|
||||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.label'))->toBe('Open finding')
|
|
||||||
->and(data_get($notification?->data, 'actions.0.url'))
|
|
||||||
->toBe(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('finding_detail')
|
|
||||||
->and(data_get($notification?->data, 'supporting_lines'))->toBe(['You are the new assignee.'])
|
|
||||||
->and(data_get($notification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
|
||||||
->and(data_get($notification?->data, 'finding_event.recipient_reason'))->toBe('new_assignee');
|
|
||||||
|
|
||||||
$this->actingAs($assignee);
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps due soon and overdue finding notifications onto the shared status and icon treatment', function (
|
|
||||||
string $eventType,
|
|
||||||
string $recipient,
|
|
||||||
string $expectedStatus,
|
|
||||||
string $findingStatus,
|
|
||||||
string $relativeDueAt,
|
|
||||||
): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Urgency Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => $findingStatus,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
'due_at' => now()->modify($relativeDueAt),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, $eventType);
|
|
||||||
|
|
||||||
$notifiable = $recipient === 'owner' ? $owner : $assignee;
|
|
||||||
$notification = $notifiable->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($notification)->not->toBeNull()
|
|
||||||
->and(data_get($notification?->data, 'status'))->toBe($expectedStatus)
|
|
||||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon($expectedStatus))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('finding_detail');
|
|
||||||
})->with([
|
|
||||||
'due soon' => [AlertRule::EVENT_FINDINGS_DUE_SOON, 'assignee', 'warning', Finding::STATUS_TRIAGED, '+6 hours'],
|
|
||||||
'overdue' => [AlertRule::EVENT_FINDINGS_OVERDUE, 'owner', 'danger', Finding::STATUS_IN_PROGRESS, '-2 hours'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
it('returns 404 when a finding notification link is opened after tenant access is removed', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Removed Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$url = data_get($assignee->notifications()->latest('id')->first(), 'data.actions.0.url');
|
|
||||||
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('user_id', (int) $assignee->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
app(CapabilityResolver::class)->clearCache();
|
|
||||||
|
|
||||||
$this->actingAs($assignee)
|
|
||||||
->get($url)
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 when a finding notification link is opened by an in-scope member without findings view capability', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Scoped Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$url = data_get($assignee->notifications()->latest('id')->first(), 'data.actions.0.url');
|
|
||||||
|
|
||||||
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false);
|
|
||||||
|
|
||||||
$this->actingAs($assignee);
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$this->get($url)->assertForbidden();
|
|
||||||
});
|
|
||||||
@ -1,26 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Notifications\OperationRunCompleted;
|
use App\Notifications\OperationRunCompleted;
|
||||||
use App\Notifications\OperationRunQueued;
|
use App\Notifications\OperationRunQueued;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
|
|
||||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
|
||||||
function spec230ExpectedNotificationIcon(string $status): string
|
|
||||||
{
|
|
||||||
return (string) data_get(
|
|
||||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
|
||||||
'icon',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('emits a queued notification after successful dispatch (initiator only) with view link', function () {
|
it('emits a queued notification after successful dispatch (initiator only) with view link', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
@ -52,16 +37,8 @@ function spec230ExpectedNotificationIcon(string $status): string
|
|||||||
$notification = $user->notifications()->latest('id')->first();
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
expect($notification)->not->toBeNull();
|
expect($notification)->not->toBeNull();
|
||||||
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
||||||
expect(data_get($notification->data, 'status'))->toBe('info');
|
|
||||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'));
|
|
||||||
expect(data_get($notification->data, 'actions'))->toHaveCount(1);
|
|
||||||
expect(data_get($notification->data, 'actions.0.label'))->toBe(OperationRunLinks::openLabel());
|
|
||||||
expect($notification->data['actions'][0]['url'] ?? null)
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
->toBe(OperationRunLinks::view($run, $tenant));
|
->toBe(OperationRunLinks::view($run, $tenant));
|
||||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('admin_operation_run');
|
|
||||||
expect(array_values(data_get($notification->data, 'supporting_lines', [])))->toBe([]);
|
|
||||||
|
|
||||||
$this->get(data_get($notification->data, 'actions.0.url'))->assertSuccessful();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not emit queued notifications for runs without an initiator', function () {
|
it('does not emit queued notifications for runs without an initiator', function () {
|
||||||
@ -111,36 +88,8 @@ function spec230ExpectedNotificationIcon(string $status): string
|
|||||||
$notification = $user->notifications()->latest('id')->first();
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
expect($notification)->not->toBeNull();
|
expect($notification)->not->toBeNull();
|
||||||
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
||||||
expect(data_get($notification->data, 'status'))->toBe('info');
|
|
||||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'));
|
|
||||||
expect($notification->data['actions'][0]['url'] ?? null)
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
->toBe(OperationRunLinks::tenantlessView($run));
|
->toBe(OperationRunLinks::tenantlessView($run));
|
||||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses a tenantless view link for queued tenantless runs', function () {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => null,
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'initiator_name' => $user->name,
|
|
||||||
'type' => 'provider.connection.check',
|
|
||||||
'status' => 'queued',
|
|
||||||
'outcome' => 'pending',
|
|
||||||
'context' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->notify(new OperationRunQueued($run));
|
|
||||||
|
|
||||||
$notification = $user->notifications()->latest('id')->first();
|
|
||||||
expect($notification)->not->toBeNull()
|
|
||||||
->and(data_get($notification?->data, 'actions.0.url'))->toBe(OperationRunLinks::tenantlessView($run))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
|
||||||
|
|
||||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits a terminal notification when an operation run transitions to completed', function () {
|
it('emits a terminal notification when an operation run transitions to completed', function () {
|
||||||
@ -182,15 +131,8 @@ function spec230ExpectedNotificationIcon(string $status): string
|
|||||||
expect($notification)->not->toBeNull();
|
expect($notification)->not->toBeNull();
|
||||||
expect($notification->data['body'] ?? null)->toContain('Completed successfully.');
|
expect($notification->data['body'] ?? null)->toContain('Completed successfully.');
|
||||||
expect($notification->data['body'] ?? null)->toContain('No action needed.');
|
expect($notification->data['body'] ?? null)->toContain('No action needed.');
|
||||||
expect(data_get($notification->data, 'status'))->toBe('success');
|
|
||||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('success'));
|
|
||||||
expect(data_get($notification->data, 'actions'))->toHaveCount(1);
|
|
||||||
expect(array_values(data_get($notification->data, 'supporting_lines', [])))->toContain('No action needed.', 'Total: 1');
|
|
||||||
expect($notification->data['actions'][0]['url'] ?? null)
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
->toBe(OperationRunLinks::view($run, $tenant));
|
->toBe(OperationRunLinks::view($run, $tenant));
|
||||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('admin_operation_run');
|
|
||||||
|
|
||||||
$this->get(data_get($notification->data, 'actions.0.url'))->assertSuccessful();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses a tenantless view link for completed tenantless runs', function () {
|
it('uses a tenantless view link for completed tenantless runs', function () {
|
||||||
@ -226,42 +168,7 @@ function spec230ExpectedNotificationIcon(string $status): string
|
|||||||
->and($notification->data['body'] ?? null)->toContain('Execution prerequisite changed')
|
->and($notification->data['body'] ?? null)->toContain('Execution prerequisite changed')
|
||||||
->and($notification->data['body'] ?? null)->toContain('queued execution prerequisites are no longer satisfied')
|
->and($notification->data['body'] ?? null)->toContain('queued execution prerequisites are no longer satisfied')
|
||||||
->and($notification->data['body'] ?? null)->not->toContain('execution_prerequisite_invalid')
|
->and($notification->data['body'] ?? null)->not->toContain('execution_prerequisite_invalid')
|
||||||
->and($notification->data['actions'][0]['url'] ?? null)->toBe(OperationRunLinks::tenantlessView($run))
|
->and($notification->data['actions'][0]['url'] ?? null)->toBe(OperationRunLinks::tenantlessView($run));
|
||||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
|
||||||
|
|
||||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the system operation route for completed notifications delivered to platform users', function (): void {
|
|
||||||
$platformUser = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::OPERATIONS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
|
||||||
'tenant_id' => null,
|
|
||||||
'type' => 'inventory_sync',
|
|
||||||
'status' => 'completed',
|
|
||||||
'outcome' => 'succeeded',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$platformUser->notify(new OperationRunCompleted($run));
|
|
||||||
|
|
||||||
$notification = $platformUser->notifications()->latest('id')->first();
|
|
||||||
|
|
||||||
expect($notification)->not->toBeNull()
|
|
||||||
->and(data_get($notification?->data, 'status'))->toBe('success')
|
|
||||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('success'))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.label'))->toBe(OperationRunLinks::openLabel())
|
|
||||||
->and(data_get($notification?->data, 'actions.0.url'))->toBe(SystemOperationRunLinks::view($run))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('system_operation_run');
|
|
||||||
|
|
||||||
$this->actingAs($platformUser, 'platform')
|
|
||||||
->get(SystemOperationRunLinks::view($run))
|
|
||||||
->assertSuccessful();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders partial backup-set update notifications with RBAC foundation summary counts', function () {
|
it('renders partial backup-set update notifications with RBAC foundation summary counts', function () {
|
||||||
|
|||||||
@ -1,229 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Notifications\OperationRunCompleted;
|
|
||||||
use App\Notifications\OperationRunQueued;
|
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
|
|
||||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
|
||||||
function spec230ExpectedNotificationIcon(string $status): string
|
|
||||||
{
|
|
||||||
return (string) data_get(
|
|
||||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
|
||||||
'icon',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! function_exists('spec230AssertSharedNotificationPayload')) {
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @param array{
|
|
||||||
* title: string,
|
|
||||||
* status: string,
|
|
||||||
* actionLabel: string,
|
|
||||||
* actionTarget: string,
|
|
||||||
* supportingLines: list<string>,
|
|
||||||
* primaryBody: string
|
|
||||||
* } $expected
|
|
||||||
*/
|
|
||||||
function spec230AssertSharedNotificationPayload(array $payload, array $expected): void
|
|
||||||
{
|
|
||||||
expect(data_get($payload, 'format'))->toBe('filament')
|
|
||||||
->and((string) data_get($payload, 'title'))->toBe($expected['title'])
|
|
||||||
->and((string) data_get($payload, 'body'))->toStartWith($expected['primaryBody'])
|
|
||||||
->and(data_get($payload, 'status'))->toBe($expected['status'])
|
|
||||||
->and(data_get($payload, 'icon'))->toBe(spec230ExpectedNotificationIcon($expected['status']))
|
|
||||||
->and(data_get($payload, 'actions', []))->toHaveCount(1)
|
|
||||||
->and(data_get($payload, 'actions.0.label'))->toBe($expected['actionLabel'])
|
|
||||||
->and(data_get($payload, 'actions.0.target'))->toBe($expected['actionTarget'])
|
|
||||||
->and(array_values(data_get($payload, 'supporting_lines', [])))->toBe($expected['supportingLines']);
|
|
||||||
|
|
||||||
foreach ($expected['supportingLines'] as $line) {
|
|
||||||
expect((string) data_get($payload, 'body'))->toContain($line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('enforces the shared database notification contract across finding queued and completed consumers', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = \App\Models\User::factory()->create(['name' => 'Shared Contract Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$this->actingAs($owner);
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'severity' => 'high',
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$queuedRun = app(OperationRunService::class)->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy.sync',
|
|
||||||
inputs: ['scope' => 'all'],
|
|
||||||
initiator: $owner,
|
|
||||||
);
|
|
||||||
|
|
||||||
app(OperationRunService::class)->dispatchOrFail($queuedRun, function (): void {
|
|
||||||
// no-op
|
|
||||||
}, emitQueuedNotification: true);
|
|
||||||
|
|
||||||
$completedRun = OperationRun::factory()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'user_id' => (int) $owner->getKey(),
|
|
||||||
'initiator_name' => $owner->name,
|
|
||||||
'type' => 'inventory_sync',
|
|
||||||
'status' => 'queued',
|
|
||||||
'outcome' => 'pending',
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(OperationRunService::class)->updateRun(
|
|
||||||
$completedRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'succeeded',
|
|
||||||
summaryCounts: ['total' => 1],
|
|
||||||
failures: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
$findingNotification = $assignee->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
$queuedNotification = $owner->notifications()
|
|
||||||
->where('type', OperationRunQueued::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
$completedNotification = $owner->notifications()
|
|
||||||
->where('type', OperationRunCompleted::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($findingNotification)->not->toBeNull();
|
|
||||||
expect($queuedNotification)->not->toBeNull();
|
|
||||||
expect($completedNotification)->not->toBeNull();
|
|
||||||
|
|
||||||
spec230AssertSharedNotificationPayload($findingNotification?->data ?? [], [
|
|
||||||
'title' => 'Finding assigned',
|
|
||||||
'primaryBody' => 'Finding #'.(int) $finding->getKey().' in '.$tenant->getFilamentName().' was assigned. High severity.',
|
|
||||||
'status' => 'info',
|
|
||||||
'actionLabel' => 'Open finding',
|
|
||||||
'actionTarget' => 'finding_detail',
|
|
||||||
'supportingLines' => ['You are the new assignee.'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
spec230AssertSharedNotificationPayload($queuedNotification?->data ?? [], [
|
|
||||||
'title' => 'Policy sync queued',
|
|
||||||
'primaryBody' => 'Queued for execution. Open the operation for progress and next steps.',
|
|
||||||
'status' => 'info',
|
|
||||||
'actionLabel' => 'Open operation',
|
|
||||||
'actionTarget' => 'admin_operation_run',
|
|
||||||
'supportingLines' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
spec230AssertSharedNotificationPayload($completedNotification?->data ?? [], [
|
|
||||||
'title' => 'Inventory sync completed successfully',
|
|
||||||
'primaryBody' => 'Completed successfully.',
|
|
||||||
'status' => 'success',
|
|
||||||
'actionLabel' => 'Open operation',
|
|
||||||
'actionTarget' => 'admin_operation_run',
|
|
||||||
'supportingLines' => ['No action needed.', 'Total: 1'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps exactly one primary action and preserves secondary metadata boundaries across in-scope consumers', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = \App\Models\User::factory()->create(['name' => 'Boundary Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$this->actingAs($owner);
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$tenantlessQueuedRun = OperationRun::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => null,
|
|
||||||
'user_id' => (int) $owner->getKey(),
|
|
||||||
'initiator_name' => $owner->name,
|
|
||||||
'type' => 'provider.connection.check',
|
|
||||||
'status' => 'queued',
|
|
||||||
'outcome' => 'pending',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$owner->notify(new OperationRunQueued($tenantlessQueuedRun));
|
|
||||||
|
|
||||||
$tenantlessCompletedRun = OperationRun::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => null,
|
|
||||||
'user_id' => (int) $owner->getKey(),
|
|
||||||
'initiator_name' => $owner->name,
|
|
||||||
'type' => 'provider.connection.check',
|
|
||||||
'status' => 'queued',
|
|
||||||
'outcome' => 'pending',
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(OperationRunService::class)->updateRun(
|
|
||||||
$tenantlessCompletedRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'blocked',
|
|
||||||
failures: [[
|
|
||||||
'code' => 'operation.blocked',
|
|
||||||
'reason_code' => 'execution_prerequisite_invalid',
|
|
||||||
'message' => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
|
|
||||||
$findingPayload = data_get(
|
|
||||||
$assignee->notifications()->where('type', FindingEventNotification::class)->latest('id')->first(),
|
|
||||||
'data',
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
$queuedPayload = data_get(
|
|
||||||
$owner->notifications()->where('type', OperationRunQueued::class)->latest('id')->first(),
|
|
||||||
'data',
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
$completedPayload = data_get(
|
|
||||||
$owner->notifications()->where('type', OperationRunCompleted::class)->latest('id')->first(),
|
|
||||||
'data',
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(data_get($findingPayload, 'actions', []))->toHaveCount(1)
|
|
||||||
->and(data_get($queuedPayload, 'actions', []))->toHaveCount(1)
|
|
||||||
->and(data_get($completedPayload, 'actions', []))->toHaveCount(1)
|
|
||||||
->and(data_get($findingPayload, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
|
||||||
->and(data_get($findingPayload, 'reason_translation'))->toBeNull()
|
|
||||||
->and(data_get($queuedPayload, 'finding_event'))->toBeNull()
|
|
||||||
->and(data_get($queuedPayload, 'reason_translation'))->toBeNull()
|
|
||||||
->and(data_get($completedPayload, 'finding_event'))->toBeNull()
|
|
||||||
->and(data_get($completedPayload, 'reason_translation.operator_label'))->toBe('Execution prerequisite changed')
|
|
||||||
->and(data_get($completedPayload, 'actions.0.target'))->toBe('tenantless_operation_run')
|
|
||||||
->and(array_values(data_get($queuedPayload, 'supporting_lines', [])))->toBe([])
|
|
||||||
->and(array_values(data_get($completedPayload, 'supporting_lines', [])))->toContain('Execution prerequisite changed');
|
|
||||||
});
|
|
||||||
@ -52,67 +52,3 @@
|
|||||||
|
|
||||||
expect($violations)->toBe([]);
|
expect($violations)->toBe([]);
|
||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('keeps in-scope database notifications routed through the shared presenter seam', function (): void {
|
|
||||||
$root = SourceFileScanner::projectRoot();
|
|
||||||
$files = [
|
|
||||||
$root.'/app/Notifications/Findings/FindingEventNotification.php',
|
|
||||||
$root.'/app/Notifications/OperationRunQueued.php',
|
|
||||||
$root.'/app/Notifications/OperationRunCompleted.php',
|
|
||||||
];
|
|
||||||
$needles = [
|
|
||||||
'FilamentNotification::make(',
|
|
||||||
'->getDatabaseMessage(',
|
|
||||||
];
|
|
||||||
$violations = [];
|
|
||||||
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$source = SourceFileScanner::read($file);
|
|
||||||
|
|
||||||
foreach ($needles as $needle) {
|
|
||||||
if (! str_contains($source, $needle)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$offset = 0;
|
|
||||||
|
|
||||||
while (($position = strpos($source, $needle, $offset)) !== false) {
|
|
||||||
$line = substr_count(substr($source, 0, $position), "\n") + 1;
|
|
||||||
|
|
||||||
$violations[] = [
|
|
||||||
'file' => SourceFileScanner::relativePath($file),
|
|
||||||
'line' => $line,
|
|
||||||
'snippet' => SourceFileScanner::snippet($source, $line),
|
|
||||||
];
|
|
||||||
|
|
||||||
$offset = $position + strlen($needle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($violations !== []) {
|
|
||||||
$messages = array_map(static function (array $violation): string {
|
|
||||||
return sprintf(
|
|
||||||
"%s:%d\n%s",
|
|
||||||
$violation['file'],
|
|
||||||
$violation['line'],
|
|
||||||
$violation['snippet'],
|
|
||||||
);
|
|
||||||
}, $violations);
|
|
||||||
|
|
||||||
$this->fail(
|
|
||||||
"Local database-notification payload composition found in in-scope consumers:\n\n".implode("\n\n", $messages)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect($violations)->toBe([]);
|
|
||||||
})->group('ops-ux');
|
|
||||||
|
|
||||||
it('keeps alert email delivery outside the shared database notification contract boundary', function (): void {
|
|
||||||
$source = SourceFileScanner::read(
|
|
||||||
SourceFileScanner::projectRoot().'/app/Notifications/Alerts/EmailAlertNotification.php'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($source)->not->toContain('OperationUxPresenter')
|
|
||||||
->and($source)->not->toContain('FilamentNotification');
|
|
||||||
})->group('ops-ux');
|
|
||||||
|
|||||||
@ -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-22
|
**Last updated**: 2026-04-20
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -70,17 +70,6 @@ ### 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)
|
||||||
@ -144,23 +133,16 @@ ### 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. 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.
|
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.
|
||||||
**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**: Canonical Control Catalog Foundation, StoredReports / EvidenceItems foundation, Tenant Review runs, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity.
|
**Depends on**: 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 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.
|
**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.
|
||||||
|
|
||||||
**Layering**:
|
- Separate framework source versions, TenantPilot interpretation versions, and customer/MSP profile versions
|
||||||
- **S1**: framework-neutral Canonical Control Catalog plus TenantPilot technical interpretations as the normative control core
|
- Map controls to evidence sources, evaluation rules, and manual attestations when automation is partial
|
||||||
- **S2**: CIS Baseline Library as a template and library layer built on top of the canonical catalog, not a separate control object model
|
- Keep BSI / NIS2 / CIS views as reporting layers on top of the shared control 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
|
||||||
|
|||||||
@ -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, promoted `Findings Notification Presentation Convergence` to Spec 230, 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)
|
**Last reviewed**: 2026-04-21 (added `My Work` candidate family and aligned it with existing promoted work)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -46,8 +46,6 @@ ## Promoted to Spec
|
|||||||
- Humanized Diagnostic Summaries for Governance Operations → Spec 220 (`governance-run-summaries`)
|
- Humanized Diagnostic Summaries for Governance Operations → Spec 220 (`governance-run-summaries`)
|
||||||
- Findings Operator Inbox v1 → Spec 221 (`findings-operator-inbox`)
|
- Findings Operator Inbox v1 → Spec 221 (`findings-operator-inbox`)
|
||||||
- Findings Intake & Team Queue v1 -> Spec 222 (`findings-intake-team-queue`)
|
- Findings Intake & Team Queue v1 -> Spec 222 (`findings-intake-team-queue`)
|
||||||
- Findings Notifications & Escalation v1 → Spec 224 (`findings-notifications-escalation`)
|
|
||||||
- Findings Notification Presentation Convergence → Spec 230 (`findings-notification-convergence`)
|
|
||||||
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
||||||
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
||||||
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
||||||
@ -223,75 +221,6 @@ ### 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
|
||||||
@ -420,18 +349,30 @@ ### 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 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 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 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), 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)
|
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Tenant App Status False-Truth Removal (quick win that removes the most obvious legacy truth — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
|
||||||
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), 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
|
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Tenant App Status False-Truth Removal, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
|
||||||
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after 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.
|
- **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.
|
||||||
- **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.
|
||||||
|
|
||||||
|
### Findings Notifications & Escalation v1
|
||||||
|
- **Type**: alerts / workflow execution
|
||||||
|
- **Source**: findings execution layer candidate pack 2026-04-17; gap between assignment metadata and actionable control loop
|
||||||
|
- **Problem**: Assignment, reopen, due, and overdue states currently risk becoming silent metadata unless operators keep polling findings views.
|
||||||
|
- **Why it matters**: Due dates without reminders or escalation are visibility, not control. Existing alert foundations only create operator value if findings workflow emits actionable events.
|
||||||
|
- **Proposed direction**: Add notifications for assignment, system-driven reopen, due-soon, and overdue states; introduce minimal escalation to owner or a defined role; explicitly consume the existing alert and notification infrastructure rather than building a findings-specific delivery system.
|
||||||
|
- **Explicit non-goals**: Multi-stage escalation chains, a large notification-preference center, and bidirectional ticket synchronization.
|
||||||
|
- **Dependencies**: Ownership semantics, operator inbox/intake surfaces, due/SLA logic, alert plumbing.
|
||||||
|
- **Roadmap fit**: Findings workflow hardening on top of the existing alerting foundation.
|
||||||
|
- **Strategic sequencing**: After inbox and intake exist so notifications land on meaningful destinations.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
### Assignment Hygiene & Stale Work Detection
|
### Assignment Hygiene & Stale Work Detection
|
||||||
- **Type**: workflow hardening / operations hygiene
|
- **Type**: workflow hardening / operations hygiene
|
||||||
- **Source**: findings execution layer candidate pack 2026-04-17; assignment lifecycle hygiene gap analysis
|
- **Source**: findings execution layer candidate pack 2026-04-17; assignment lifecycle hygiene gap analysis
|
||||||
@ -489,105 +430,58 @@ ### Cross-Tenant Findings Workboard v1
|
|||||||
- **Roadmap fit**: MSP portfolio and operations.
|
- **Roadmap fit**: MSP portfolio and operations.
|
||||||
- **Priority**: medium-low
|
- **Priority**: medium-low
|
||||||
|
|
||||||
### Canonical Control Catalog Foundation
|
### Compliance Control Catalog & Interpretation Foundation
|
||||||
- **Type**: foundation
|
- **Type**: foundation
|
||||||
- **Source**: governance-engine gap analysis 2026-04-22, roadmap/principles alignment 2026-04-10, compliance modeling discussion, future framework-oriented readiness planning
|
- **Source**: roadmap/principles alignment 2026-04-10, compliance modeling discussion, future framework-oriented readiness planning
|
||||||
- **Vehicle**: new standalone candidate
|
- **Vehicle**: new standalone candidate
|
||||||
- **Layer position**: **S1** — normative control core
|
- **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.
|
||||||
- **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 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.
|
||||||
- **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 framework-neutral canonical control catalog centered on control themes and objectives rather than framework clauses or raw Microsoft API objects
|
- 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
|
||||||
- Define canonical domains and subdomains plus stable product-wide control keys that outlive individual APIs, workloads, or framework versions
|
- Separate three independent version layers: framework source version, TenantPilot interpretation version, and customer/MSP profile version
|
||||||
- Classify each control by control class, detectability class, evaluation strategy, evidence archetypes, and artifact suitability for baseline, drift, findings, exceptions, reports, and evidence packs
|
- Allow each control to declare automation posture such as fully automatable, partially automatable, manual-attestation-required, or outside-product-scope
|
||||||
- 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
|
- Map one control to multiple evidence sources and evaluation rules, and allow one evidence source to support multiple controls
|
||||||
- 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
|
- Treat risk exceptions, findings, stored reports, and readiness views as downstream consumers of the control model rather than the place where framework logic lives
|
||||||
- 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
|
- 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
|
||||||
- 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
|
- Start with lightweight, product-owned control metadata and interpretation summaries rather than assuming full storage of normative framework text inside the product
|
||||||
- **Scope boundaries**:
|
- **Scope boundaries**:
|
||||||
- **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
|
- **In scope**: framework registry/version model, control catalog foundation, interpretation/profile/override model, evidence and evaluation mapping model, manual attestation linkage, framework-pack import and diff lifecycle, bounded admin/registry surfaces where required to manage activation state and profile variants
|
||||||
- **Out of scope**: 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
|
- **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
|
||||||
- **Explicit non-goals**:
|
- **Explicit non-goals**:
|
||||||
- Not a certification engine or legal interpretation layer
|
- Not a certification engine or legal interpretation layer
|
||||||
- Not a framework-first registry where the same control is duplicated once per standard
|
- Not a hardcoded per-framework report generator
|
||||||
- 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 directly technically evaluable; indirect, attested, and external-evidence-only controls remain first-class
|
- Not a promise that every control becomes fully automatable; manual attestation remains a first-class path
|
||||||
- **Acceptance points**:
|
- **Acceptance points**:
|
||||||
- The platform can represent canonical domains, subdomains, and controls with stable keys independent of framework source versions
|
- 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
|
||||||
- Every seed control declares control class, detectability class, evaluation strategy, and at least one evidence archetype
|
- A single control can map to multiple evidence requirements and evaluation rules, with optional manual attestation where automation is incomplete
|
||||||
- Every seed control can declare whether it is baseline-capable, drift-capable, finding-capable, exception-capable, and report or evidence-pack-capable
|
- The model can express whether a control is fully automatable, partially automatable, manual-only, or outside current product scope
|
||||||
- The model can bind one canonical control to multiple Microsoft subject families or signal sources without redefining the control per workload
|
- A framework-pack update can preview new, changed, and retired controls before activation
|
||||||
- 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
|
- Framework-oriented readiness/reporting work can consume the shared model without introducing hardcoded BSI/NIS2/CIS rule paths in presentation features
|
||||||
- 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 Evidence Domain Foundation**: Evidence Domain Foundation owns evidence capture, storage, completeness, and immutable artifacts. This candidate owns the normative control model, product interpretation layer, and mapping from controls to those evidence artifacts.
|
||||||
- **Boundary with Spec 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 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 Evidence Domain Foundation**: Evidence Domain Foundation owns evidence capture, completeness, freshness, and immutable artifacts. This candidate owns the canonical control definitions those evidence artifacts can support and the detectability and evaluation semantics that explain what the evidence means.
|
- **Boundary with 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 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.
|
- **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 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.
|
- **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 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.
|
- **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.
|
||||||
- **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
|
- **Priority**: medium
|
||||||
- **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 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.
|
- **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.
|
||||||
- **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, 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)
|
- 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)
|
||||||
- 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.
|
||||||
- **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.
|
- **Dependencies**: Evidence Domain Foundation (data layer), review pack export (Spec 109), findings workflow (Spec 111), baseline/drift engine (Specs 116–119), permission posture (Specs 104/105), audit log foundation (Spec 134)
|
||||||
- **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 116–119), 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
|
||||||
@ -1093,63 +987,32 @@ ### 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)
|
||||||
|
|
||||||
> Repository cleanup strand from the strict read-only legacy audit 2026-04-22:
|
### Tenant App Status False-Truth Removal
|
||||||
> 1. **Dead Transitional Residue Cleanup**
|
- **Type**: hardening
|
||||||
> 2. **Onboarding State Fallback Retirement**
|
- **Source**: legacy / orphaned truth audit 2026-03-16
|
||||||
> 3. **Canonical Operation Type Source of Truth**
|
- **Classification**: quick removal
|
||||||
>
|
- **Problem**: `Tenant.app_status` is displayed in tenant UI as current operational truth even though production code no longer writes it. Operators can see a frozen "OK" or other stale badge that does not reflect the real provider connection state.
|
||||||
> 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.
|
- **Why it matters**: This is misleading operator-facing truth, not just dead schema. It creates false confidence on a tier-1 admin surface.
|
||||||
|
- **Target model**: `Tenant`
|
||||||
### Dead Transitional Residue Cleanup
|
- **Canonical source of truth**: `ProviderConnection.consent_status` and `ProviderConnection.verification_status`
|
||||||
- **Type**: hardening / cleanup
|
- **Must stop being read**: `Tenant.app_status` in `TenantResource` table columns, infolist/details, filters, and badge-domain mapping.
|
||||||
- **Source**: strict read-only legacy / compatibility audit 2026-04-22; orphaned-truth residue review
|
- **Can be removed immediately**:
|
||||||
- **Absorbs / broadens**: the earlier `Tenant App Status False-Truth Removal` slice plus adjacent dead-symbol cleanup
|
- TenantResource reads of `app_status`
|
||||||
- **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.
|
- tenant app-status badge domain / badge mapping usage
|
||||||
- **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.
|
- factory defaults that seed `app_status`
|
||||||
- **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.
|
- **Remove only after cutover**:
|
||||||
- **In scope**:
|
- the `tenants.app_status` column itself, once all UI/report/export reads are confirmed gone
|
||||||
- remove unused deprecated `BaselineProfile::STATUS_*` constants
|
- **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 orphaned tenant app-status badge, factory, fixture, and test residue
|
- **UI / resource / policy / test impact**:
|
||||||
- verify that no hidden runtime, UI, filter, cast, or API dependency still exists before removal
|
- UI/resources: remove misleading badge and filter from tenant surfaces
|
||||||
- document the remaining active domain language after cleanup
|
- Policy: none
|
||||||
- **Out of scope**: operation-type dual semantics, onboarding state fallbacks, provider identity or migration review, Baseline Scope V2, and spec-backed legacy redirect paths.
|
- Tests: update `TenantFactory`, remove assertions that treat `app_status` as live truth
|
||||||
- **Key requirements**:
|
- **Scope boundaries**:
|
||||||
- dead deprecated constants must be removed when no productive reference remains
|
- In scope: remove stale tenant app-status reads and schema field
|
||||||
- orphaned badge, status, factory, and fixture residue must not survive as silent compatibility lore
|
- Out of scope: provider connection UX redesign, credential migration, broader tenant health redesign
|
||||||
- cleanup must include tests and fixtures in the same change
|
- **Dependencies**: None required if the immediate operator-facing action is removal rather than replacement with a new tenant-level derived badge.
|
||||||
- removal must prove there is no hidden runtime, UI, filter, cast, or API dependency
|
- **Risks**: Low rollout risk. Main risk is short-term operator confusion about where to view connection health after removal.
|
||||||
- the remaining canonical domain language must be clearer after cleanup
|
- **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.
|
||||||
- **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
|
||||||
|
|||||||
199
specs/001-finding-ownership-semantics/plan.md
Normal file
199
specs/001-finding-ownership-semantics/plan.md
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
# Implementation Plan: Finding Ownership Semantics Clarification
|
||||||
|
|
||||||
|
**Branch**: `001-finding-ownership-semantics` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/001-finding-ownership-semantics/spec.md`
|
||||||
|
|
||||||
|
**Note**: The setup script reported a numeric-prefix collision with `001-rbac-onboarding`, but it still resolved the active branch and plan path correctly to this feature directory. Planning continues against the current branch path.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Clarify the meaning of finding owner versus finding assignee across the existing tenant findings list, detail surface, responsibility-update flows, and exception-request context without adding new persistence, capabilities, or workflow services. The implementation will reuse the existing `owner_user_id` and `assignee_user_id` fields, add a derived responsibility-state presentation layer on top of current data, tighten operator-facing copy and audit/feedback wording, preserve tenant-safe Filament behavior, and extend focused Pest + Livewire coverage for list/detail semantics, responsibility updates, and exception-owner boundary cases.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15 / Laravel 12
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
|
||||||
|
**Storage**: PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned
|
||||||
|
**Testing**: Pest v4 feature and Livewire component tests via `./vendor/bin/sail artisan test --compact`
|
||||||
|
**Validation Lanes**: fast-feedback, confidence
|
||||||
|
**Target Platform**: Laravel monolith in Sail/Docker locally; Dokploy-hosted Linux deployment for staging/production
|
||||||
|
**Project Type**: Laravel monolith / Filament admin application
|
||||||
|
**Performance Goals**: No new remote calls, no new queued work, no material list/detail query growth beyond current eager loading of owner, assignee, and exception-owner relations
|
||||||
|
**Constraints**: Keep responsibility state derived rather than persisted; preserve existing tenant membership validation; preserve deny-as-not-found tenant isolation; do not split capabilities or add new abstractions; keep destructive-action confirmations unchanged
|
||||||
|
**Scale/Scope**: 1 primary Filament resource, 1 model helper or equivalent derived-state mapping, 1 workflow/audit wording touchpoint, 1 exception-owner wording boundary, and 2 focused new/expanded feature test families
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: native
|
||||||
|
- **Shared-family relevance**: existing tenant findings resource and exception-context surfaces only
|
||||||
|
- **State layers in scope**: page, detail, URL-query
|
||||||
|
- **Handling modes by drift class or surface**: review-mandatory
|
||||||
|
- **Repository-signal treatment**: review-mandatory
|
||||||
|
- **Special surface test profiles**: standard-native-filament
|
||||||
|
- **Required tests or manual smoke**: functional-core, state-contract
|
||||||
|
- **Exception path and spread control**: none; the feature reuses existing resource/table/infolist/action primitives and keeps exception-owner semantics local to the finding context
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first: PASS. The feature only clarifies operator semantics on tenant-owned findings and exception artifacts; it does not change observed-state or snapshot truth.
|
||||||
|
- Read/write separation: PASS. Responsibility updates remain TenantPilot-only writes on existing finding records; no Microsoft tenant mutation is introduced. Existing destructive-like actions keep their current confirmation rules.
|
||||||
|
- Graph contract path: PASS / N/A. No Graph calls or contract-registry changes are involved.
|
||||||
|
- Deterministic capabilities: PASS. Existing canonical findings capabilities remain the source of truth; no new capability split is introduced.
|
||||||
|
- RBAC-UX: PASS. Tenant-context membership remains the isolation boundary. Non-members remain 404 and in-scope members missing `TENANT_FINDINGS_ASSIGN` remain 403 for responsibility mutations.
|
||||||
|
- Workspace isolation: PASS. The feature remains inside tenant-context findings flows and preserves existing workspace-context stabilization patterns.
|
||||||
|
- Global search: PASS. `FindingResource` already has a `view` page, so any existing global-search participation remains compliant. This feature does not add or remove global search.
|
||||||
|
- Tenant isolation: PASS. All reads and writes remain tenant-scoped and continue to restrict owner/assignee selections to current tenant members.
|
||||||
|
- Run observability / Ops-UX: PASS / N/A. No new long-running, queued, or remote work is introduced and no `OperationRun` behavior changes.
|
||||||
|
- Automation / data minimization: PASS / N/A. No new background automation or payload persistence is introduced.
|
||||||
|
- Test governance (TEST-GOV-001): PASS. The narrowest proving surface is feature-level Filament + workflow coverage with low fixture cost and no heavy-family expansion.
|
||||||
|
- Proportionality / no premature abstraction / persisted truth / behavioral state: PASS. Responsibility state remains derived from existing fields and does not create a persisted enum, new abstraction, or new table.
|
||||||
|
- UI semantics / few layers: PASS. The plan uses direct domain-to-UI mapping on `FindingResource` and an optional local helper on `Finding` rather than a new presenter or taxonomy layer.
|
||||||
|
- Badge semantics (BADGE-001): PASS with restraint. If a new responsibility badge or label is added, it stays local to findings semantics unless multiple consumers later prove centralization is necessary.
|
||||||
|
- Filament-native UI / action surface contract / UX-001: PASS. Existing native Filament tables, infolists, filters, selects, grouped actions, and modals remain the implementation path; the finding remains the sole primary inspect/open model and row click remains the canonical inspect affordance.
|
||||||
|
|
||||||
|
**Post-Phase-1 re-check**: PASS. The design keeps responsibility semantics derived, tenant-safe, and local to the existing findings resource and tests.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Feature
|
||||||
|
- **Affected validation lanes**: fast-feedback, confidence
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: The business truth is visible in Filament list/detail rendering, responsibility action behavior, and tenant-scoped audit feedback. Unit-only testing would miss the operator-facing semantics, while browser/heavy-governance coverage would add unnecessary cost.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: Low. Existing tenant/user/finding factories and tenant membership helpers are sufficient. The only explicit context risk is tenant-panel routing and admin canonical tenant state.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; tests should keep tenant context explicit rather than broadening shared fixtures.
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: standard-native relief; explicit tenant-panel routing is required for authorization assertions.
|
||||||
|
- **Closing validation and reviewer handoff**: Re-run the two focused test files plus formatter on dirty files. Reviewers should verify owner versus assignee wording on list/detail surfaces, exception-owner separation, and 404/403 semantics for out-of-scope versus in-scope unauthorized users.
|
||||||
|
- **Budget / baseline / trend follow-up**: none
|
||||||
|
- **Review-stop questions**: lane fit, hidden fixture cost, accidental presenter growth, tenant-context drift in tests
|
||||||
|
- **Escalation path**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Why no dedicated follow-up spec is needed**: This work stays inside the existing findings responsibility contract. Only future queue/team-routing or capability-split work would justify a separate spec.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/001-finding-ownership-semantics/
|
||||||
|
├── plan.md
|
||||||
|
├── spec.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── finding-responsibility.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ └── Resources/
|
||||||
|
│ │ └── FindingResource.php # MODIFY: list/detail labels, derived responsibility state, filters, action help text, exception-owner wording
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ └── Finding.php # MODIFY: local derived responsibility-state helper if needed
|
||||||
|
│ └── Services/
|
||||||
|
│ └── Findings/
|
||||||
|
│ ├── FindingWorkflowService.php # MODIFY: mutation feedback / audit wording for owner-only vs assignee-only changes
|
||||||
|
│ ├── FindingExceptionService.php # MODIFY: request-exception wording if the exception-owner boundary needs alignment
|
||||||
|
│ └── FindingRiskGovernanceResolver.php # MODIFY: next-action copy to reflect orphaned-accountability semantics
|
||||||
|
└── tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Filament/
|
||||||
|
│ └── Resources/
|
||||||
|
│ └── FindingResourceOwnershipSemanticsTest.php # NEW: list/detail rendering, filters, exception-owner distinction, tenant-safe semantics
|
||||||
|
└── Findings/
|
||||||
|
├── FindingAssignmentAuditSemanticsTest.php # NEW: owner-only, assignee-only, combined update feedback/audit semantics
|
||||||
|
├── FindingWorkflowRowActionsTest.php # MODIFY: assignment form/help-text semantics and member validation coverage
|
||||||
|
└── FindingWorkflowServiceTest.php # MODIFY: audit metadata and responsibility-mutation expectations
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep all work inside the existing Laravel/Filament monolith. The implementation is a targeted semantics pass over the current findings resource and workflow tests; no new folders, packages, or service families are required.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| — | — | — |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Operators cannot tell whether a finding change transferred accountability, active remediation work, or only exception ownership.
|
||||||
|
- **Existing structure is insufficient because**: The existing fields and actions exist, but the current UI copy and derived next-step language do not establish a stable contract across list, detail, and exception flows.
|
||||||
|
- **Narrowest correct implementation**: Reuse the existing `owner_user_id` and `assignee_user_id` fields, derive responsibility state from them, and tighten wording on existing Filament surfaces and audit feedback.
|
||||||
|
- **Ownership cost created**: Low ongoing UI/test maintenance to keep future findings work aligned with the clarified contract.
|
||||||
|
- **Alternative intentionally rejected**: A new ownership framework, queue model, or capability split was rejected because the current product has not yet exhausted the simpler owner-versus-assignee model.
|
||||||
|
- **Release truth**: Current-release truth
|
||||||
|
|
||||||
|
## Phase 0 — Research (output: `research.md`)
|
||||||
|
|
||||||
|
See: [research.md](./research.md)
|
||||||
|
|
||||||
|
Research goals:
|
||||||
|
- Confirm the existing source of truth for owner, assignee, and exception owner.
|
||||||
|
- Confirm the smallest derived responsibility-state model that fits the current schema.
|
||||||
|
- Confirm the existing findings tests and Filament routing pitfalls to avoid false negatives.
|
||||||
|
- Confirm which operator-facing wording changes belong in resource copy versus workflow service feedback.
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||||
|
|
||||||
|
See:
|
||||||
|
- [data-model.md](./data-model.md)
|
||||||
|
- [contracts/finding-responsibility.openapi.yaml](./contracts/finding-responsibility.openapi.yaml)
|
||||||
|
- [quickstart.md](./quickstart.md)
|
||||||
|
|
||||||
|
Design focus:
|
||||||
|
- Keep responsibility truth on existing finding and finding-exception records.
|
||||||
|
- Model responsibility state as a derived projection over owner and assignee presence rather than a persisted enum.
|
||||||
|
- Preserve exception owner as a separate governance concept when shown from a finding context.
|
||||||
|
- Keep tenant membership validation and existing `FindingWorkflowService::assign()` semantics as the mutation boundary.
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||||
|
|
||||||
|
### Surface semantics pass
|
||||||
|
- Update the findings list column hierarchy so owner and assignee meaning is explicit at first scan.
|
||||||
|
- Add a derived responsibility-state label or equivalent summary on list/detail surfaces.
|
||||||
|
- Keep exception owner visibly separate from finding owner wherever both appear.
|
||||||
|
|
||||||
|
### Responsibility mutation clarity
|
||||||
|
- Add owner/assignee help text to assignment flows.
|
||||||
|
- Differentiate owner-only, assignee-only, and combined responsibility changes in operator feedback and audit-facing wording.
|
||||||
|
- Keep current tenant-member validation and open-finding restrictions unchanged.
|
||||||
|
|
||||||
|
### Personal-work and next-action alignment
|
||||||
|
- Add or refine personal-work filters so assignee-based work and owner-based accountability are explicitly separate.
|
||||||
|
- Update next-action copy for owner-missing states so assignee-only findings are treated as accountability gaps.
|
||||||
|
|
||||||
|
### Regression protection
|
||||||
|
- Add focused list/detail rendering tests for owner-only, assignee-only, both-set, same-user, and both-null states.
|
||||||
|
- Add focused responsibility-update tests for owner-only, assignee-only, and combined changes.
|
||||||
|
- Preserve tenant-context and authorization regression coverage using explicit Filament panel routing where needed.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Run the two focused Pest files and any directly modified sibling findings tests.
|
||||||
|
- Run Pint on dirty files through Sail.
|
||||||
|
|
||||||
|
## Constitution Check (Post-Design)
|
||||||
|
|
||||||
|
Re-check result: PASS. The design stays inside the existing findings domain, preserves tenant isolation and capability enforcement, avoids new persisted truth or semantic framework growth, and keeps responsibility state derived from current fields.
|
||||||
|
|
||||||
|
## Filament v5 Agent Output Contract
|
||||||
|
|
||||||
|
1. **Livewire v4.0+ compliance**: Yes. The feature only adjusts existing Filament v5 resources/pages/actions that already run on Livewire v4.0+.
|
||||||
|
2. **Provider registration location**: No new panel or service providers are needed. Existing Filament providers remain registered in `apps/platform/bootstrap/providers.php`.
|
||||||
|
3. **Global search**: `FindingResource` already has a `view` page via `getPages()`, so any existing global-search participation remains compliant. This feature does not enable new global search.
|
||||||
|
4. **Destructive actions and authorization**: No new destructive actions are introduced. Existing destructive-like findings actions remain server-authorized and keep `->requiresConfirmation()` where already required. Responsibility updates continue to enforce tenant membership and the canonical findings capability registry.
|
||||||
|
5. **Asset strategy**: No new frontend assets or published views. The feature uses existing Filament tables, infolists, filters, and action modals, so deployment asset handling stays unchanged and no new `filament:assets` step is added.
|
||||||
|
6. **Testing plan**: Cover the change with focused Pest feature tests for findings resource responsibility semantics, assignment/audit wording, and existing workflow regression surfaces. No browser or heavy-governance expansion is planned.
|
||||||
204
specs/001-finding-ownership-semantics/spec.md
Normal file
204
specs/001-finding-ownership-semantics/spec.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# Feature Specification: Finding Ownership Semantics Clarification
|
||||||
|
|
||||||
|
**Feature Branch**: `001-finding-ownership-semantics`
|
||||||
|
**Created**: 2026-04-20
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Finding Ownership Semantics Clarification"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Open findings already store both an owner and an assignee, but the product does not yet state clearly which field represents accountability and which field represents active execution.
|
||||||
|
- **Today's failure**: Operators can see or change responsibility on a finding without being sure whether they transferred accountability, execution work, or only exception ownership, which makes triage slower and audit language less trustworthy.
|
||||||
|
- **User-visible improvement**: Findings surfaces make accountable owner, active assignee, and exception owner visibly distinct, so operators can route work faster and read ownership changes honestly.
|
||||||
|
- **Smallest enterprise-capable version**: Clarify the existing responsibility contract on current findings list/detail/action surfaces, add explicit derived responsibility states, and align audit and filter language without creating new roles, queues, or persistence.
|
||||||
|
- **Explicit non-goals**: No team queues, no escalation engine, no automatic reassignment hygiene, no new capability split, no new ownership framework, and no mandatory backfill before rollout.
|
||||||
|
- **Permanent complexity imported**: One explicit product contract for finding owner versus assignee, one derived responsibility-state vocabulary, and focused acceptance tests for mixed ownership contexts.
|
||||||
|
- **Why now**: The next findings execution slices depend on honest ownership semantics, and the ambiguity already affects triage, reassignment, and exception routing in today’s tenant workflow.
|
||||||
|
- **Why not local**: A one-surface copy fix would leave contradictory meaning across the findings list, detail view, assignment flows, personal work cues, and exception-request language.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: None after scope cut. This spec clarifies existing fields instead of introducing a new semantics axis.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant
|
||||||
|
- **Primary Routes**: `/admin/t/{tenant}/findings`, `/admin/t/{tenant}/findings/{finding}`
|
||||||
|
- **Data Ownership**: Tenant-owned findings remain the source of truth; this spec also covers exception ownership only when it is shown or edited from a finding-context surface.
|
||||||
|
- **RBAC**: Tenant membership is required for visibility. Tenant findings view permission gates read access. Tenant findings assign permission gates owner and assignee changes. Non-members or cross-tenant requests remain deny-as-not-found. Members without mutation permission receive an explicit authorization failure.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list and grouped bulk actions | yes | Native Filament + existing UI enforcement helpers | Same tenant work-queue family as other tenant resources | table, bulk-action modal, notification copy | no | Clarifies an existing resource instead of adding a new page |
|
||||||
|
| Finding detail and single-record action modals | yes | Native Filament infolist + action modals | Same finding resource family | detail, action modal, helper copy | no | Keeps one decision flow inside the current resource |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list and grouped bulk actions | Primary Decision Surface | A tenant operator reviews the open backlog and decides who owns the outcome and who should act next | Severity, lifecycle status, due state, owner, assignee, and derived responsibility state | Full evidence, audit trail, run metadata, and exception details | Primary because it is the queue where responsibility is routed at scale | Follows the operator’s “what needs action now?” workflow instead of raw data lineage | Removes the need to open each record just to tell who is accountable versus merely assigned |
|
||||||
|
| Finding detail and single-record action modals | Secondary Context Surface | A tenant operator verifies one finding before reassigning work or requesting an exception | Finding summary, current owner, current assignee, exception owner when applicable, and next workflow action | Raw evidence, historical context, and additional governance details | Secondary because it resolves one case after the list has already identified the work item | Preserves the single-record decision loop without creating a separate ownership page | Avoids cross-page reconstruction when ownership and exception context must be compared together |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list and grouped bulk actions | List / Table / Bulk | Workflow queue / list-first resource | Open a finding or update responsibility | Finding | required | Structured `More` action group and grouped bulk actions | Existing dangerous actions remain inside grouped actions with confirmation where already required | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant context, filters, severity, due state, workflow state | Findings / Finding | Accountable owner, active assignee, and whether the finding is assigned, owned-but-unassigned, or orphaned | none |
|
||||||
|
| Finding detail and single-record action modals | Record / Detail / Actions | View-first operational detail | Confirm or update responsibility for one finding | Finding | N/A - detail surface | Structured header actions on the existing record page | Existing dangerous actions remain grouped and confirmed | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant breadcrumb, lifecycle state, due state, governance context | Findings / Finding | Finding owner, finding assignee, and exception owner stay visibly distinct in the same context | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list and grouped bulk actions | Tenant operator or tenant manager | Route responsibility on open findings and separate accountable ownership from active work | Workflow queue | Who owns this outcome, who is doing the work, and what needs reassignment now? | Severity, lifecycle state, due state, owner, assignee, derived responsibility state, and personal-work cues | Raw evidence, run identifiers, historical audit details | lifecycle, severity, governance validity, responsibility state | TenantPilot only | Open finding, Assign, Triage, Start progress | Resolve, Close, Request exception |
|
||||||
|
| Finding detail and single-record action modals | Tenant operator or tenant manager | Verify and change responsibility for one finding without losing exception context | Detail/action surface | Is this finding owned by the right person, assigned to the right executor, and clearly separate from any exception owner? | Finding summary, owner, assignee, exception owner when present, due state, lifecycle state | Raw evidence payloads, extended audit history, related run metadata | lifecycle, governance validity, responsibility state | TenantPilot only | Assign, Start progress, Resolve | Close, Request exception |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no
|
||||||
|
- **New persisted entity/table/artifact?**: no
|
||||||
|
- **New abstraction?**: no
|
||||||
|
- **New enum/state/reason family?**: no persisted family; only derived responsibility-state rules on existing fields
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: Operators cannot reliably distinguish accountability from active execution, which creates avoidable misrouting and weak audit interpretation.
|
||||||
|
- **Existing structure is insufficient because**: Two existing user fields and an exception-owner concept can already appear in the same workflow, but current copy does not establish a stable product contract across list, detail, and action surfaces.
|
||||||
|
- **Narrowest correct implementation**: Reuse the existing finding owner and assignee fields, define their meaning, and apply that meaning consistently to labels, filters, derived states, and audit copy.
|
||||||
|
- **Ownership cost**: Focused UI copy review, acceptance tests for role combinations, and ongoing discipline to keep future findings work aligned with the same contract.
|
||||||
|
- **Alternative intentionally rejected**: A broader ownership framework with separate role types, queues, or team routing was rejected because it adds durable complexity before the product has proven the simpler owner-versus-assignee contract.
|
||||||
|
- **Release truth**: Current-release truth. This spec clarifies the meaning of responsibility on live findings now and becomes the contract for later execution surfaces.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Feature
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence
|
||||||
|
- **Why this classification and these lanes are sufficient**: The change is visible through tenant findings UI behavior, responsibility-state rendering, and audit-facing wording. Focused feature coverage proves the contract without introducing browser or heavy-governance breadth.
|
||||||
|
- **New or expanded test families**: Expand tenant findings resource coverage to include responsibility rendering, personal-work cues, and mixed-context exception-owner wording. Add focused assignment-audit coverage for owner-only, assignee-only, and combined changes.
|
||||||
|
- **Fixture / helper cost impact**: Low. Existing tenant, membership, user, and finding factories are sufficient; tests only need explicit responsibility combinations and tenant-scoped memberships.
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: standard-native-filament
|
||||||
|
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions for owner versus assignee visibility and authorization behavior.
|
||||||
|
- **Reviewer handoff**: Reviewers should confirm that one positive and one negative authorization case exist, that list and detail surfaces use the same vocabulary, and that audit-facing feedback distinguishes owner changes from assignee changes.
|
||||||
|
- **Budget / baseline / trend impact**: none
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Route accountable ownership clearly (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want to tell immediately who owns a finding and who is actively assigned to it, so I can route remediation work without guessing which responsibility changed.
|
||||||
|
|
||||||
|
**Why this priority**: This is the smallest slice that fixes the core trust gap. Without it, every later execution surface inherits ambiguous meaning.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by loading the tenant findings list and detail pages with findings in owner-only, owner-plus-assignee, and no-owner states, and verifying that the first decision is possible without opening unrelated screens.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an open finding with an owner and no assignee, **When** an operator views the findings list, **Then** the record is shown as owned but unassigned rather than fully assigned.
|
||||||
|
2. **Given** an open finding with both an owner and an assignee, **When** an operator opens the finding detail, **Then** the accountable owner and active assignee are shown as separate roles.
|
||||||
|
3. **Given** an open finding with no owner, **When** an operator views the finding from the list or detail page, **Then** the accountability gap is surfaced as orphaned work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Reassign work without losing accountability (Priority: P2)
|
||||||
|
|
||||||
|
As a tenant manager, I want to change the assignee, the owner, or both in one responsibility update, so I can transfer execution work without silently transferring accountability.
|
||||||
|
|
||||||
|
**Why this priority**: Reassignment is the first mutation where the ambiguity causes operational mistakes and misleading audit history.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by performing responsibility updates on open findings and asserting separate outcomes for owner-only, assignee-only, and combined changes.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the assignee, **Then** the owner remains unchanged and the resulting feedback states that only the assignee changed.
|
||||||
|
2. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the owner, **Then** the assignee remains unchanged and the resulting feedback states that only the owner changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Keep exception ownership separate (Priority: P3)
|
||||||
|
|
||||||
|
As a governance reviewer, I want exception ownership to remain visibly separate from finding ownership when both appear in one flow, so risk-acceptance work does not overwrite the meaning of the finding owner.
|
||||||
|
|
||||||
|
**Why this priority**: This is a narrower but important boundary that prevents the accepted-risk workflow from reintroducing the same ambiguity under a second owner label.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by opening a finding that has, or is about to create, an exception record and verifying that finding owner and exception owner remain distinct in the same operator context.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a finding detail view that shows both finding responsibility and exception context, **When** the operator reviews ownership information, **Then** exception ownership is labeled separately from finding owner.
|
||||||
|
2. **Given** an operator starts an exception request from a finding, **When** the request asks for exception ownership, **Then** the form language makes clear that the selected person owns the exception artifact, not the finding itself.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A finding may have the same user as both owner and assignee; the UI must show both roles as satisfied without implying duplication or error.
|
||||||
|
- A finding with an assignee but no owner is still an orphaned accountability case, not a healthy assigned state.
|
||||||
|
- Historical or imported findings may have both responsibility fields empty; they must render as orphaned without blocking rollout on a data backfill.
|
||||||
|
- When personal-work cues are shown, assignee-based work and owner-based accountability must not collapse into one ambiguous shortcut.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature changes tenant-scoped finding workflow semantics only. It introduces no Microsoft Graph calls, no scheduled or long-running work, and no new `OperationRun`. Responsibility mutations remain audited tenant-local writes.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The affected authorization plane is tenant-context only. Cross-tenant and non-member access remain deny-as-not-found. Members without the findings assign permission continue to be blocked from responsibility mutations. The feature reuses the canonical capability registry and does not introduce raw role checks or a new capability split.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001 / Filament Action Surfaces / UX-001):** The feature reuses existing native Filament tables, infolist entries, filters, selects, modal actions, grouped actions, and notifications. No local badge taxonomy or custom action chrome is added. The action-surface contract remains satisfied: the finding is still the one and only primary inspect/open model, row click remains the canonical inspect affordance on the list, no redundant view action is introduced, and existing dangerous actions remain grouped and confirmed where already required.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001 / UI-SEM-001 / TEST-TRUTH-001):** Direct field labels alone are insufficient because finding owner, finding assignee, and exception owner can appear in the same operator flow. This feature resolves that ambiguity by tightening the product vocabulary on the existing domain truth instead of adding a new semantic layer or presenter family. Tests prove visible business consequences rather than thin indirection.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST treat finding owner as the accountable user responsible for ensuring that a finding reaches a governed terminal outcome.
|
||||||
|
- **FR-002**: The system MUST treat finding assignee as the user currently expected to perform or coordinate active remediation work, and any operator-facing use of `assigned` language MUST refer to this role only.
|
||||||
|
- **FR-003**: Authorized users MUST be able to set, change, or clear finding owner and finding assignee independently on open findings while continuing to restrict selectable people to members of the active tenant.
|
||||||
|
- **FR-004**: The system MUST derive responsibility state from the existing owner and assignee fields without adding a persisted lifecycle field. At minimum it MUST distinguish `owned but unassigned`, `assigned`, and `orphaned accountability`.
|
||||||
|
- **FR-005**: Findings list, finding detail, and responsibility-update flows MUST label owner and assignee distinctly, and any mixed context that also references exception ownership MUST label that role as exception owner.
|
||||||
|
- **FR-006**: When personal-work shortcuts or filters are shown on the tenant findings list, the system MUST expose separate, explicitly named cues for assignee-based work and owner-based accountability.
|
||||||
|
- **FR-007**: Responsibility-update feedback and audit-facing wording MUST state whether a mutation changed the owner, the assignee, or both.
|
||||||
|
- **FR-008**: Historical or imported findings with null or partial responsibility data MUST render using the derived responsibility contract without requiring a blocking data migration before rollout.
|
||||||
|
- **FR-009**: Exception ownership MUST remain a separate governance concept. Creating, viewing, or editing an exception from a finding context MUST NOT silently replace the meaning of the finding owner.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list | `/admin/t/{tenant}/findings` | None added by this feature | Full-row open into finding detail | Primary open affordance plus `More` action group with `Triage`, `Start progress`, `Assign`, `Resolve`, `Close`, and `Request exception` | Grouped actions including `Triage selected`, `Assign selected`, `Resolve selected`, and existing close/exception-safe bulk actions if already present | Existing findings empty state remains; no new CTA introduced | N/A | N/A | Yes for responsibility mutations and existing workflow mutations | Action Surface Contract satisfied. No empty groups or redundant view action introduced. |
|
||||||
|
| Finding detail | `/admin/t/{tenant}/findings/{finding}` | Existing record-level workflow actions only | Entered from the list row click; no separate inspect action added | Structured record actions retain `Assign`, `Start progress`, `Resolve`, `Close`, and `Request exception` | N/A | N/A | Existing view header actions only | N/A | Yes for responsibility mutations and existing workflow mutations | UI-FIL-001 satisfied through existing infolist and action modal primitives. No exemption needed. |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Finding responsibility**: The visible responsibility contract attached to an open finding, consisting of accountable ownership, active execution assignment, and a derived responsibility state.
|
||||||
|
- **Finding owner**: The accountable person who owns the outcome of the finding and is expected to ensure it reaches a governed end state.
|
||||||
|
- **Finding assignee**: The person currently expected to perform or coordinate the remediation work on the finding.
|
||||||
|
- **Exception owner**: The accountable person for an exception artifact created from a finding; separate from finding owner whenever both appear in one operator context.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: In acceptance review, an authorized operator can identify owner, assignee, and responsibility state for any scripted open-finding example from the list or detail surface within 10 seconds.
|
||||||
|
- **SC-002**: 100% of covered responsibility combinations in automated acceptance tests, including same-person, owner-only, owner-plus-assignee, assignee-only, and both-empty cases, render the correct visible labels and derived state.
|
||||||
|
- **SC-003**: 100% of covered responsibility-update tests distinguish owner-only, assignee-only, and combined changes in operator feedback and audit-facing wording.
|
||||||
|
- **SC-004**: When personal-work shortcuts are present, an operator can isolate assignee-based work and owner-based accountability from the findings list in one interaction each.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing finding owner and assignee fields remain the single source of truth for responsibility in this slice.
|
||||||
|
- Open findings may legitimately begin without an assignee while still needing an accountable owner.
|
||||||
|
- Membership hygiene for users who later lose tenant access is a separate follow-up concern and not solved by this clarification slice.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Introduce team, queue, or workgroup ownership.
|
||||||
|
- Add automatic escalation, reassignment, or inactivity timers.
|
||||||
|
- Split authorization into separate owner-edit and assignee-edit capabilities.
|
||||||
|
- Require a mandatory historical backfill before the clarified semantics can ship.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spec 111, Findings Workflow + SLA, remains the lifecycle and assignment baseline that this spec clarifies.
|
||||||
|
- Spec 154, Finding Risk Acceptance, remains the exception governance baseline whose owner semantics must stay separate from finding owner.
|
||||||
@ -159,10 +159,3 @@ ### Measurable Outcomes
|
|||||||
- **SC-004**: Every core page includes a visible next-step CTA and at least one deeper path into the product, trust, or contact story.
|
- **SC-004**: Every core page includes a visible next-step CTA and at least one deeper path into the product, trust, or contact story.
|
||||||
- **SC-005**: No released page contains placeholder copy, unsubstantiated trust or compliance claims, or speculative integration promises.
|
- **SC-005**: No released page contains placeholder copy, unsubstantiated trust or compliance claims, or speculative integration promises.
|
||||||
- **SC-006**: Core pages remain readable and navigable on both desktop and mobile widths without horizontal scrolling or hidden primary navigation.
|
- **SC-006**: Core pages remain readable and navigable on both desktop and mobile widths without horizontal scrolling or hidden primary navigation.
|
||||||
|
|
||||||
## Spec 223 Rebuild Status
|
|
||||||
|
|
||||||
- **Classification**: partially valid
|
|
||||||
- **Forward owner**: `../223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`
|
|
||||||
- **Material drift now recorded**: canonical trust routing now lives on `/trust`, `/security-trust` is redirect-only, `/changelog` and `/imprint` are canonical public surfaces, and `/legal`, `/terms`, `/solutions`, and `/integrations` are retained secondary routes under the smaller IA.
|
|
||||||
- **Forward-work rule**: new implementation work must start from the AstroDeck intake aliases and the Spec 223 mapping sheet instead of from the current `apps/website` implementation.
|
|
||||||
|
|||||||
@ -5,12 +5,6 @@ # Tasks: Initial Website Foundation & v0 Product Site
|
|||||||
|
|
||||||
**Tests**: Browser smoke coverage is required for this runtime-changing website feature, together with the root website build proof.
|
**Tests**: Browser smoke coverage is required for this runtime-changing website feature, together with the root website build proof.
|
||||||
|
|
||||||
## Historical Status
|
|
||||||
|
|
||||||
This implementation record is retained for traceability and is superseded by AstroDeck rebuild.
|
|
||||||
|
|
||||||
Forward planning now lives in `../223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`. Do not reset or reopen the checkbox state below; new work belongs to the Spec 223 mapping and follow-up slices.
|
|
||||||
|
|
||||||
## Test Governance Checklist
|
## Test Governance Checklist
|
||||||
|
|
||||||
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||||
|
|||||||
@ -174,10 +174,3 @@ ### Measurable Outcomes
|
|||||||
- **SC-003**: 100% of library-derived website components proposed for the initial implementation phase can be justified as adaptations of the defined token and primitive model rather than uncontrolled default styles.
|
- **SC-003**: 100% of library-derived website components proposed for the initial implementation phase can be justified as adaptations of the defined token and primitive model rather than uncontrolled default styles.
|
||||||
- **SC-004**: The specification contains zero requirements that obligate visual changes to `apps/platform`, Filament, or a shared cross-surface design system.
|
- **SC-004**: The specification contains zero requirements that obligate visual changes to `apps/platform`, Filament, or a shared cross-surface design system.
|
||||||
- **SC-005**: At least one primary CTA hierarchy and one low-emphasis CTA pattern are defined clearly enough that reviewers can classify CTA weight consistently across representative website pages.
|
- **SC-005**: At least one primary CTA hierarchy and one low-emphasis CTA pattern are defined clearly enough that reviewers can classify CTA weight consistently across representative website pages.
|
||||||
|
|
||||||
## Spec 223 Rebuild Status
|
|
||||||
|
|
||||||
- **Classification**: continuing
|
|
||||||
- **Forward owner**: `../223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md`
|
|
||||||
- **Material drift**: none logged. AstroDeck changes the implementation substrate only; this website-local visual contract remains authoritative.
|
|
||||||
- **Forward-work rule**: all AstroDeck primitives must be adapted to the Spec 214 foundation before they are allowed into `apps/website`.
|
|
||||||
|
|||||||
@ -5,12 +5,6 @@ # Tasks: Website Visual Foundation
|
|||||||
|
|
||||||
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
||||||
|
|
||||||
## Historical Status
|
|
||||||
|
|
||||||
This implementation record is retained for traceability and is superseded by AstroDeck rebuild.
|
|
||||||
|
|
||||||
Forward planning now lives in `../223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md`. Do not reset or reopen the checkbox state below; new work belongs to the Spec 223 mapping and follow-up slices.
|
|
||||||
|
|
||||||
## Test Governance Checklist
|
## Test Governance Checklist
|
||||||
|
|
||||||
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||||
|
|||||||
@ -202,10 +202,3 @@ ## Planned Follow-on Specs
|
|||||||
- Spec 223 - Blog / Resources Surface, if activated
|
- Spec 223 - Blog / Resources Surface, if activated
|
||||||
- Spec 224 - Solutions / Use-Case Surfaces, if activated later
|
- Spec 224 - Solutions / Use-Case Surfaces, if activated later
|
||||||
- Spec 225 - Pricing Surface, if activated later
|
- Spec 225 - Pricing Surface, if activated later
|
||||||
|
|
||||||
## Spec 223 Rebuild Status
|
|
||||||
|
|
||||||
- **Classification**: continuing
|
|
||||||
- **Forward owner**: `../223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md`
|
|
||||||
- **Material drift**: none logged. This spec remains the canonical IA source of truth that constrains AstroDeck adoption.
|
|
||||||
- **Forward-work rule**: no AstroDeck route or navigation item may become public unless it matches the required, retained-secondary, or approved-optional surfaces defined here.
|
|
||||||
|
|||||||
@ -5,12 +5,6 @@ # Tasks: Website Information Architecture / Core Pages
|
|||||||
|
|
||||||
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
||||||
|
|
||||||
## Historical Status
|
|
||||||
|
|
||||||
This implementation record is retained for traceability and is superseded by AstroDeck rebuild.
|
|
||||||
|
|
||||||
Forward planning now lives in `../223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md`. Do not reset or reopen the checkbox state below; new work belongs to the Spec 223 mapping and follow-up slices.
|
|
||||||
|
|
||||||
## Test Governance Checklist
|
## Test Governance Checklist
|
||||||
|
|
||||||
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||||
|
|||||||
@ -213,10 +213,3 @@ ### Measurable Outcomes
|
|||||||
- **SC-009**: Reviewers can identify at least one typographic or compositional cue and one product-truth cue that distinguish the hero from a generic shadcn or Tailwind marketing layout.
|
- **SC-009**: Reviewers can identify at least one typographic or compositional cue and one product-truth cue that distinguish the hero from a generic shadcn or Tailwind marketing layout.
|
||||||
- **SC-010**: On desktop and mobile, the hero retains clear contrast between the dominant element, supporting copy, product visual, and CTA instead of flattening into visually equal neutral surfaces.
|
- **SC-010**: On desktop and mobile, the hero retains clear contrast between the dominant element, supporting copy, product visual, and CTA instead of flattening into visually equal neutral surfaces.
|
||||||
- **SC-011**: The released homepage hero avoids the anti-patterns of neutral mush and correct-but-forgettable minimalism while remaining calm and trust-oriented.
|
- **SC-011**: The released homepage hero avoids the anti-patterns of neutral mush and correct-but-forgettable minimalism while remaining calm and trust-oriented.
|
||||||
|
|
||||||
## Spec 223 Rebuild Status
|
|
||||||
|
|
||||||
- **Classification**: continuing
|
|
||||||
- **Forward owner**: `../223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md`
|
|
||||||
- **Material drift**: none logged. Optional AstroDeck proof sections are handled as remove/adapt decisions inside the mapping sheet, not as structural homepage truth changes.
|
|
||||||
- **Forward-work rule**: AstroDeck homepage work must preserve the required block order of hero, outcome, product model, trust, progress, CTA, and footer.
|
|
||||||
|
|||||||
@ -5,12 +5,6 @@ # Tasks: Website Homepage Structure & Section Model
|
|||||||
|
|
||||||
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
||||||
|
|
||||||
## Historical Status
|
|
||||||
|
|
||||||
This implementation record is retained for traceability and is superseded by AstroDeck rebuild.
|
|
||||||
|
|
||||||
Forward planning now lives in `../223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md`. Do not reset or reopen the checkbox state below; new work belongs to the Spec 223 mapping and follow-up slices.
|
|
||||||
|
|
||||||
## Test Governance Checklist
|
## Test Governance Checklist
|
||||||
|
|
||||||
- [X] Lane assignment stays `Browser` in `fast-feedback`, which is the narrowest sufficient proof for this homepage-only change.
|
- [X] Lane assignment stays `Browser` in `fast-feedback`, which is the narrowest sufficient proof for this homepage-only change.
|
||||||
|
|||||||
@ -187,10 +187,3 @@ ## Planned Follow-on Work
|
|||||||
- Final hero copy exploration
|
- Final hero copy exploration
|
||||||
- Stitch-based hero design exploration
|
- Stitch-based hero design exploration
|
||||||
- Downstream homepage-section detail work that assumes this hero contract rather than redefining it
|
- Downstream homepage-section detail work that assumes this hero contract rather than redefining it
|
||||||
|
|
||||||
## Spec 223 Rebuild Status
|
|
||||||
|
|
||||||
- **Classification**: continuing
|
|
||||||
- **Forward owner**: `../223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md`
|
|
||||||
- **Material drift**: none logged. AstroDeck can provide the hero shell, but it does not change the allowed semantic structure or anti-pattern rules.
|
|
||||||
- **Forward-work rule**: mapped AstroDeck hero primitives must keep one clear anchor, one CTA pair, product-near visual truth, and bounded trust cues on desktop and mobile.
|
|
||||||
|
|||||||
@ -5,12 +5,6 @@ # Tasks: Website Homepage Hero
|
|||||||
|
|
||||||
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
||||||
|
|
||||||
## Historical Status
|
|
||||||
|
|
||||||
This implementation record is retained for traceability and is superseded by AstroDeck rebuild.
|
|
||||||
|
|
||||||
Forward planning now lives in `../223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md`. Do not reset or reopen the checkbox state below; new work belongs to the Spec 223 mapping and follow-up slices.
|
|
||||||
|
|
||||||
## Test Governance Checklist
|
## Test Governance Checklist
|
||||||
|
|
||||||
- [X] Lane assignment stays `Browser` in `fast-feedback`, which is the narrowest sufficient proof for this homepage-hero-only change.
|
- [X] Lane assignment stays `Browser` in `fast-feedback`, which is the narrowest sufficient proof for this homepage-hero-only change.
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
# AstroDeck Primitive Inventory
|
|
||||||
|
|
||||||
The IDs below are stable intake aliases for the AstroDeck snapshot described in `astrodeck-source-intake.md`. They are the only allowed primitive vocabulary for follow-up rebuild planning until the real snapshot is mounted and bound to these aliases.
|
|
||||||
|
|
||||||
## Inventory Columns
|
|
||||||
|
|
||||||
| Column | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `primitiveId` | Stable intake alias used by the mapping sheets |
|
|
||||||
| `primitiveType` | `page`, `section`, or `component` |
|
|
||||||
| `sourceReference` | Expected AstroDeck source family or later binding target |
|
|
||||||
| `candidateSurfaces` | Current routes or spec slices the primitive can support |
|
|
||||||
| `demoContentFlags` | Demo artifacts that must be removed, adapted, or explicitly approved |
|
|
||||||
| `notes` | Review guidance before the primitive is kept or adapted |
|
|
||||||
|
|
||||||
## Page Candidates
|
|
||||||
|
|
||||||
| primitiveId | primitiveType | sourceReference | candidateSurfaces | demoContentFlags | notes |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| `adk-page-home-marketing` | page | `snapshot/page/home` | `/`, Specs 213, 215, 217, 218 | demo stats, generic customer proof, newsletter CTA | Primary landing substrate for the rebuild. |
|
|
||||||
| `adk-page-product-overview` | page | `snapshot/page/product-or-features` | `/product`, Specs 213, 215 | feature-silo copy, pricing teaser | Use for the product-model page, not a feature-wall clone. |
|
|
||||||
| `adk-page-trust-proof` | page | `snapshot/page/security-or-proof` | `/trust`, Specs 213, 215, 217, 218 | compliance theater, absolute claims, badge walls | Preferred starting point for the canonical trust route. |
|
|
||||||
| `adk-page-contact-conversion` | page | `snapshot/page/contact` | `/contact`, Specs 213, 215 | generic sales language, oversized lead form | Must be adapted to the working-session framing. |
|
|
||||||
| `adk-page-content-index` | page | `snapshot/page/blog-or-news-index` | `/changelog`, optional `/resources` | blog taxonomy chrome, author bios, editorial promos | Repurpose for dated changelog proof; keep resources unpublished unless substantive. |
|
|
||||||
| `adk-page-legal-utility` | page | `snapshot/page/legal-or-company-utility` | `/privacy`, `/imprint`, `/terms`, `/legal` | placeholder legal copy, company boilerplate | Use as the base for legal surfaces; do not ship template legal text. |
|
|
||||||
| `adk-page-supporting-showcase` | page | `snapshot/page/about-solutions-or-integrations` | `/solutions`, `/integrations` | fake logos, partner walls, over-broad ecosystem claims | Secondary-only surface for audience-fit and integration-fit pages. |
|
|
||||||
|
|
||||||
## Section Candidates
|
|
||||||
|
|
||||||
| primitiveId | primitiveType | sourceReference | candidateSurfaces | demoContentFlags | notes |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| `adk-section-hero-split-media` | section | `snapshot/section/hero-split-media` | homepage, product, trust, contact | startup buzzwords, fake metrics, decorative dashboard wallpaper | Preferred hero family for Specs 217 and 218. |
|
|
||||||
| `adk-section-outcome-band` | section | `snapshot/section/outcomes-or-value-band` | homepage, product | vague benefit copy, investor-style slogans | Use to translate product capability into buyer outcomes. |
|
|
||||||
| `adk-section-feature-cluster-grid` | section | `snapshot/section/feature-grid-or-capability-clusters` | homepage, product, solutions, integrations | equal-weight feature cards, pricing hooks | Must be grouped by product model, not by generic marketing categories. |
|
|
||||||
| `adk-section-trust-principles` | section | `snapshot/section/trust-or-proof-grid` | trust page, homepage | compliance badges, guarantee claims | Use only with bounded, supportable trust language. |
|
|
||||||
| `adk-section-proof-stats` | section | `snapshot/section/stats-or-proof-strip` | homepage, product, trust | invented KPIs, vanity counters | Remove unless real approved proof exists. |
|
|
||||||
| `adk-section-logo-strip` | section | `snapshot/section/logo-cloud` | homepage, solutions | fake customers, unapproved brands | Remove by default. |
|
|
||||||
| `adk-section-testimonial-stack` | section | `snapshot/section/testimonials` | homepage, solutions | fabricated quotes, polished social proof | Remove by default. |
|
|
||||||
| `adk-section-changelog-teaser` | section | `snapshot/section/news-or-update-teaser` | homepage, changelog | editorial filler, blog cards without dated product value | Keep only if it points to real dated changelog entries. |
|
|
||||||
| `adk-section-contact-form` | section | `snapshot/section/contact-form` | contact, footer CTA follow-through | unnecessary intake fields, aggressive SDR language | Adapt to minimal useful working-session context. |
|
|
||||||
| `adk-section-cta-band` | section | `snapshot/section/final-cta` | homepage, product, trust, changelog, legal | duplicated loud CTAs, demo pressure | Keep exactly one dominant action. |
|
|
||||||
| `adk-section-footer-utility` | section | `snapshot/section/footer-utility` | all public routes | docs/pricing overload, empty content links | Must match the canonical 215 IA. |
|
|
||||||
|
|
||||||
## Component Candidates
|
|
||||||
|
|
||||||
| primitiveId | primitiveType | sourceReference | candidateSurfaces | demoContentFlags | notes |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| `adk-component-header-nav` | component | `snapshot/component/header-nav` | all public routes | pricing/docs/blog clutter | Must shrink to Product, Trust, Changelog, Contact, plus one CTA. |
|
|
||||||
| `adk-component-footer-nav` | component | `snapshot/component/footer-nav` | all public routes | template link dumps | Must preserve trust/legal/contact grouping. |
|
|
||||||
| `adk-component-primary-button` | component | `snapshot/component/button-primary` | homepage, product, trust, contact | multi-primary CTA styling | Dominant CTA only. |
|
|
||||||
| `adk-component-secondary-button` | component | `snapshot/component/button-secondary` | homepage, product, trust, contact, changelog | outline fallback styling with no hierarchy | Use only as a lower-emphasis deepening action. |
|
|
||||||
| `adk-component-badge-chip` | component | `snapshot/component/badge-chip` | hero trust cues, callouts | badge walls, pseudo-certification labels | Use sparingly and only with factual claims. |
|
|
||||||
| `adk-component-card-surface` | component | `snapshot/component/card` | product clusters, legal summaries, changelog cards | over-elevated surfaces | Keep border-first clarity and restrained shadows. |
|
|
||||||
| `adk-component-section-shell` | component | `snapshot/component/section-shell` | all page families | inconsistent width/spacing defaults | Main adaptation point for Spec 214 spacing and surface rules. |
|
|
||||||
| `adk-component-input-field` | component | `snapshot/component/input` | contact | marketing-form defaults | Must align to the website foundation and minimal intake scope. |
|
|
||||||
| `adk-component-textarea-field` | component | `snapshot/component/textarea` | contact | marketing-form defaults | Same constraint as the input field. |
|
|
||||||
| `adk-component-callout-panel` | component | `snapshot/component/callout-or-alert-panel` | product, trust, legal, changelog | alarmist styling, decorative status colors | Use for bounded explanation, not urgency theater. |
|
|
||||||
|
|
||||||
## Demo-Content Flag Vocabulary
|
|
||||||
|
|
||||||
- `demo stats`: invented counters, fake KPIs, or unsourced numerical proof.
|
|
||||||
- `generic customer proof`: logo clouds, testimonials, and case-study language without approved source material.
|
|
||||||
- `newsletter CTA`: sign-up or nurture patterns that are not part of the current IA.
|
|
||||||
- `pricing teaser`: navigation or CTA pressure toward pricing or packaging pages that are not live.
|
|
||||||
- `compliance theater`: seals, badges, or guarantee language that overstates trust posture.
|
|
||||||
|
|
||||||
## Inventory Conclusion
|
|
||||||
|
|
||||||
The alias set above is sufficient to drive the per-spec mapping sheets without creating net-new primitive families up front. Where the imported snapshot later disproves one of these families, the mismatch must reopen the intake review and the exception workflow before implementation continues.
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
# AstroDeck Source Intake
|
|
||||||
|
|
||||||
Spec 223 assumes AstroDeck is an external template source, not an already-imported repository subtree. This intake record fixes the review assumptions that must hold before primitive mapping starts.
|
|
||||||
|
|
||||||
## Intake Record
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| sourceSnapshotReference | Pending external AstroDeck distribution archive to be mounted into the local implementation workspace before the first rebuild slice starts |
|
|
||||||
| planningAliasPrefix | `adk-` |
|
|
||||||
| reviewStatus | planning assumptions only; no committed AstroDeck source is present in this repository as of 2026-04-22 |
|
|
||||||
| notes | All primitive IDs in this feature are stable intake aliases. Once the snapshot is mounted, the implementation owner must bind each alias to the real AstroDeck file or component name before copying code. |
|
|
||||||
|
|
||||||
## Intake Constraints
|
|
||||||
|
|
||||||
- Treat AstroDeck as the only forward substrate for `apps/website`; the current custom Astro site remains historical comparison material only.
|
|
||||||
- Do not let AstroDeck demo routes, placeholder sections, generic testimonials, pricing promos, or newsletter capture define the public IA by default.
|
|
||||||
- Do not copy vendor demo copy, customer logos, invented metrics, or compliance theater into TenantAtlas surfaces.
|
|
||||||
- Keep the repo-level website working contract intact: `@tenantatlas/website`, `WEBSITE_PORT`, `corepack pnpm dev:website`, and `corepack pnpm build:website` remain unchanged.
|
|
||||||
- Keep all rebuild governance local to `apps/website`; no platform, Filament, or cross-app obligations may leak out of the template intake.
|
|
||||||
- If the imported snapshot lacks an adequate page, section, or component family, route the gap through `exception-register.md` before inventing a custom primitive.
|
|
||||||
|
|
||||||
## Review Assumptions
|
|
||||||
|
|
||||||
- The chosen AstroDeck distribution will provide at least one marketing home page, one product/features page, one contact/conversion page, one blog/news/content index, one generic legal/company utility page, and shared header/footer shells.
|
|
||||||
- The distribution will also provide hero, feature-grid, CTA, proof, and form sections plus button, badge, card, input, textarea, and navigation primitives.
|
|
||||||
- Trust, changelog, and imprint will likely require adaptation of generic proof, blog/news, and legal/company primitives instead of direct one-to-one template pages.
|
|
||||||
- Any AstroDeck page family that tries to push pricing, docs, resources, or social proof into primary navigation remains non-authoritative until an active website spec explicitly promotes it.
|
|
||||||
- The mapping sheets may use intake aliases even before the real snapshot is committed, but the first implementation slice must resolve each alias against the mounted source before code transfer starts.
|
|
||||||
- No compatibility shim is needed for the retired custom site. LEAN-001 applies: replace the old substrate rather than hybridizing it.
|
|
||||||
|
|
||||||
## Review Boundaries
|
|
||||||
|
|
||||||
- Current-site route truth comes from `current-website-inventory.md`, not from the AstroDeck template.
|
|
||||||
- Governing spec truth comes from Specs 213, 214, 215, 217, and 218 plus their Spec 223 rebuild notes, not from template defaults.
|
|
||||||
- Material route, navigation, CTA, or trust drift introduced by AstroDeck must be recorded in `material-drift-follow-up.md`.
|
|
||||||
- The exception path remains closed unless the adequacy rubric in `exception-register.md` explicitly says the available AstroDeck primitives cannot satisfy the active requirement through bounded adaptation.
|
|
||||||
|
|
||||||
## Forward Substrate Decision
|
|
||||||
|
|
||||||
The forward substrate for new website work is therefore:
|
|
||||||
|
|
||||||
1. Mounted AstroDeck snapshot
|
|
||||||
2. Spec 223 intake aliases and primitive inventory
|
|
||||||
3. Per-spec mapping sheets
|
|
||||||
4. Exception review only if mapping fails
|
|
||||||
|
|
||||||
The current `apps/website` codebase is still required for comparison, smoke-baseline review, and copy extraction, but it is not a valid starting point for new implementation work.
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
# Specification Quality Checklist: Website Reset and AstroDeck Rebuild
|
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
- Validation round 1 passed.
|
|
||||||
- The spec stays local to `apps/website` and defines no runtime or platform obligations.
|
|
||||||
- Existing website spec references were aligned to the current repository set: Specs 213, 214, 215, 217, and 218.
|
|
||||||
@ -1,345 +0,0 @@
|
|||||||
version: 1
|
|
||||||
feature: 223-astrodeck-website-rebuild
|
|
||||||
description: File-based contract for the planning artifacts required by the AstroDeck rebuild workflow.
|
|
||||||
|
|
||||||
artifacts:
|
|
||||||
astroDeckSourceIntake:
|
|
||||||
description: Reviewable source-intake record for the AstroDeck snapshot being evaluated before primitive mapping begins.
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- sourceSnapshotReference
|
|
||||||
- intakeConstraints
|
|
||||||
- reviewAssumptions
|
|
||||||
properties:
|
|
||||||
sourceSnapshotReference:
|
|
||||||
type: string
|
|
||||||
intakeConstraints:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
reviewAssumptions:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
currentWebsiteInventory:
|
|
||||||
description: Current `apps/website` surface inventory captured before any AstroDeck replacement work starts.
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- surfaces
|
|
||||||
properties:
|
|
||||||
surfaces:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- route
|
|
||||||
- sourceFile
|
|
||||||
- surfaceRole
|
|
||||||
- governingSpecs
|
|
||||||
- plannedDisposition
|
|
||||||
properties:
|
|
||||||
route:
|
|
||||||
type: string
|
|
||||||
sourceFile:
|
|
||||||
type: string
|
|
||||||
surfaceRole:
|
|
||||||
type: string
|
|
||||||
governingSpecs:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: integer
|
|
||||||
currentDependencies:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
plannedDisposition:
|
|
||||||
type: string
|
|
||||||
enum: [keep, adapt, remove, redirect]
|
|
||||||
|
|
||||||
astroDeckPrimitiveInventory:
|
|
||||||
description: Inventory of candidate AstroDeck pages, sections, and components.
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- primitives
|
|
||||||
properties:
|
|
||||||
primitives:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- primitiveId
|
|
||||||
- primitiveType
|
|
||||||
- sourceReference
|
|
||||||
properties:
|
|
||||||
primitiveId:
|
|
||||||
type: string
|
|
||||||
primitiveType:
|
|
||||||
type: string
|
|
||||||
enum: [page, section, component]
|
|
||||||
sourceReference:
|
|
||||||
type: string
|
|
||||||
candidateSurfaces:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
demoContentFlags:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
notes:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
governingSpecClassification:
|
|
||||||
description: Classification of the active website spec set before rebuild implementation starts.
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- specs
|
|
||||||
properties:
|
|
||||||
specs:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- specId
|
|
||||||
- title
|
|
||||||
- classification
|
|
||||||
- scopeSummary
|
|
||||||
- rationale
|
|
||||||
- followUpPlan
|
|
||||||
properties:
|
|
||||||
specId:
|
|
||||||
type: integer
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
classification:
|
|
||||||
type: string
|
|
||||||
enum: [continuing, "partially valid", superseded]
|
|
||||||
scopeSummary:
|
|
||||||
type: string
|
|
||||||
rationale:
|
|
||||||
type: string
|
|
||||||
followUpPlan:
|
|
||||||
description: Path to the per-spec mapping artifact or explicit supersession-closure artifact that owns current-slice delivery for this spec.
|
|
||||||
type: string
|
|
||||||
|
|
||||||
primitiveMapping:
|
|
||||||
description: Mapping of one governing website spec requirement to one AstroDeck primitive, including the replacement task ownership embedded in the same per-spec artifact.
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- mappings
|
|
||||||
properties:
|
|
||||||
mappings:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- specId
|
|
||||||
- requirementReference
|
|
||||||
- disposition
|
|
||||||
- acceptanceMapping
|
|
||||||
- replacementTasks
|
|
||||||
properties:
|
|
||||||
specId:
|
|
||||||
type: integer
|
|
||||||
requirementReference:
|
|
||||||
type: string
|
|
||||||
primitiveId:
|
|
||||||
description: Required when disposition is keep, adapt, or remove; omitted when disposition is exception.
|
|
||||||
type: string
|
|
||||||
disposition:
|
|
||||||
type: string
|
|
||||||
enum: [keep, adapt, remove, exception]
|
|
||||||
adaptationSummary:
|
|
||||||
type: string
|
|
||||||
acceptanceMapping:
|
|
||||||
type: string
|
|
||||||
replacementTasks:
|
|
||||||
description: Ordered replacement task list owned by the same per-spec mapping or disposition artifact.
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
taskPrimitiveReferences:
|
|
||||||
description: Named AstroDeck pages, sections, components, or explicit mapping activities referenced by the replacement task list.
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
materialDriftReferences:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
exceptionReference:
|
|
||||||
type: string
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
disposition:
|
|
||||||
enum: [keep, adapt, remove]
|
|
||||||
required:
|
|
||||||
- primitiveId
|
|
||||||
- properties:
|
|
||||||
disposition:
|
|
||||||
enum: [exception]
|
|
||||||
required:
|
|
||||||
- exceptionReference
|
|
||||||
|
|
||||||
supersessionClosure:
|
|
||||||
description: Explicit closure artifact for an in-scope website spec that ends the rebuild review as superseded instead of continuing into a mapping-based plan.
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- specId
|
|
||||||
- classification
|
|
||||||
- closureRationale
|
|
||||||
- legacyTaskDispositionReference
|
|
||||||
- replacementReference
|
|
||||||
properties:
|
|
||||||
specId:
|
|
||||||
type: integer
|
|
||||||
classification:
|
|
||||||
type: string
|
|
||||||
enum: [superseded]
|
|
||||||
closureRationale:
|
|
||||||
type: string
|
|
||||||
legacyTaskDispositionReference:
|
|
||||||
type: string
|
|
||||||
replacementReference:
|
|
||||||
description: Replacement plan reference or an explicit `no replacement work required` statement.
|
|
||||||
type: string
|
|
||||||
notes:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
legacyTaskDisposition:
|
|
||||||
description: Historical task record preserved while being superseded by the rebuild.
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- dispositions
|
|
||||||
properties:
|
|
||||||
dispositions:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- originalSpecId
|
|
||||||
- originalTaskReference
|
|
||||||
- disposition
|
|
||||||
- replacementReference
|
|
||||||
properties:
|
|
||||||
originalSpecId:
|
|
||||||
type: integer
|
|
||||||
originalTaskReference:
|
|
||||||
type: string
|
|
||||||
disposition:
|
|
||||||
type: string
|
|
||||||
enum: ["superseded by AstroDeck rebuild"]
|
|
||||||
replacementReference:
|
|
||||||
description: New plan, task set, or an explicit `no replacement work required` statement.
|
|
||||||
type: string
|
|
||||||
notes:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
materialDriftFollowUp:
|
|
||||||
description: Explicit spec-update follow-up record for material page inventory, CTA logic, navigation, or trust messaging drift discovered during mapping.
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- driftRecords
|
|
||||||
properties:
|
|
||||||
driftRecords:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- affectedSpecId
|
|
||||||
- driftClass
|
|
||||||
- driftSummary
|
|
||||||
- requiredSpecAction
|
|
||||||
- targetSpecReference
|
|
||||||
properties:
|
|
||||||
affectedSpecId:
|
|
||||||
type: integer
|
|
||||||
driftClass:
|
|
||||||
type: string
|
|
||||||
enum: ["page inventory", "CTA logic", navigation, "trust messaging"]
|
|
||||||
driftSummary:
|
|
||||||
type: string
|
|
||||||
requiredSpecAction:
|
|
||||||
type: string
|
|
||||||
enum: ["update existing spec", "create follow-up spec"]
|
|
||||||
targetSpecReference:
|
|
||||||
type: string
|
|
||||||
notes:
|
|
||||||
type: string
|
|
||||||
|
|
||||||
exceptionRegister:
|
|
||||||
description: Standing review register for missing-candidate checks, including approved exceptions and explicit no-exception outcomes.
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- entries
|
|
||||||
properties:
|
|
||||||
entries:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- specId
|
|
||||||
- scope
|
|
||||||
- reviewOutcome
|
|
||||||
- reviewRationale
|
|
||||||
properties:
|
|
||||||
specId:
|
|
||||||
type: integer
|
|
||||||
scope:
|
|
||||||
type: string
|
|
||||||
reviewOutcome:
|
|
||||||
type: string
|
|
||||||
enum: ["approved exception", "no exception required"]
|
|
||||||
reviewRationale:
|
|
||||||
type: string
|
|
||||||
exceptionReference:
|
|
||||||
type: string
|
|
||||||
notes:
|
|
||||||
type: string
|
|
||||||
oneOf:
|
|
||||||
- properties:
|
|
||||||
reviewOutcome:
|
|
||||||
enum: ["approved exception"]
|
|
||||||
required:
|
|
||||||
- exceptionReference
|
|
||||||
- properties:
|
|
||||||
reviewOutcome:
|
|
||||||
enum: ["no exception required"]
|
|
||||||
|
|
||||||
documentedException:
|
|
||||||
description: Approved exception record for custom work when no adequate AstroDeck primitive exists, stored inside the exception register when reviewOutcome is `approved exception`.
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- exceptions
|
|
||||||
properties:
|
|
||||||
exceptions:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- scope
|
|
||||||
- missingCandidate
|
|
||||||
- unmetRequirement
|
|
||||||
- adequacyFailureReason
|
|
||||||
- boundedDeviation
|
|
||||||
- approvedBy
|
|
||||||
- approvalReference
|
|
||||||
- spreadControl
|
|
||||||
properties:
|
|
||||||
scope:
|
|
||||||
type: string
|
|
||||||
missingCandidate:
|
|
||||||
type: string
|
|
||||||
unmetRequirement:
|
|
||||||
type: string
|
|
||||||
adequacyFailureReason:
|
|
||||||
type: string
|
|
||||||
boundedDeviation:
|
|
||||||
type: string
|
|
||||||
approvedBy:
|
|
||||||
type: string
|
|
||||||
approvalReference:
|
|
||||||
type: string
|
|
||||||
spreadControl:
|
|
||||||
type: string
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
# Current Website Inventory
|
|
||||||
|
|
||||||
This document captures the current `apps/website` implementation as retired implementation history. It remains reviewable context for Spec 223, but it is not the forward substrate for new website work.
|
|
||||||
|
|
||||||
## Legacy Substrate Record
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| scopePath | `apps/website` |
|
|
||||||
| status | discarded implementation history |
|
|
||||||
| replacedBySpec | `223-astrodeck-website-rebuild` |
|
|
||||||
| notes | The current Astro 6 site remains useful for route, copy, and smoke-test baseline review, but future delivery must start from the AstroDeck intake aliases documented in this feature. |
|
|
||||||
|
|
||||||
## Current Route Baseline
|
|
||||||
|
|
||||||
| Route | Source file | Surface role | Governing specs | Current dependencies | Planned disposition | Notes |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| `/` | `apps/website/src/pages/index.astro` | entry and homepage routing hub | 213, 214, 215, 217, 218 | `src/content/pages/home.ts`; `PageHero`; `OutcomeSection`; `CapabilityGrid`; `TrustGrid`; `ProgressTeaser`; `home-product.spec.ts`; `visual-foundation-guardrails.spec.ts` | adapt | Keep the route, but replace the custom section assembly with an AstroDeck home page shell plus mapped sections. |
|
|
||||||
| `/product` | `apps/website/src/pages/product.astro` | core product model surface | 213, 214, 215 | `src/content/pages/product.ts`; `PageHero`; `FeatureGrid`; `Callout`; `home-product.spec.ts` | adapt | Product explanation remains required, but the current page composition is historical. |
|
|
||||||
| `/trust` | `apps/website/src/pages/trust.astro` | canonical trust surface | 214, 215 | `src/content/pages/trust.ts`; `PageHero`; `TrustGrid`; `Callout`; `solutions-trust-integrations.spec.ts`; `visual-foundation-guardrails.spec.ts` | adapt | Retain the canonical route and re-map it onto AstroDeck proof/trust primitives. |
|
|
||||||
| `/changelog` | `apps/website/src/pages/changelog.astro` | dated public progress surface | 215 | `src/content/pages/changelog.ts`; `src/content/changelog`; `PageHero`; `Card`; `changelog-core-ia.spec.ts` | adapt | Preserve the route and dated update behavior by adapting an AstroDeck content/news index. |
|
|
||||||
| `/contact` | `apps/website/src/pages/contact.astro` | primary conversion route | 213, 215 | `src/content/pages/contact.ts`; `ContactPanel`; `DemoPrompt`; `Input`; `Textarea`; `contact-legal.spec.ts` | adapt | Keep the route and working-session framing, but move the shell to AstroDeck contact primitives. |
|
|
||||||
| `/privacy` | `apps/website/src/pages/privacy.astro` | required legal surface | 213, 215 | `src/content/pages/privacy.ts`; `RichText`; `PageHero`; `contact-legal.spec.ts` | adapt | Preserve the route and legal intent; repurpose a generic AstroDeck legal shell. |
|
|
||||||
| `/imprint` | `apps/website/src/pages/imprint.astro` | canonical legal notice surface | 215 | `src/content/pages/imprint.ts`; `RichText`; `PageHero`; `contact-legal.spec.ts` | adapt | Keep the canonical route even if the AstroDeck snapshot only ships a generic legal/company page. |
|
|
||||||
| `/legal` | `apps/website/src/pages/legal.astro` | retained secondary legal hub | 213, 215 | `src/content/pages/legal.ts`; `RichText`; `Card`; `contact-legal.spec.ts` | adapt | Remains published as a secondary surface, not a primary-nav destination. |
|
|
||||||
| `/terms` | `apps/website/src/pages/terms.astro` | retained secondary legal surface | 213, 215 | `src/content/pages/terms.ts`; `RichText`; `PageHero`; `contact-legal.spec.ts` | adapt | Keep the route, but align it with the AstroDeck legal utility shell. |
|
|
||||||
| `/solutions` | `apps/website/src/pages/solutions.astro` | retained secondary audience-fit surface | 213, 215 | `src/content/pages/solutions.ts`; `AudienceRow`; `FeatureGrid`; `solutions-trust-integrations.spec.ts` | adapt | Keep available as a secondary surface if the mapped AstroDeck page remains substantive. |
|
|
||||||
| `/integrations` | `apps/website/src/pages/integrations.astro` | retained secondary ecosystem-fit surface | 213, 215 | `src/content/pages/integrations.ts`; `IntegrationBadge`; `FeatureGrid`; `solutions-trust-integrations.spec.ts` | adapt | Keep available as a secondary surface if the mapped AstroDeck content stays grounded in real integrations. |
|
|
||||||
| `/security-trust` | `apps/website/src/pages/security-trust.astro` | compatibility alias | 213, 215 | redirect only; `solutions-trust-integrations.spec.ts` | redirect | Preserve only as a compatibility redirect to `/trust`; do not rebuild as an independent AstroDeck page. |
|
|
||||||
|
|
||||||
## Component-Family Baseline
|
|
||||||
|
|
||||||
| Family | Current files | Current role | Forward candidate |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| layout | `Navbar`, `Footer`, `PageShell` | current shell, navigation, footer grouping | adapt via AstroDeck header/footer/page-shell primitives |
|
|
||||||
| primitives | `Badge`, `Button`, `Card`, `Cluster`, `Container`, `Grid`, `Input`, `Section`, `SectionHeader`, `Stack`, `Textarea` | local semantic building blocks | adapt or replace with AstroDeck component families re-skinned to Spec 214 |
|
|
||||||
| sections | `PageHero`, `CTASection`, `CapabilityGrid`, `FeatureGrid`, `OutcomeSection`, `ProgressTeaser`, `TrustGrid` | primary public-page assembly surfaces | adapt from AstroDeck hero, feature-grid, trust, changelog-teaser, and CTA-band sections |
|
|
||||||
| optional sections | `LogoStrip`, `HeroDashboard` | proof and product-near visual helpers in the current site | remove generic proof/demo variants unless the imported AstroDeck assets carry real, approved proof material |
|
|
||||||
| content primitives | `AudienceRow`, `Callout`, `ContactPanel`, `DemoPrompt`, `Eyebrow`, `FeatureItem`, `Headline`, `IntegrationBadge`, `Lead`, `Metric`, `PrimaryCTA`, `RichText`, `SecondaryCTA`, `TrustPrincipleCard` | current page-level copy and CTA helpers | selectively adapt only where the AstroDeck intake does not already provide the same semantic role |
|
|
||||||
|
|
||||||
## Content and Support Baseline
|
|
||||||
|
|
||||||
- Route-local content modules live under `apps/website/src/content/pages` and currently define the published copy contract for all 12 public routes.
|
|
||||||
- Astro content collections currently expose one published `changelog` entry, keep `resources` unpublished by gating, and keep `articles` unpublished entirely.
|
|
||||||
- `apps/website/src/pages/sitemap.xml.ts` is a support artifact for discoverability proof, not a public planning surface to rebuild directly.
|
|
||||||
|
|
||||||
## Smoke-Suite Baseline
|
|
||||||
|
|
||||||
| Smoke file | Current proof focus |
|
|
||||||
| --- | --- |
|
|
||||||
| `apps/website/tests/smoke/home-product.spec.ts` | homepage clarity, hero semantics, grouped capability model, trust/progress ordering, route reachability, and product-page narrative integrity |
|
|
||||||
| `apps/website/tests/smoke/solutions-trust-integrations.spec.ts` | secondary audience-fit surface, canonical trust posture, legacy trust redirect, and integrations-page credibility |
|
|
||||||
| `apps/website/tests/smoke/changelog-core-ia.spec.ts` | dated changelog proof plus optional/deferred-surface suppression |
|
|
||||||
| `apps/website/tests/smoke/contact-legal.spec.ts` | contact-path qualification, legal discoverability, footer links, and mobile navigation |
|
|
||||||
| `apps/website/tests/smoke/visual-foundation-guardrails.spec.ts` | shared CTA, badge, surface, and input semantics across representative pages |
|
|
||||||
|
|
||||||
## Baseline Conclusion
|
|
||||||
|
|
||||||
The current site already encodes the canonical route family that Specs 214, 215, 217, and 218 expect. Spec 223 keeps that truth visible, but treats the underlying page assembly, component family, and section composition as retired implementation that must be reintroduced through AstroDeck mapping instead of copied forward.
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
# Data Model: Website Reset and AstroDeck Rebuild
|
|
||||||
|
|
||||||
This feature does not introduce runtime persistence. The entities below define the planning artifacts that must exist for the rebuild workflow to remain reviewable and deterministic.
|
|
||||||
|
|
||||||
## Entity: LegacyWebsiteImplementation
|
|
||||||
|
|
||||||
- **Purpose**: Represents the current `apps/website` codebase as the retired implementation substrate.
|
|
||||||
- **Fields**:
|
|
||||||
- `scopePath`: repository path being retired, currently `apps/website`
|
|
||||||
- `status`: discarded implementation history
|
|
||||||
- `replacedBySpec`: spec reference that formally retires it, `223-astrodeck-website-rebuild`
|
|
||||||
- `notes`: short rationale for why it is no longer the active base
|
|
||||||
- **Relationships**:
|
|
||||||
- One `LegacyWebsiteImplementation` has many `LegacyTaskDisposition` records.
|
|
||||||
|
|
||||||
## Entity: CurrentWebsiteSurface
|
|
||||||
|
|
||||||
- **Purpose**: Captures the existing public routes and current website surfaces that must be reconciled during rebuild planning.
|
|
||||||
- **Fields**:
|
|
||||||
- `route`: public route, such as `/`, `/product`, `/trust`, `/contact`, `/legal`
|
|
||||||
- `sourceFile`: current Astro page file
|
|
||||||
- `surfaceRole`: current role in the website IA, such as core, secondary, legal, redirect, or optional
|
|
||||||
- `governingSpecs`: list of active website specs that currently constrain the surface
|
|
||||||
- `currentDependencies`: content collections, layout primitives, tests, or redirects tied to the surface
|
|
||||||
- `plannedDisposition`: keep, adapt, remove, or redirect
|
|
||||||
- **Relationships**:
|
|
||||||
- A `CurrentWebsiteSurface` may map to one or more `AstroDeckPrimitive` candidates through `PrimitiveMapping`.
|
|
||||||
|
|
||||||
## Entity: AstroDeckSourceIntake
|
|
||||||
|
|
||||||
- **Purpose**: Captures the reviewable AstroDeck source snapshot, intake constraints, and working assumptions before primitive mapping begins.
|
|
||||||
- **Fields**:
|
|
||||||
- `sourceSnapshotReference`: path, tag, archive name, or other stable reference for the imported AstroDeck source
|
|
||||||
- `intakeConstraints`: list of intake limitations, licensing boundaries, or review constraints
|
|
||||||
- `reviewAssumptions`: explicit assumptions used while building the primitive inventory and mappings
|
|
||||||
- `notes`: traceability details for reviewers
|
|
||||||
- **Relationships**:
|
|
||||||
- One `AstroDeckSourceIntake` may inform many `AstroDeckPrimitive` records.
|
|
||||||
|
|
||||||
## Entity: GoverningWebsiteSpec
|
|
||||||
|
|
||||||
- **Purpose**: Tracks each existing website spec that must be classified before rebuild work continues.
|
|
||||||
- **Fields**:
|
|
||||||
- `specId`: current spec number, initially one of 213, 214, 215, 217, 218
|
|
||||||
- `title`: short spec title
|
|
||||||
- `classification`: continuing, partially valid, or superseded
|
|
||||||
- `scopeSummary`: what part of the website the spec governs
|
|
||||||
- `followUpPlan`: reference to the per-spec mapping artifact or explicit supersession-closure artifact that owns current-slice delivery for this spec
|
|
||||||
- `rationale`: why the classification was chosen
|
|
||||||
- **Relationships**:
|
|
||||||
- One `GoverningWebsiteSpec` has many `PrimitiveMapping` records.
|
|
||||||
- One `GoverningWebsiteSpec` has many `LegacyTaskDisposition` records.
|
|
||||||
- One `GoverningWebsiteSpec` may have one `SupersessionClosure` when rebuild planning ends with an explicit closure instead of a mapping plan.
|
|
||||||
|
|
||||||
## Entity: SupersessionClosure
|
|
||||||
|
|
||||||
- **Purpose**: Captures the explicit closure artifact used when an in-scope website spec is classified as superseded and therefore does not continue into a mapping-based rebuild plan.
|
|
||||||
- **Fields**:
|
|
||||||
- `specId`: governing website spec being closed out
|
|
||||||
- `classification`: superseded
|
|
||||||
- `closureRationale`: why the spec no longer needs a rebuild mapping artifact
|
|
||||||
- `legacyTaskDispositionReference`: where the superseded legacy tasks were recorded
|
|
||||||
- `replacementReference`: replacement plan reference or explicit `no replacement work required` statement
|
|
||||||
- `notes`: traceability details for reviewers
|
|
||||||
- **Relationships**:
|
|
||||||
- One `SupersessionClosure` belongs to one `GoverningWebsiteSpec`.
|
|
||||||
|
|
||||||
## Entity: AstroDeckPrimitive
|
|
||||||
|
|
||||||
- **Purpose**: Represents a candidate AstroDeck page, section, or component available for rebuild mapping.
|
|
||||||
- **Fields**:
|
|
||||||
- `primitiveId`: stable identifier inside the imported AstroDeck inventory
|
|
||||||
- `primitiveType`: page, section, or component
|
|
||||||
- `sourceReference`: where the primitive came from inside the AstroDeck source snapshot
|
|
||||||
- `candidateSurfaces`: current routes or spec requirements it may satisfy
|
|
||||||
- `demoContentFlags`: whether it carries demo copy, demo media, or demo CTA behavior that may require removal
|
|
||||||
- `notes`: freeform adaptation concerns
|
|
||||||
- **Relationships**:
|
|
||||||
- One `AstroDeckPrimitive` may be referenced by many `PrimitiveMapping` records.
|
|
||||||
|
|
||||||
## Entity: PrimitiveMapping
|
|
||||||
|
|
||||||
- **Purpose**: Records how a governing website spec requirement maps to one AstroDeck primitive or falls through to a documented missing-candidate exception path, including the replacement task ownership captured inside the same per-spec artifact.
|
|
||||||
- **Fields**:
|
|
||||||
- `specId`: owning website spec
|
|
||||||
- `requirementReference`: spec requirement, acceptance point, or surface requirement being mapped
|
|
||||||
- `primitiveId`: referenced AstroDeck primitive when a concrete candidate exists; omitted when the disposition is `exception`
|
|
||||||
- `disposition`: keep, adapt, remove, or exception
|
|
||||||
- `adaptationSummary`: required changes to route, structure, styling, copy slots, or CTA behavior
|
|
||||||
- `acceptanceMapping`: how the mapped primitive satisfies the spec once adapted
|
|
||||||
- `replacementTasks`: ordered replacement task list owned by the same per-spec mapping or disposition artifact
|
|
||||||
- `taskPrimitiveReferences`: named AstroDeck pages, sections, components, or explicit mapping activities referenced by the replacement task list
|
|
||||||
- `materialDriftReferences`: references to updated or follow-up website specs required when AstroDeck adoption changes page inventory, CTA logic, navigation, or trust messaging
|
|
||||||
- `exceptionReference`: linked `DocumentedException` record when the disposition is `exception`
|
|
||||||
- **Relationships**:
|
|
||||||
- Many `PrimitiveMapping` records belong to one `GoverningWebsiteSpec`.
|
|
||||||
- Many `PrimitiveMapping` records may target one `AstroDeckPrimitive`.
|
|
||||||
- One `PrimitiveMapping` may have zero or one `DocumentedException`.
|
|
||||||
|
|
||||||
## Entity: ExceptionRegisterEntry
|
|
||||||
|
|
||||||
- **Purpose**: Captures the review outcome for each missing-candidate check so the rebuild keeps a standing register of approved exceptions and explicit no-exception outcomes.
|
|
||||||
- **Fields**:
|
|
||||||
- `specId`: governing website spec under review
|
|
||||||
- `scope`: page, section, component, or route slice being checked
|
|
||||||
- `reviewOutcome`: approved exception or no exception required
|
|
||||||
- `reviewRationale`: why the review produced that outcome
|
|
||||||
- `exceptionReference`: linked `DocumentedException` when the outcome is approved exception
|
|
||||||
- `notes`: traceability details for reviewers
|
|
||||||
- **Relationships**:
|
|
||||||
- Many `ExceptionRegisterEntry` records belong to one `GoverningWebsiteSpec`.
|
|
||||||
- One `ExceptionRegisterEntry` may reference zero or one `DocumentedException`.
|
|
||||||
|
|
||||||
## Entity: MaterialDriftFollowUp
|
|
||||||
|
|
||||||
- **Purpose**: Captures any material page inventory, CTA logic, navigation, or trust messaging change discovered during mapping so it becomes an explicit spec update instead of silent template drift.
|
|
||||||
- **Fields**:
|
|
||||||
- `affectedSpecId`: governing website spec whose truth must be updated or followed up
|
|
||||||
- `driftClass`: page inventory, CTA logic, navigation, or trust messaging
|
|
||||||
- `driftSummary`: concise description of what changed materially
|
|
||||||
- `requiredSpecAction`: update existing spec or create follow-up spec
|
|
||||||
- `targetSpecReference`: path to the existing spec or named follow-up spec that owns the change
|
|
||||||
- `notes`: rationale and traceability details
|
|
||||||
- **Relationships**:
|
|
||||||
- Many `MaterialDriftFollowUp` records may belong to one `GoverningWebsiteSpec`.
|
|
||||||
|
|
||||||
## Entity: LegacyTaskDisposition
|
|
||||||
|
|
||||||
- **Purpose**: Preserves old website implementation task history while marking it as no longer authoritative.
|
|
||||||
- **Fields**:
|
|
||||||
- `originalSpecId`: spec that owned the original task
|
|
||||||
- `originalTaskReference`: stable task identifier or short description
|
|
||||||
- `disposition`: `superseded by AstroDeck rebuild`
|
|
||||||
- `replacementReference`: new plan, spec, or task set that replaces the old task
|
|
||||||
- `notes`: why the original task cannot simply be reopened
|
|
||||||
- **Relationships**:
|
|
||||||
- Many `LegacyTaskDisposition` records belong to one `LegacyWebsiteImplementation`.
|
|
||||||
- Many `LegacyTaskDisposition` records may be associated with one `GoverningWebsiteSpec`.
|
|
||||||
|
|
||||||
## Entity: DocumentedException
|
|
||||||
|
|
||||||
- **Purpose**: Captures the narrow approved cases where no adequate AstroDeck primitive exists and custom work is justified, stored as the approved-exception detail inside the exception register.
|
|
||||||
- **Fields**:
|
|
||||||
- `scope`: page, section, component, or route slice that needs an exception
|
|
||||||
- `missingCandidate`: statement of which AstroDeck search failed
|
|
||||||
- `unmetRequirement`: the active website-spec requirement that could not be satisfied
|
|
||||||
- `adequacyFailureReason`: why keep or bounded adaptation could not satisfy the requirement
|
|
||||||
- `boundedDeviation`: precise custom work being allowed
|
|
||||||
- `approvedBy`: named website reviewer, owner, or equivalent feature approver
|
|
||||||
- `approvalReference`: spec, review note, or task entry that owns the exception
|
|
||||||
- `spreadControl`: explicit statement of why the exception stays local
|
|
||||||
- **Relationships**:
|
|
||||||
- One `DocumentedException` belongs to one `PrimitiveMapping`.
|
|
||||||
- One `DocumentedException` is represented by one approved `ExceptionRegisterEntry`.
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
- Every continuing or partially valid `GoverningWebsiteSpec` must have at least one `PrimitiveMapping` before implementation starts.
|
|
||||||
- Every `CurrentWebsiteSurface` must end the planning phase with an explicit disposition.
|
|
||||||
- Every `LegacyTaskDisposition` must point to a replacement reference or explicitly state that no replacement work is required.
|
|
||||||
- Every `DocumentedException` must name the failed AstroDeck candidate search, the unmet requirement, the adequacy failure reason, the bounded deviation, and the named approver.
|
|
||||||
- Every material page inventory, CTA logic, navigation, or trust messaging change discovered during mapping must create a `MaterialDriftFollowUp` record that points to an updated or follow-up website spec.
|
|
||||||
- Every missing-candidate review must create an `ExceptionRegisterEntry` with outcome `approved exception` or `no exception required`.
|
|
||||||
- Every replacement task list must name the relevant AstroDeck page, section, component, or mapping activity instead of using generic build wording.
|
|
||||||
- No `PrimitiveMapping` may use `exception` as its disposition without a linked `ExceptionRegisterEntry` and `DocumentedException`.
|
|
||||||
|
|
||||||
## State Transitions
|
|
||||||
|
|
||||||
- `GoverningWebsiteSpec.classification`: `unreviewed -> continuing | partially valid | superseded`
|
|
||||||
- `CurrentWebsiteSurface.plannedDisposition`: `discovered -> keep | adapt | remove | redirect`
|
|
||||||
- `PrimitiveMapping.disposition`: `candidate -> keep | adapt | remove | exception`
|
|
||||||
- `ExceptionRegisterEntry.reviewOutcome`: `reviewed -> approved exception | no exception required`
|
|
||||||
- `DocumentedException`: `gap identified -> approved exception record`
|
|
||||||
- `LegacyTaskDisposition.disposition`: `historical -> superseded by AstroDeck rebuild`
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- These entities are planning artifacts only. They do not imply new database tables, application models, or runtime services.
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
# Exception Register
|
|
||||||
|
|
||||||
This register defines the only allowed path for non-AstroDeck primitives. Until an entry is approved here, custom rebuild work is out of bounds.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. Search the AstroDeck alias inventory before proposing any custom page, section, or component.
|
|
||||||
2. Record the candidate aliases reviewed and explain why keep or bounded adaptation is or is not sufficient.
|
|
||||||
3. If one or more aliases can satisfy the need without inventing a new IA contract or unsupported interaction model, record `no exception required`.
|
|
||||||
4. If all viable aliases fail, record the unmet requirement, the failed candidate search, the bounded deviation, the named approver, and the owning follow-up task.
|
|
||||||
5. Keep every approved exception local to one page, section, or component slice. Do not generalize it into a new default primitive family.
|
|
||||||
|
|
||||||
## Adequacy Rubric
|
|
||||||
|
|
||||||
Use `no exception required` when the AstroDeck candidate family can satisfy the active requirement through bounded adaptation across all of the checks below:
|
|
||||||
|
|
||||||
- It preserves the active route and IA contract from Specs 213, 215, 217, or 218.
|
|
||||||
- It preserves CTA hierarchy, trust-boundary rules, and mobile meaning order without adding a new interaction model.
|
|
||||||
- It avoids shipping demo-only proof, fake logos, invented metrics, or placeholder legal/trust content.
|
|
||||||
- It can be re-skinned to the Spec 214 foundation without introducing a second visual system.
|
|
||||||
|
|
||||||
Use `approved exception` only when every candidate fails one or more of those checks and the failure cannot be resolved by bounded adaptation.
|
|
||||||
|
|
||||||
## Required Evidence for Approved Exceptions
|
|
||||||
|
|
||||||
- failed AstroDeck candidate search
|
|
||||||
- unmet active-spec requirement
|
|
||||||
- adequacy failure reason for each reviewed candidate
|
|
||||||
- bounded deviation being approved
|
|
||||||
- named website reviewer, owner, or equivalent feature approver
|
|
||||||
- replacement task or mapping entry that owns the deviation
|
|
||||||
- spread-control note explaining why the deviation does not create a new default
|
|
||||||
|
|
||||||
## Approval Boundary
|
|
||||||
|
|
||||||
- Minimum approver: named website reviewer or feature owner
|
|
||||||
- Preferred approver for IA or trust changes: spec author or feature owner responsible for Specs 215, 217, or 218
|
|
||||||
- Re-review trigger: if the mounted AstroDeck snapshot disproves the intake aliases currently recorded in `astrodeck-primitive-inventory.md`
|
|
||||||
|
|
||||||
## Register Entries
|
|
||||||
|
|
||||||
| specId | scope | reviewOutcome | reviewRationale | exceptionReference | notes |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| 213 | site-foundation shell, core routes, contact/legal baseline | no exception required | The alias set already covers landing, product, trust, contact, legal utility, header/footer, and CTA primitives. The main drift is route emphasis, not missing primitive families. | none | Spread control: route drift is handled by the Spec 213 mapping sheet and the drift ledger, not by inventing new primitives. |
|
|
||||||
| 214 | visual foundation tokens, surfaces, CTA/input semantics | no exception required | AstroDeck page, section, and component families can be re-skinned to the website-local foundation without inventing a second design system. | none | Acceptance trace: all work must route through `mappings/spec-214-website-visual-foundation.md`. |
|
|
||||||
| 215 | core IA, trust/changelog/contact/legal surfaces, retained secondary routes | no exception required | Generic home, product, proof, content-index, contact, and legal utility aliases are sufficient when demo routes are suppressed. | none | Spread control: any later missing route family must re-open this register before a custom page is approved. |
|
|
||||||
| 217 | homepage section order, trust/progress placement, CTA transition | no exception required | Hero, outcome, feature-cluster, trust, changelog-teaser, and CTA-band aliases cover the homepage contract. | none | Acceptance trace: remove optional proof sections instead of replacing the homepage with a custom greenfield assembly. |
|
|
||||||
| 218 | hero CTA pair, product-near media, bounded trust cues, mobile order | no exception required | The split hero family plus button and badge primitives are adequate once demo media and fake proof are stripped. | none | Spread control: a later hero exception would need to show why the split-media family cannot preserve the existing hero contract. |
|
|
||||||
|
|
||||||
## Approved Exception Records
|
|
||||||
|
|
||||||
None as of 2026-04-22.
|
|
||||||
|
|
||||||
## Standing Rule
|
|
||||||
|
|
||||||
No follow-up implementation slice may introduce a net-new page, section, or component by default. If the mounted AstroDeck snapshot later proves one of the alias families wrong, that mismatch must reopen this register before implementation continues.
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
# Governing Website Spec Classification
|
|
||||||
|
|
||||||
This crosswalk classifies the in-scope website specs before AstroDeck implementation begins and records the follow-up owner for each one.
|
|
||||||
|
|
||||||
## Classification Summary
|
|
||||||
|
|
||||||
| specId | Title | Classification | scopeSummary | rationale | followUpPlan |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| 213 | Initial Website Foundation & v0 Product Site | partially valid | broad v0 public-site truth, shared shell expectations, contact/legal baseline, trust-first positioning | The broad public-site intent remains useful, but the original route inventory and trust-route naming no longer match the canonical IA recorded in later specs. | `specs/223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md` |
|
|
||||||
| 214 | Website Visual Foundation | continuing | website-only visual direction, tokens, typography, surfaces, CTA/input semantics | AstroDeck changes the implementation substrate, not the website-local visual contract. | `specs/223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md` |
|
|
||||||
| 215 | Website Information Architecture / Core Pages | continuing | canonical public IA, route priorities, trust/changelog/contact/legal reachability | The current route truth already reflects this spec and must constrain AstroDeck adoption. | `specs/223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md` |
|
|
||||||
| 217 | Website Homepage Structure & Section Model | continuing | homepage block order, trust/progress placement, CTA sequencing, onward routing | AstroDeck must adapt to the homepage contract instead of redefining it. | `specs/223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md` |
|
|
||||||
| 218 | Website Homepage Hero | continuing | homepage hero semantics, CTA pair, product-near visual truth, bounded trust cues | AstroDeck hero primitives are acceptable only when they preserve the existing hero contract. | `specs/223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md` |
|
|
||||||
|
|
||||||
## Current-Surface Crosswalk
|
|
||||||
|
|
||||||
| Surface or concern | 213 | 214 | 215 | 217 | 218 | Forward owner |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| `/` homepage route | X | X | X | X | X | `mappings/spec-217-homepage-structure.md` and `mappings/spec-218-homepage-hero.md`, with 214 and 215 as constraints |
|
|
||||||
| `/product` | X | X | X | | | `mappings/spec-213-website-foundation-v0.md` and `mappings/spec-215-website-core-pages.md` |
|
|
||||||
| `/trust` plus `/security-trust` compatibility | X | X | X | X | X | `mappings/spec-215-website-core-pages.md` and `mappings/spec-213-website-foundation-v0.md` |
|
|
||||||
| `/changelog` | | | X | X | | `mappings/spec-215-website-core-pages.md` |
|
|
||||||
| `/contact` | X | X | X | X | X | `mappings/spec-213-website-foundation-v0.md` and `mappings/spec-215-website-core-pages.md` |
|
|
||||||
| `/privacy`, `/imprint`, `/terms`, `/legal` | X | X | X | | | `mappings/spec-213-website-foundation-v0.md` and `mappings/spec-215-website-core-pages.md` |
|
|
||||||
| `/solutions` and `/integrations` as retained secondary surfaces | X | X | X | | | `mappings/spec-215-website-core-pages.md` |
|
|
||||||
| shared visual system, shells, CTA/input semantics | X | X | X | X | X | `mappings/spec-214-website-visual-foundation.md` |
|
|
||||||
|
|
||||||
## Final Follow-up Execution Order
|
|
||||||
|
|
||||||
1. AstroDeck intake binding and primitive verification using `astrodeck-source-intake.md` and `astrodeck-primitive-inventory.md`.
|
|
||||||
2. Conditional foundation slice owned by `mappings/spec-213-website-foundation-v0.md`, because Spec 213 remains partially valid and still governs broad public-site truth.
|
|
||||||
3. Shared visual-adaptation slice owned by `mappings/spec-214-website-visual-foundation.md`.
|
|
||||||
4. Canonical IA and route-mapping slice owned by `mappings/spec-215-website-core-pages.md`.
|
|
||||||
5. Homepage section-composition slice owned by `mappings/spec-217-homepage-structure.md`.
|
|
||||||
6. Homepage hero-refinement slice owned by `mappings/spec-218-homepage-hero.md`.
|
|
||||||
|
|
||||||
## Execution Notes
|
|
||||||
|
|
||||||
- Steps 3 and 4 can overlap once the AstroDeck aliases are bound, but route ownership from Spec 215 must settle before any homepage-only AstroDeck assembly lands.
|
|
||||||
- Steps 5 and 6 share homepage files and therefore stay sequential after the IA slice has fixed the canonical route shell.
|
|
||||||
- Any imported AstroDeck demo page that tries to introduce new top-level IA must be handled as removal or suppression work in the owning mapping sheet, not as silent scope growth.
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
# Legacy Task Disposition
|
|
||||||
|
|
||||||
The task files below remain visible as historical execution records, but they are no longer the forward implementation source for `apps/website`.
|
|
||||||
|
|
||||||
## Historical Marker
|
|
||||||
|
|
||||||
Every affected legacy task file now carries the canonical marker:
|
|
||||||
|
|
||||||
`superseded by AstroDeck rebuild`
|
|
||||||
|
|
||||||
That marker preserves the completed checkbox history while making it clear that new delivery belongs to the Spec 223 mapping artifacts instead of the old implementation plans.
|
|
||||||
|
|
||||||
## Replacement References
|
|
||||||
|
|
||||||
| originalSpecId | originalTaskReference | disposition | replacementReference | notes |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| 213 | `specs/213-website-foundation-v0/tasks.md` (`T001-T033`) | superseded by AstroDeck rebuild | `specs/223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md` | The original v0-site build shipped on the custom Astro substrate. Future work must restart from AstroDeck while preserving the public-site truth. |
|
|
||||||
| 214 | `specs/214-website-visual-foundation/tasks.md` (`T001-T021`) | superseded by AstroDeck rebuild | `specs/223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md` | The current website foundation remains valid as a design rule set, but its implementation record is historical. |
|
|
||||||
| 215 | `specs/215-website-core-pages/tasks.md` (`T001-T022`) | superseded by AstroDeck rebuild | `specs/223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md` | Route truth remains authoritative; the old implementation task list does not. |
|
|
||||||
| 217 | `specs/217-homepage-structure/tasks.md` (`T001-T023`) | superseded by AstroDeck rebuild | `specs/223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md` | Homepage structure truth stays active, but the existing section assembly is not the forward substrate. |
|
|
||||||
| 218 | `specs/218-homepage-hero/tasks.md` (`T001-T016`) | superseded by AstroDeck rebuild | `specs/223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md` | Hero semantics remain active; the existing hero implementation record is historical only. |
|
|
||||||
|
|
||||||
## Interpretation Rule
|
|
||||||
|
|
||||||
- Do not reset or reopen checkbox state in the legacy task files.
|
|
||||||
- Do not continue delivery from the custom `apps/website` implementation plan.
|
|
||||||
- Use the replacement mapping sheet for all forward task planning, acceptance tracing, and exception review.
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
# Mapping: Spec 213 - Initial Website Foundation & v0 Product Site
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| classification | partially valid |
|
|
||||||
| follow-up owner | `specs/223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md` |
|
|
||||||
| legacy task file | `specs/213-website-foundation-v0/tasks.md` |
|
|
||||||
| exception outcome | no exception required |
|
|
||||||
| material drift references | `223-DRIFT-213-ia`, `223-DRIFT-213-nav`, `223-DRIFT-213-trust` |
|
|
||||||
|
|
||||||
Spec 213 remains useful as the broad v0 public-site contract, but its original route emphasis predates the later canonical IA. This mapping sheet keeps the surviving truth while moving all implementation ownership onto AstroDeck.
|
|
||||||
|
|
||||||
## Mapping Records
|
|
||||||
|
|
||||||
| Requirement reference | Candidate primitive(s) | Disposition | Adaptation summary | Acceptance mapping | materialDriftReferences | exceptionReference |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| Core public-site shell, header/footer reachability, and one coherent page family | `adk-component-header-nav`, `adk-component-footer-nav`, `adk-component-section-shell` | adapt | Collapse AstroDeck navigation to Product, Trust, Changelog, and Contact plus one primary CTA; preserve footer legal grouping; keep repo-level website contracts intact. | 213 FR-002, FR-003, FR-010, FR-011, FR-017 | `223-DRIFT-213-nav` | none |
|
|
||||||
| First-pass product explanation across Home and Product | `adk-page-home-marketing`, `adk-page-product-overview`, `adk-section-outcome-band`, `adk-section-feature-cluster-grid` | adapt | Keep the trust-first governance story, but replace the current custom page assembly with AstroDeck landing and product shells. | 213 FR-001, FR-004, FR-005, FR-012, FR-016 | none | none |
|
|
||||||
| Trust, contact, and legal baseline | `adk-page-trust-proof`, `adk-page-contact-conversion`, `adk-page-legal-utility`, `adk-section-trust-principles`, `adk-section-contact-form` | adapt | Route trust to `/trust`, preserve `/contact` as the primary next step, and repurpose legal/company utility pages for privacy, imprint, terms, and the retained legal hub. | 213 FR-007, FR-008, FR-009, FR-010 | `223-DRIFT-213-trust` | none |
|
|
||||||
| Secondary pages and compatibility behavior | `adk-page-supporting-showcase`, `adk-page-content-index`, `adk-page-trust-proof` | adapt | Keep `/solutions`, `/integrations`, `/legal`, and `/terms` published as secondary surfaces; preserve `/security-trust` as a redirect only; add `/changelog` and `/imprint` through adapted content/legal shells. | 213 SC-002, SC-004, plus the Spec 223 rebuild rule for preserved history | `223-DRIFT-213-ia` | none |
|
|
||||||
|
|
||||||
## Replacement Tasks
|
|
||||||
|
|
||||||
1. Bind the AstroDeck aliases for the landing shell, product shell, trust proof page, contact page, and legal utility page before any code is copied out of the current `apps/website` implementation.
|
|
||||||
2. Adapt `adk-component-header-nav` and `adk-component-footer-nav` to the canonical Product/Trust/Changelog/Contact navigation model with retained legal footer grouping.
|
|
||||||
3. Map `adk-page-home-marketing` and `adk-page-product-overview` to the v0 product-story surfaces without carrying over AstroDeck demo proof, pricing, or newsletter behavior.
|
|
||||||
4. Repurpose `adk-page-trust-proof`, `adk-page-contact-conversion`, and `adk-page-legal-utility` for `/trust`, `/contact`, `/privacy`, `/imprint`, `/terms`, and `/legal`.
|
|
||||||
5. Add a route-suppression pass that removes AstroDeck demo pages from top-level discoverability and keeps `/security-trust` as a compatibility redirect only.
|
|
||||||
6. Re-run the Spec 213 acceptance trace against the later canonical IA so the broad v0 truth remains visible without overriding Specs 214, 215, 217, and 218.
|
|
||||||
|
|
||||||
## Spread Control and Acceptance Trace
|
|
||||||
|
|
||||||
- Spread control: this mapping sheet does not authorize any new public IA beyond the canonical route family already documented in the current-site inventory and Spec 215.
|
|
||||||
- Acceptance trace: broad v0-site requirements remain owned here, but route inventory, navigation, and trust-route naming defer to the later specs and the drift ledger.
|
|
||||||
- Exception note: no exception is approved. If the mounted AstroDeck snapshot lacks a usable legal utility page or trust-proof page, the missing family must reopen `exception-register.md`.
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
# Mapping: Spec 214 - Website Visual Foundation
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| classification | continuing |
|
|
||||||
| follow-up owner | `specs/223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md` |
|
|
||||||
| legacy task file | `specs/214-website-visual-foundation/tasks.md` |
|
|
||||||
| exception outcome | no exception required |
|
|
||||||
| material drift references | none |
|
|
||||||
|
|
||||||
Spec 214 remains the controlling visual contract. AstroDeck may accelerate the rebuild, but it may not impose a second visual system or a generic startup aesthetic.
|
|
||||||
|
|
||||||
## Mapping Records
|
|
||||||
|
|
||||||
| Requirement reference | Candidate primitive(s) | Disposition | Adaptation summary | Acceptance mapping | materialDriftReferences | exceptionReference |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| Color roles, surfaces, border-first clarity, and restrained elevation | `adk-component-section-shell`, `adk-component-card-surface`, `adk-component-badge-chip` | adapt | Map AstroDeck colors, borders, and surface layers to the website-local token model; strip decorative gradients, glass, and vanity shadows. | 214 FR-003, FR-004, FR-008, FR-009, FR-015 | none | none |
|
|
||||||
| Typography hierarchy, page rhythm, and progressive disclosure | `adk-section-hero-split-media`, `adk-section-outcome-band`, `adk-section-feature-cluster-grid` | adapt | Rebuild heading scale, section spacing, and copy rhythm around the Spec 214 hierarchy instead of vendor defaults. | 214 FR-005, FR-006, FR-007, FR-013, FR-016 | none | none |
|
|
||||||
| CTA, link, and form semantics | `adk-component-primary-button`, `adk-component-secondary-button`, `adk-component-input-field`, `adk-component-textarea-field` | adapt | Preserve one dominant CTA, lower-emphasis secondary actions, shared focus logic, and calm form styling. | 214 FR-010, FR-012, FR-015, FR-017 | none | none |
|
|
||||||
| Shared shell and page-family consistency across landing, trust, and content routes | `adk-component-header-nav`, `adk-component-footer-nav`, `adk-section-footer-utility`, `adk-page-home-marketing`, `adk-page-trust-proof`, `adk-page-legal-utility` | adapt | Keep one website-only visual language across landing, trust/legal, and content-heavy surfaces; no platform coupling and no raw library styling. | 214 FR-001, FR-002, FR-011, FR-018, FR-019, FR-020 | none | none |
|
|
||||||
|
|
||||||
## Replacement Tasks
|
|
||||||
|
|
||||||
1. Inventory AstroDeck design tokens, utility classes, and section-shell defaults, then bind them to the Spec 214 role model before any page-level restyling starts.
|
|
||||||
2. Adapt `adk-component-section-shell`, `adk-component-card-surface`, and `adk-component-badge-chip` to the website-local surface, border, radius, and contrast rules.
|
|
||||||
3. Re-skin `adk-component-primary-button`, `adk-component-secondary-button`, `adk-component-input-field`, and `adk-component-textarea-field` to match the CTA and form semantics already defined by Spec 214.
|
|
||||||
4. Rebuild AstroDeck header/footer typography, spacing, and nav emphasis so landing, trust, and content page families read as one website.
|
|
||||||
5. Remove or neutralize AstroDeck visual defaults that drift into glass, loud gradients, decorative shadows, or badge-heavy proof theater.
|
|
||||||
6. Re-verify the mapped primitives against representative landing, trust/legal, and content-heavy surfaces before homepage-specific work lands.
|
|
||||||
|
|
||||||
## Spread Control and Acceptance Trace
|
|
||||||
|
|
||||||
- Spread control: all styling work must route through the website-local token and primitive model; no raw AstroDeck styling is allowed to remain as a second standard.
|
|
||||||
- Acceptance trace: the mapped primitives cover the required design token set, typography hierarchy, surface model, interaction semantics, and page-family review rules from Spec 214.
|
|
||||||
- Exception note: no exception is approved. A later exception would need to prove that the mounted AstroDeck snapshot lacks a usable button, form, card, or shell family entirely.
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
# Mapping: Spec 215 - Website Information Architecture / Core Pages
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| classification | continuing |
|
|
||||||
| follow-up owner | `specs/223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md` |
|
|
||||||
| legacy task file | `specs/215-website-core-pages/tasks.md` |
|
|
||||||
| exception outcome | no exception required |
|
|
||||||
| material drift references | none |
|
|
||||||
|
|
||||||
Spec 215 is the canonical IA source of truth. AstroDeck may only be adopted in ways that preserve this route model, page priority, and navigation discipline.
|
|
||||||
|
|
||||||
## Mapping Records
|
|
||||||
|
|
||||||
| Requirement reference | Candidate primitive(s) | Disposition | Adaptation summary | Acceptance mapping | materialDriftReferences | exceptionReference |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| Required core routes: `/`, `/product`, `/trust`, `/changelog`, `/contact`, `/privacy`, `/imprint` | `adk-page-home-marketing`, `adk-page-product-overview`, `adk-page-trust-proof`, `adk-page-content-index`, `adk-page-contact-conversion`, `adk-page-legal-utility` | adapt | Bind each canonical route to an AstroDeck page family without inheriting template route names or page priorities. | 215 FR-002, FR-003, FR-006, FR-008, FR-010, FR-012, FR-013 | none | none |
|
|
||||||
| Small primary navigation plus trust/legal footer discoverability | `adk-component-header-nav`, `adk-component-footer-nav`, `adk-section-footer-utility` | adapt | Keep top-level navigation intentionally small and group footer trust/legal/contact links according to Spec 215. | 215 FR-014, FR-015, FR-017, FR-018, FR-019, FR-025 | none | none |
|
|
||||||
| Optional and deferred surfaces | `adk-page-content-index`, `adk-page-supporting-showcase`, `adk-section-logo-strip`, `adk-section-testimonial-stack` | remove | Suppress template pricing, docs, case-study, resource-hub, logo-cloud, and testimonial promotion until an active spec turns them on. | 215 FR-004, FR-005, FR-016, FR-021, FR-022, FR-024 | none | none |
|
|
||||||
| Retained secondary routes and compatibility behavior | `adk-page-supporting-showcase`, `adk-page-legal-utility`, `adk-page-trust-proof` | adapt | Keep `/legal`, `/terms`, `/solutions`, and `/integrations` published as secondary surfaces; preserve `/security-trust` as a redirect to `/trust`. | 215 FR-011, FR-020, FR-023, FR-027 | none | none |
|
|
||||||
|
|
||||||
## Replacement Tasks
|
|
||||||
|
|
||||||
1. Bind AstroDeck page aliases for home, product, trust/proof, content index, contact, and legal utility before route-level implementation starts.
|
|
||||||
2. Adapt `adk-component-header-nav` so primary discoverability remains Product, Trust, Changelog, and Contact with one CTA only.
|
|
||||||
3. Repurpose `adk-page-content-index` into `/changelog` and keep optional `Resources` or editorial surfaces unpublished unless substantive content exists.
|
|
||||||
4. Adapt `adk-page-legal-utility` for `/privacy`, `/imprint`, `/terms`, and the retained `/legal` hub without shipping template legal copy.
|
|
||||||
5. Keep `adk-page-supporting-showcase` available only for `/solutions` and `/integrations`, and do not let those routes displace the required core IA.
|
|
||||||
6. Add an AstroDeck route-suppression pass for pricing, docs, case-study, resource, team, and newsletter surfaces that are not yet active.
|
|
||||||
7. Preserve `/security-trust` as redirect-only behavior once `/trust` is mapped to the canonical proof page.
|
|
||||||
|
|
||||||
## Spread Control and Acceptance Trace
|
|
||||||
|
|
||||||
- Spread control: no AstroDeck route becomes publicly discoverable unless Spec 215 already classifies it as required, retained secondary, or approved optional.
|
|
||||||
- Acceptance trace: the mapping above preserves the required core routes, the small top-level navigation, trust visibility, changelog visibility, and contact primacy.
|
|
||||||
- Exception note: no exception is approved. A future exception would need to show that the mounted snapshot lacks a usable content-index or legal-utility family entirely.
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
# Mapping: Spec 217 - Website Homepage Structure & Section Model
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| classification | continuing |
|
|
||||||
| follow-up owner | `specs/223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md` |
|
|
||||||
| legacy task file | `specs/217-homepage-structure/tasks.md` |
|
|
||||||
| exception outcome | no exception required |
|
|
||||||
| material drift references | none |
|
|
||||||
|
|
||||||
Spec 217 remains the homepage structure contract. AstroDeck can supply section families, but it may not reorder the homepage into a generic feature wall or proof-heavy template.
|
|
||||||
|
|
||||||
## Mapping Records
|
|
||||||
|
|
||||||
| Requirement reference | Candidate primitive(s) | Disposition | Adaptation summary | Acceptance mapping | materialDriftReferences | exceptionReference |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| Hero to outcome flow | `adk-section-hero-split-media`, `adk-section-outcome-band` | adapt | Keep the homepage order of hero first, outcome framing second, and route visitors into a product-near reading path immediately. | 217 FR-001, FR-002, FR-003, FR-004, FR-008 | none | none |
|
|
||||||
| Grouped capability model instead of route-job cards | `adk-section-feature-cluster-grid` | adapt | Use grouped capability clusters that route deeper explanation to `/product` instead of equal-weight marketing cards. | 217 FR-009, FR-010 | none | none |
|
|
||||||
| Trust and progress before the final CTA | `adk-section-trust-principles`, `adk-section-changelog-teaser`, `adk-section-cta-band` | adapt | Keep trust and visible product movement ahead of the closing CTA and route them to `/trust` and `/changelog`. | 217 FR-011, FR-012, FR-013, FR-014, FR-018 | none | none |
|
|
||||||
| Optional proof sections that risk fake maturity | `adk-section-proof-stats`, `adk-section-logo-strip`, `adk-section-testimonial-stack` | remove | Remove by default unless the team has approved, real, public-safe proof material. | 217 FR-017, FR-020 | none | none |
|
|
||||||
| Header/footer discoverability and mobile continuity | `adk-component-header-nav`, `adk-component-footer-nav`, `adk-section-footer-utility` | adapt | Preserve the published route set and keep the same meaning order on narrow screens. | 217 FR-005, FR-006, FR-015, FR-016, FR-019, FR-021 | none | none |
|
|
||||||
|
|
||||||
## Replacement Tasks
|
|
||||||
|
|
||||||
1. Adapt `adk-section-hero-split-media` into the homepage entry point without letting AstroDeck proof strips or testimonial blocks appear above the product explanation.
|
|
||||||
2. Adapt `adk-section-outcome-band` so buyer-oriented outcomes appear before any capability cluster or proof section.
|
|
||||||
3. Rebuild `adk-section-feature-cluster-grid` into grouped capability coverage that routes to `/product` instead of acting like a route list.
|
|
||||||
4. Place `adk-section-trust-principles` and `adk-section-changelog-teaser` ahead of the closing `adk-section-cta-band`.
|
|
||||||
5. Remove `adk-section-proof-stats`, `adk-section-logo-strip`, and `adk-section-testimonial-stack` unless real proof assets are explicitly approved later.
|
|
||||||
6. Re-check the homepage route transitions, footer legal reachability, and mobile section order after the mapped sections are in place.
|
|
||||||
|
|
||||||
## Spread Control and Acceptance Trace
|
|
||||||
|
|
||||||
- Spread control: homepage-only structure work may not pull in extra AstroDeck sections simply because they are visually available.
|
|
||||||
- Acceptance trace: the mapping preserves the required homepage block set, section order, trust/progress placement, and onward routing from Spec 217.
|
|
||||||
- Exception note: no exception is approved. If the mounted snapshot lacks a usable outcome or changelog-teaser family, reopen the exception workflow before composing a net-new homepage section.
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
# Mapping: Spec 218 - Website Homepage Hero
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| classification | continuing |
|
|
||||||
| follow-up owner | `specs/223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md` |
|
|
||||||
| legacy task file | `specs/218-homepage-hero/tasks.md` |
|
|
||||||
| exception outcome | no exception required |
|
|
||||||
| material drift references | none |
|
|
||||||
|
|
||||||
Spec 218 keeps the hero contract narrow and semantic. AstroDeck may only be used as a hero substrate when it preserves one clear anchor, one CTA pair, product-near truth, and bounded trust cues.
|
|
||||||
|
|
||||||
## Mapping Records
|
|
||||||
|
|
||||||
| Requirement reference | Candidate primitive(s) | Disposition | Adaptation summary | Acceptance mapping | materialDriftReferences | exceptionReference |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| Hero text core: category context, headline, supporting copy | `adk-section-hero-split-media` | adapt | Keep one clear product category cue, one headline, and one supporting-copy block; remove stacked marketing slogans and filler text. | 218 FR-001, FR-002, FR-005, FR-006, FR-007, FR-013, FR-014 | none | none |
|
|
||||||
| One dominant primary CTA plus one lower-emphasis secondary CTA | `adk-component-primary-button`, `adk-component-secondary-button` | adapt | Preserve the Contact-first action with one deepening CTA and remove any additional equal-weight buttons. | 218 FR-008, FR-009, FR-016 | none | none |
|
|
||||||
| Product-near visual truth | `adk-section-hero-split-media`, `adk-component-card-surface` | adapt | Replace generic analytics wallpaper with a governance-specific product visual or truthful placeholder tied to change history, review, restore, or drift. | 218 FR-010, FR-011, FR-015, FR-020 | none | none |
|
|
||||||
| Optional bounded trust chips | `adk-component-badge-chip` | adapt | Reduce AstroDeck hero chips to a small set of factual, supportable trust cues. | 218 FR-012, FR-013 | none | none |
|
|
||||||
| Anti-pattern removal and mobile meaning order | `adk-section-proof-stats`, `adk-section-logo-strip`, `adk-section-testimonial-stack`, `adk-section-hero-split-media` | remove | Remove badge walls, fake proof, and extra CTA pressure; preserve headline, copy, CTA, visual, and optional trust chips on mobile. | 218 FR-017, FR-018, FR-019, FR-022 | none | none |
|
|
||||||
|
|
||||||
## Replacement Tasks
|
|
||||||
|
|
||||||
1. Bind `adk-section-hero-split-media` to the homepage hero and reduce it to the allowed semantic structure before any art-direction pass starts.
|
|
||||||
2. Adapt `adk-component-primary-button` and `adk-component-secondary-button` into the Contact-first CTA pair with no competing primary actions.
|
|
||||||
3. Replace AstroDeck demo hero imagery with a governance-specific product visual or a truthful approximation derived from real product structure.
|
|
||||||
4. Reduce `adk-component-badge-chip` usage to a small bounded trust-subclaim set that routes deeper context to `/trust`.
|
|
||||||
5. Remove hero-adjacent proof stats, logo strips, testimonials, and extra CTA blocks that dilute the primary anchor.
|
|
||||||
6. Verify the mapped hero preserves headline-first reading order, CTA visibility, and product-near proof on narrow screens before the homepage section pass is considered complete.
|
|
||||||
|
|
||||||
## Spread Control and Acceptance Trace
|
|
||||||
|
|
||||||
- Spread control: this sheet does not authorize a bespoke hero framework or a custom visual language separate from the Spec 214 foundation.
|
|
||||||
- Acceptance trace: the mapping covers the required hero text core, CTA pair, product-near visual, bounded trust cues, mobile meaning order, and anti-pattern rejection from Spec 218.
|
|
||||||
- Exception note: no exception is approved. A future exception would need to prove that the mounted AstroDeck hero family cannot preserve the current hero contract even after bounded adaptation.
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
# Material Drift Follow-up
|
|
||||||
|
|
||||||
Only material drift that changes page inventory, CTA logic, navigation, or trust messaging belongs here. As of 2026-04-22, drift is concentrated in Spec 213.
|
|
||||||
|
|
||||||
## Drift Records
|
|
||||||
|
|
||||||
| driftId | affectedSpecId | driftClass | driftSummary | requiredSpecAction | targetSpecReference | notes |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| `223-DRIFT-213-ia` | 213 | page inventory | The canonical public IA now treats `/trust`, `/changelog`, and `/imprint` as required surfaces and demotes `/legal`, `/terms`, `/solutions`, and `/integrations` to retained secondary status. | update existing spec | `specs/213-website-foundation-v0/spec.md` | Spec 215 already carries the newer route truth; Spec 213 needs an explicit rebuild note so the mismatch is visible. |
|
|
||||||
| `223-DRIFT-213-nav` | 213 | navigation | Top-level discoverability now centers on Product, Trust, Changelog, and Contact with one primary CTA instead of the broader route emphasis implied by the original v0 foundation. | update existing spec | `specs/213-website-foundation-v0/spec.md` | This keeps the public journey aligned with Spec 215 and the current website baseline. |
|
|
||||||
| `223-DRIFT-213-trust` | 213 | trust messaging | The canonical trust surface is `/trust`, while `/security-trust` is now only a compatibility redirect and must not be rebuilt as a first-class page. | update existing spec | `specs/213-website-foundation-v0/spec.md` | The rebuild note in Spec 213 points future work to the canonical Trust route and the Spec 223 mapping sheet. |
|
|
||||||
|
|
||||||
## No Additional Drift Logged
|
|
||||||
|
|
||||||
- Spec 214: no material drift logged. AstroDeck changes the substrate, but the website-only visual contract stays authoritative.
|
|
||||||
- Spec 215: no material drift logged. The current IA is already the source of truth that constrains AstroDeck adoption.
|
|
||||||
- Spec 217: no material drift logged. Optional AstroDeck proof sections are handled as remove/adapt mapping decisions, not as homepage-structure truth changes.
|
|
||||||
- Spec 218: no material drift logged. Hero anti-pattern control and CTA semantics remain authoritative without new spec changes.
|
|
||||||
|
|
||||||
## Action Summary
|
|
||||||
|
|
||||||
The drift set above requires one direct spec update: Spec 213 must now carry an explicit Spec 223 rebuild note so reviewers can see that its broad truth survives while its older route and navigation assumptions do not.
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
# Implementation Plan: Website Reset and AstroDeck Rebuild
|
|
||||||
|
|
||||||
**Branch**: `223-astrodeck-website-rebuild` | **Date**: 2026-04-22 | **Spec**: [spec.md](spec.md)
|
|
||||||
**Input**: Feature specification from `/specs/223-astrodeck-website-rebuild/spec.md`
|
|
||||||
|
|
||||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
This plan turns Spec 223 into a documentation-first rebuild workflow for `apps/website`. The primary requirement is to discard the current website implementation as the forward substrate while preserving the validity of continuing website specs and the history of legacy implementation tasks.
|
|
||||||
|
|
||||||
The technical approach is:
|
|
||||||
1. Confirm the current website is a standalone Astro 6 static app with file-based routes, content collections, and Playwright smoke coverage.
|
|
||||||
2. Treat AstroDeck as an external template source that must be inventoried before any rebuild mapping or custom work starts.
|
|
||||||
3. Model the rebuild with file-based planning artifacts only: current-site inventory, AstroDeck source intake, AstroDeck primitive inventory, website-spec classification, per-spec mapping or supersession-closure records, superseded legacy-task treatment, material-drift follow-up, and an exception register with review outcomes plus embedded approved exception records.
|
|
||||||
4. Hand off follow-up task planning as an inventory-first slice, a conditional Spec 213 disposition-or-mapping slice, and per-spec mapping slices for the continuing website specs, with each per-spec mapping artifact owning its embedded replacement task list and explicit spec-update follow-up when mapping reveals material page inventory, CTA logic, navigation, or trust messaging drift.
|
|
||||||
|
|
||||||
## Phases & Checkpoints
|
|
||||||
|
|
||||||
### Phase 0 - Research & Scope Lock
|
|
||||||
|
|
||||||
- Done when the current `apps/website` substrate, AstroDeck availability, route drift, and planning-artifact contract are documented in [research.md](research.md).
|
|
||||||
- Done when no Technical Context field remains unresolved.
|
|
||||||
|
|
||||||
### Phase 1 - Design Artifacts
|
|
||||||
|
|
||||||
- Done when [data-model.md](data-model.md), [contracts/rebuild-planning-artifacts.yaml](contracts/rebuild-planning-artifacts.yaml), and [quickstart.md](quickstart.md) define the inventory, classification, mapping, superseded-task, and exception workflow.
|
|
||||||
- Done when the Constitution Check is re-run post-design and still passes without introducing runtime or platform obligations.
|
|
||||||
|
|
||||||
### Phase 2 - Task Planning Handoff
|
|
||||||
|
|
||||||
- Done when `/speckit.tasks` can generate a task set that starts with AstroDeck inventory, keeps a conditional dedicated path for Spec 213, and then splits into per-spec mapping slices for Specs 214, 215, 217, and 218.
|
|
||||||
- Done when legacy-task superseded handling and exception boundaries are explicit enough that task generation cannot silently fall back to greenfield rebuild work.
|
|
||||||
|
|
||||||
## Final Follow-up Execution Order
|
|
||||||
|
|
||||||
1. Bind the mounted AstroDeck snapshot to the aliases in `astrodeck-source-intake.md` and `astrodeck-primitive-inventory.md`.
|
|
||||||
2. Execute the conditional foundation slice in `mappings/spec-213-website-foundation-v0.md`.
|
|
||||||
3. Execute the shared visual-adaptation slice in `mappings/spec-214-website-visual-foundation.md`.
|
|
||||||
4. Execute the canonical IA and route-mapping slice in `mappings/spec-215-website-core-pages.md`.
|
|
||||||
5. Execute the homepage section-composition slice in `mappings/spec-217-homepage-structure.md`.
|
|
||||||
6. Execute the homepage hero-refinement slice in `mappings/spec-218-homepage-hero.md`.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: TypeScript 5.9, Astro 6, Node.js 20+
|
|
||||||
**Primary Dependencies**: Astro, astro-icon, Tailwind CSS v4, Playwright 1.59
|
|
||||||
**Storage**: File-based route files, Astro content collections under `src/content`, public assets, and planning documents under `specs/223-astrodeck-website-rebuild`; no database
|
|
||||||
**Testing**: Static build plus Playwright smoke tests in `apps/website/tests/smoke` for follow-up implementation slices; this planning slice itself is documentation-only
|
|
||||||
**Validation Lanes**: N/A for this planning slice; fast-feedback for follow-up website implementation (`corepack pnpm build:website`, `cd apps/website && corepack pnpm exec playwright test`)
|
|
||||||
**Target Platform**: Static public website served from the Astro app in `apps/website`
|
|
||||||
**Project Type**: Monorepo web application with a standalone Astro website app
|
|
||||||
**Performance Goals**: No new runtime goal in this planning-only slice; follow-up rebuild work must preserve static-site buildability and smoke-testable route rendering across the current public route family
|
|
||||||
**Constraints**: Strictly local to `apps/website`; AstroDeck-first inventory and mapping; legacy tasks remain visible as superseded history; no default greenfield work; no platform or Filament coupling
|
|
||||||
**Scale/Scope**: Current website scope covers 12 public route files, 5 governing website specs (213, 214, 215, 217, 218), component families under `src/components/{primitives,sections,content,layout}`, and file-based content collections for articles, changelog, and resources
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Plan
|
|
||||||
|
|
||||||
- **Guardrail scope**: no operator-facing surface change
|
|
||||||
- **Native vs custom classification summary**: N/A
|
|
||||||
- **Shared-family relevance**: none
|
|
||||||
- **State layers in scope**: none
|
|
||||||
- **Handling modes by drift class or surface**: report-only
|
|
||||||
- **Repository-signal treatment**: review-mandatory for follow-up inventory and mapping artifacts; no runtime hard-stop in this slice
|
|
||||||
- **Special surface test profiles**: N/A
|
|
||||||
- **Required tests or manual smoke**: N/A for this planning slice; follow-up implementation slices must use browser smoke plus static build proof
|
|
||||||
- **Exception path and spread control**: one named exception boundary only, for non-AstroDeck primitives that fail the mapping search and meet the documented exception rule
|
|
||||||
- **Active feature PR close-out entry**: N/A
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
|
||||||
|
|
||||||
- [X] **Inventory-first / snapshots / Graph contract / deterministic capabilities / run observability / automation**: N/A. This slice introduces no runtime inventory model, no Graph calls, no queued work, and no `OperationRun`.
|
|
||||||
- [X] **Scope / ownership / workspace and tenant isolation / RBAC / operator-surface rules**: N/A. The work is repository-local to `apps/website` and does not touch `/admin`, `/system`, tenant scope, or platform permissions.
|
|
||||||
- [X] **Proportionality / no premature abstraction / few layers**: Pass. All outputs remain file-based design artifacts; no runtime registries, DTO layers, or persisted models are introduced.
|
|
||||||
- [X] **LEAN-001**: Pass. The discarded website implementation is replaced rather than preserved through compatibility shims or legacy aliases.
|
|
||||||
- [X] **TEST-GOV-001**: Pass. This slice is explicitly docs-only, states `N/A` for runtime proof, and pushes build/browser proof to follow-up implementation specs.
|
|
||||||
- [X] **UI-FIL / BADGE / UX-001 / action-surface / naming / opsurface rules**: N/A. No Filament or operator-facing UI changes are planned in this slice.
|
|
||||||
|
|
||||||
## Test Governance Check
|
|
||||||
|
|
||||||
- **Test purpose / classification by changed surface**: N/A
|
|
||||||
- **Affected validation lanes**: N/A
|
|
||||||
- **Why this lane mix is the narrowest sufficient proof**: This slice only produces planning artifacts and does not change runtime behavior.
|
|
||||||
- **Narrowest proving command(s)**: N/A
|
|
||||||
- **Fixture / helper / factory / seed / context cost risks**: none
|
|
||||||
- **Expensive defaults or shared helper growth introduced?**: no
|
|
||||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
|
||||||
- **Surface-class relief / special coverage rule**: N/A
|
|
||||||
- **Closing validation and reviewer handoff**: Reviewers should inspect the generated planning artifacts for completeness, continuity with Spec 223, and explicit follow-up validation ownership.
|
|
||||||
- **Budget / baseline / trend follow-up**: none
|
|
||||||
- **Review-stop questions**: lane fit, hidden runtime claims, accidental platform coupling
|
|
||||||
- **Escalation path**: none
|
|
||||||
- **Active feature PR close-out entry**: N/A
|
|
||||||
- **Why no dedicated follow-up spec is needed**: This slice already exists to define the reset and planning contract; follow-up implementation proof belongs in the subsequent mapping specs and task plans.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation & Planning Artifacts (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/223-astrodeck-website-rebuild/
|
|
||||||
├── current-website-inventory.md
|
|
||||||
├── astrodeck-source-intake.md
|
|
||||||
├── astrodeck-primitive-inventory.md
|
|
||||||
├── governing-website-spec-classification.md
|
|
||||||
├── legacy-task-disposition.md
|
|
||||||
├── exception-register.md
|
|
||||||
├── material-drift-follow-up.md
|
|
||||||
├── mappings/
|
|
||||||
│ ├── spec-213-website-foundation-v0.md
|
|
||||||
│ ├── spec-214-website-visual-foundation.md
|
|
||||||
│ ├── spec-215-website-core-pages.md
|
|
||||||
│ ├── spec-217-homepage-structure.md
|
|
||||||
│ └── spec-218-homepage-hero.md
|
|
||||||
├── plan.md
|
|
||||||
├── research.md
|
|
||||||
├── data-model.md
|
|
||||||
├── quickstart.md
|
|
||||||
├── contracts/
|
|
||||||
│ └── rebuild-planning-artifacts.yaml
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/website/
|
|
||||||
├── astro.config.mjs
|
|
||||||
├── package.json
|
|
||||||
├── playwright.config.ts
|
|
||||||
├── src/
|
|
||||||
│ ├── content.config.ts
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── content/
|
|
||||||
│ │ ├── layout/
|
|
||||||
│ │ ├── primitives/
|
|
||||||
│ │ └── sections/
|
|
||||||
│ ├── content/
|
|
||||||
│ │ ├── articles/
|
|
||||||
│ │ ├── changelog/
|
|
||||||
│ │ ├── pages/
|
|
||||||
│ │ └── resources/
|
|
||||||
│ ├── layouts/
|
|
||||||
│ ├── lib/
|
|
||||||
│ ├── pages/
|
|
||||||
│ │ ├── index.astro
|
|
||||||
│ │ ├── product.astro
|
|
||||||
│ │ ├── trust.astro
|
|
||||||
│ │ ├── changelog.astro
|
|
||||||
│ │ ├── contact.astro
|
|
||||||
│ │ ├── privacy.astro
|
|
||||||
│ │ ├── imprint.astro
|
|
||||||
│ │ ├── terms.astro
|
|
||||||
│ │ ├── solutions.astro
|
|
||||||
│ │ ├── integrations.astro
|
|
||||||
│ │ ├── legal.astro
|
|
||||||
│ │ └── security-trust.astro
|
|
||||||
│ └── styles/
|
|
||||||
└── tests/
|
|
||||||
└── smoke/
|
|
||||||
├── home-product.spec.ts
|
|
||||||
├── solutions-trust-integrations.spec.ts
|
|
||||||
├── changelog-core-ia.spec.ts
|
|
||||||
├── contact-legal.spec.ts
|
|
||||||
└── visual-foundation-guardrails.spec.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Use the existing monorepo structure as-is. This plan is centered on `apps/website` and the planning artifacts under `specs/223-astrodeck-website-rebuild`, with only bounded traceability updates to the referenced 213/214/215/217/218 spec or task files when legacy-task supersession or material-drift follow-up requires them. No new runtime packages, shared libraries, or platform directories are introduced. The per-spec mapping or disposition files are also the forward-looking rebuild-plan artifacts: they embed the replacement task list or explicit supersession closure instead of spawning separate per-spec `tasks.md` files in this slice.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
||||||
|-----------|------------|-------------------------------------|
|
|
||||||
| none | N/A | N/A |
|
|
||||||
|
|
||||||
## Proportionality Review
|
|
||||||
|
|
||||||
- **Current operator problem**: Website contributors and reviewers need one explicit rebuild contract so they can discard the current implementation without losing continuing website truth or legacy task history.
|
|
||||||
- **Existing structure is insufficient because**: The current website specs define public-surface intent, but they do not specify how a full substrate change should preserve those decisions, reconcile current routes, and prevent silent greenfield rebuilding.
|
|
||||||
- **Narrowest correct implementation**: File-based artifacts only: research, data model, contracts, quickstart, inventories, classifications, mapping sheets, superseded-task handling, material-drift follow-up, and exception boundaries.
|
|
||||||
- **Ownership cost created**: Ongoing maintenance of a planning vocabulary for current-site inventory, AstroDeck primitive inventory, spec classification, mapping records, legacy-task disposition, and bounded exceptions.
|
|
||||||
- **Alternative intentionally rejected**: A code-first template import or a single monolithic rebuild task list was rejected because both would hide the strategy shift and collapse inventory, classification, and mapping into one opaque step.
|
|
||||||
- **Release truth**: Current-release truth for the public website rebuild; not future platform preparation.
|
|
||||||
|
|
||||||
## Post-Design Constitution Check
|
|
||||||
|
|
||||||
- [X] Post-design gate still passes: artifacts remain file-based and local to `apps/website`.
|
|
||||||
- [X] No runtime, platform, RBAC, Filament, or operator-surface obligations were introduced during design.
|
|
||||||
- [X] Follow-up implementation responsibility is explicit: inventory first, mapping second, tasks third, build/browser proof in later slices.
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
# Quickstart: Website Reset and AstroDeck Rebuild
|
|
||||||
|
|
||||||
This quickstart describes how to execute Spec 223 after planning is approved.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Work on branch `223-astrodeck-website-rebuild` or a follow-up branch derived from it.
|
|
||||||
- Keep the existing `apps/website` codebase available for comparison until legacy-task disposition and current-site inventory are complete.
|
|
||||||
- Obtain the AstroDeck source snapshot that will act as the new substrate. It does not need to be committed yet, but it must be available for inventory and mapping.
|
|
||||||
|
|
||||||
## Step 1: Capture the current website inventory
|
|
||||||
|
|
||||||
Inspect the current website surface before replacing anything.
|
|
||||||
|
|
||||||
- Record every current route under `apps/website/src/pages`.
|
|
||||||
- Record current component families under `apps/website/src/components`.
|
|
||||||
- Record content-backed sources from `apps/website/src/content` and `apps/website/src/content.config.ts`.
|
|
||||||
- Record current smoke coverage under `apps/website/tests/smoke` so follow-up rebuild work preserves intentional route and guardrail coverage.
|
|
||||||
|
|
||||||
## Step 2: Capture the AstroDeck inventory
|
|
||||||
|
|
||||||
Create a reviewable inventory of the imported AstroDeck source.
|
|
||||||
|
|
||||||
- List AstroDeck pages.
|
|
||||||
- List AstroDeck sections.
|
|
||||||
- List AstroDeck components.
|
|
||||||
- Flag demo-only copy, media, and CTA behavior that will require adaptation or removal.
|
|
||||||
|
|
||||||
## Step 3: Classify the governing website specs
|
|
||||||
|
|
||||||
Classify the current website spec set before touching implementation tasks.
|
|
||||||
|
|
||||||
- Evaluate Specs 213, 214, 215, 217, and 218.
|
|
||||||
- Mark each as continuing, partially valid, or superseded.
|
|
||||||
- Record why the classification was chosen.
|
|
||||||
- Record the `scopeSummary` for each classified spec.
|
|
||||||
- Record the `followUpPlan` path for each classified spec so the owning per-spec mapping artifact or explicit supersession-closure artifact is visible immediately.
|
|
||||||
- If Spec 213 remains continuing or partially valid, assign it its own dedicated rebuild-plan artifact instead of folding it into the 214-218 slices.
|
|
||||||
|
|
||||||
## Step 4: Mark legacy implementation tasks as superseded
|
|
||||||
|
|
||||||
Do not reopen or reset legacy tasks.
|
|
||||||
|
|
||||||
- Preserve the old task history.
|
|
||||||
- Mark implementation tasks tied to the discarded website with the canonical historical marker `superseded by AstroDeck rebuild`.
|
|
||||||
- Point each superseded task to the new follow-up plan or task list, or record the explicit statement `no replacement work required`.
|
|
||||||
|
|
||||||
## Step 5: Build per-spec AstroDeck mappings
|
|
||||||
|
|
||||||
For each continuing or partially valid website spec:
|
|
||||||
|
|
||||||
- Identify candidate AstroDeck pages, sections, and components.
|
|
||||||
- Decide keep, adapt, remove, or exception for each relevant primitive.
|
|
||||||
- Capture required adaptation to routes, content slots, styling, CTA logic, and trust/legal behavior.
|
|
||||||
- Record acceptance mapping back to the governing spec.
|
|
||||||
- Author the replacement task list inside the same per-spec mapping or disposition artifact so forward delivery ownership is explicit before implementation begins.
|
|
||||||
- Name each replacement task by the relevant AstroDeck page, section, component, or mapping activity rather than using generic build verbs.
|
|
||||||
- Give Spec 213 its own mapping or explicit supersession-closure artifact depending on the classification outcome.
|
|
||||||
|
|
||||||
## Step 6: Record exceptions only when the primitive search fails
|
|
||||||
|
|
||||||
Custom work is the exception path.
|
|
||||||
|
|
||||||
- Search AstroDeck first.
|
|
||||||
- Treat a primitive as adequate only when keep or bounded adaptation can satisfy the requirement without introducing a net-new IA contract or unsupported interaction model.
|
|
||||||
- Record every missing-candidate review in the exception register as either `approved exception` or `no exception required`.
|
|
||||||
- If no adequate primitive exists, capture the documented exception record in `exception-register.md` with the failed adequacy rationale and named approver.
|
|
||||||
- Bound the exception to one page, section, or component slice.
|
|
||||||
|
|
||||||
## Step 7: Record material drift as explicit spec work
|
|
||||||
|
|
||||||
AstroDeck adoption must not silently change website truth.
|
|
||||||
|
|
||||||
- Log any material page inventory, CTA logic, navigation, or trust messaging drift.
|
|
||||||
- Update the affected existing website spec or create a named follow-up website spec.
|
|
||||||
- Link the drift record to the mapping artifact that exposed it.
|
|
||||||
|
|
||||||
## Step 8: Record cross-spec execution order for the follow-up tasks
|
|
||||||
|
|
||||||
After the per-spec artifacts already contain their embedded replacement task lists or supersession closures, record the cross-spec execution order in this sequence:
|
|
||||||
|
|
||||||
1. AstroDeck intake binding and primitive verification using `astrodeck-source-intake.md` and `astrodeck-primitive-inventory.md`
|
|
||||||
2. Conditional Spec 213 disposition-or-mapping slice in `mappings/spec-213-website-foundation-v0.md`
|
|
||||||
3. Spec 214 visual-adaptation slice in `mappings/spec-214-website-visual-foundation.md`
|
|
||||||
4. Spec 215 IA and route-mapping slice in `mappings/spec-215-website-core-pages.md`
|
|
||||||
5. Spec 217 homepage section-composition slice in `mappings/spec-217-homepage-structure.md`
|
|
||||||
6. Spec 218 homepage hero-refinement slice in `mappings/spec-218-homepage-hero.md`
|
|
||||||
|
|
||||||
Each per-spec mapping or disposition artifact should already contain its replacement task list or explicit supersession closure before this cross-spec execution order is recorded.
|
|
||||||
|
|
||||||
## Step 9: Begin implementation only after the planning artifacts are complete
|
|
||||||
|
|
||||||
Implementation should start only when:
|
|
||||||
|
|
||||||
- every current website surface has a disposition
|
|
||||||
- every continuing or partially valid website spec has a mapping artifact
|
|
||||||
- legacy tasks are marked superseded
|
|
||||||
- every material drift item points to an updated or follow-up website spec
|
|
||||||
- any custom primitive has an approved exception record
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
This planning slice does not require runtime validation. Follow-up implementation slices should use:
|
|
||||||
|
|
||||||
- `corepack pnpm build:website`
|
|
||||||
- `cd apps/website && corepack pnpm exec playwright test`
|
|
||||||
|
|
||||||
## Expected Outputs
|
|
||||||
|
|
||||||
- A current-site inventory for `apps/website`
|
|
||||||
- An AstroDeck source-intake record
|
|
||||||
- An AstroDeck primitive inventory
|
|
||||||
- A classification of Specs 213, 214, 215, 217, and 218
|
|
||||||
- Superseded legacy-task records
|
|
||||||
- Per-spec AstroDeck mappings and embedded replacement task lists or explicit supersession closures, including the conditional Spec 213 artifact
|
|
||||||
- Material-drift follow-up records tied to updated or follow-up website specs
|
|
||||||
- An exception register with approved-exception and no-exception outcomes
|
|
||||||
- Exception records only where AstroDeck cannot satisfy an active requirement
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
# Research: Website Reset and AstroDeck Rebuild
|
|
||||||
|
|
||||||
## Decision 1: Treat the current website as a custom Astro substrate that is being retired, not as a hybrid AstroDeck base
|
|
||||||
|
|
||||||
- **Decision**: The existing `apps/website` codebase is a custom Astro 6 static site and must be treated as the legacy implementation being replaced.
|
|
||||||
- **Rationale**: The app uses hand-authored route files under `src/pages`, custom component families under `src/components/{primitives,sections,content,layout}`, Astro content collections in `src/content.config.ts`, and a focused Playwright smoke suite. No AstroDeck runtime artifacts or imports exist in the current app.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Assume the current site is already close enough to AstroDeck and continue incrementally. Rejected because the repository shows a custom build, not a template-derived primitive inventory.
|
|
||||||
- Rebuild directly from the current component families. Rejected because Spec 223 makes AstroDeck the required substrate for forward work.
|
|
||||||
|
|
||||||
## Decision 2: Treat AstroDeck as an external source intake that must be imported and inventoried before mapping begins
|
|
||||||
|
|
||||||
- **Decision**: Planning must assume AstroDeck is not yet materialized in the repository and therefore must first be brought into a reviewable workspace before mapping decisions can start.
|
|
||||||
- **Rationale**: Repository-wide search found AstroDeck references only in the spec text, not in `apps/website` source code or supporting docs. That makes AstroDeck an external input to the rebuild, not an already-present code surface.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Start mapping directly against the current website files. Rejected because that would bypass the mandatory AstroDeck-first workflow.
|
|
||||||
- Begin greenfield implementation and retrofit AstroDeck naming later. Rejected because Spec 223 forbids making AstroDeck invisible in planning and tasks.
|
|
||||||
|
|
||||||
## Decision 3: Use file-based planning artifacts, not runtime models or APIs
|
|
||||||
|
|
||||||
- **Decision**: The rebuild workflow will be modeled through documentation artifacts only: inventory, classification, mapping, legacy-task disposition, and exception records.
|
|
||||||
- **Rationale**: Spec 223 is planning-only, introduces no runtime behavior, and explicitly avoids platform or application changes. File-based artifacts satisfy the need without importing new persistence, runtime contracts, or code abstractions.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Add database tables or a runtime registry for website planning state. Rejected as disproportionate and unnecessary for a docs-only slice.
|
|
||||||
- Encode the workflow only in freeform notes. Rejected because the rebuild needs consistent, reviewable structures for later task generation.
|
|
||||||
|
|
||||||
## Decision 4: The current website source of truth spans routes, content collections, and smoke tests
|
|
||||||
|
|
||||||
- **Decision**: The current-site inventory phase must inspect `src/pages`, `src/content`, `src/content.config.ts`, and `tests/smoke`, not route files alone.
|
|
||||||
- **Rationale**: Routes define the surface area, content collections define editorial sources that survive page swaps, and smoke tests encode which public paths and guardrails the current website already treats as important.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Inventory route files only. Rejected because it would miss content-backed and test-backed obligations already present in the app.
|
|
||||||
- Inventory components only. Rejected because route and content drift is central to the rebuild scope.
|
|
||||||
|
|
||||||
## Decision 5: The current route family is mostly aligned with the active website specs, but two classification edge cases must be handled explicitly
|
|
||||||
|
|
||||||
- **Decision**: The rebuild plan should treat the current route family as mostly aligned with the active website specs while explicitly classifying `/legal` and `/security-trust` during the inventory and mapping phases.
|
|
||||||
- **Rationale**: Current route files cover the core and secondary IA surfaces from Specs 213, 214, 215, 217, and 218. The comparison found one trust alias (`/security-trust` redirecting to `/trust`) and one legal hub/question (`/legal`) that require an explicit keep/adapt/remove decision.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Assume all current routes are canonical and carry them forward untouched. Rejected because Spec 223 requires visible mapping and removal decisions for non-conforming or extra surfaces.
|
|
||||||
- Assume any extra route is legacy and delete it immediately. Rejected because redirect and legal-hub behavior may still be valid and must be classified first.
|
|
||||||
|
|
||||||
## Decision 6: Follow-up planning should split into one inventory slice, one conditional Spec 213 slice, and multiple mapping slices
|
|
||||||
|
|
||||||
- **Decision**: After this plan, task generation should split into one AstroDeck inventory slice, one conditional Spec 213 disposition-or-mapping slice, and then separate mapping-and-replanning slices for Specs 214, 215, 217, and 218, with explicit spec updates or follow-up specs when material page inventory, CTA logic, navigation, or trust messaging drift is discovered.
|
|
||||||
- **Rationale**: Spec 223 requires inventory before mapping, Spec 213 cannot disappear implicitly if it remains active after classification, and the continuing website specs each own different acceptance logic. Separating them keeps the rebuild reviewable and prevents one opaque website mega-task from hiding route, section, component, and spec-update decisions.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- One monolithic rebuild task list for the entire website. Rejected because it would blur spec ownership and make superseded legacy-task treatment hard to audit.
|
|
||||||
- One tiny task per route or component. Rejected because it would over-fragment the work and obscure the governing spec boundaries.
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
# Feature Specification: Website Reset and AstroDeck Rebuild
|
|
||||||
|
|
||||||
**Feature Branch**: `223-astrodeck-website-rebuild`
|
|
||||||
**Created**: 2026-04-22
|
|
||||||
**Status**: Approved
|
|
||||||
**Input**: User description: "Reset and rebuild `apps/website` on AstroDeck with strict AstroDeck primitive mapping, preserved website spec truth, superseded legacy implementation tasks, and documented exceptions for any non-AstroDeck rebuild work."
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: `apps/website` has an existing implementation and task history that no longer match the desired implementation substrate, so future work can drift between legacy code, template import, and undocumented greenfield rebuilding.
|
|
||||||
- **Today's failure**: Contributors could delete the old website, import AstroDeck, and reopen legacy tasks as if nothing materially changed, which would erase the strategy shift and make planning history misleading.
|
|
||||||
- **User-visible improvement**: Website contributors and reviewers can rebuild on a clearly defined base, preserve existing public-surface spec truth, and distinguish legacy work from the new forward plan without guesswork.
|
|
||||||
- **Smallest enterprise-capable version**: One `apps/website`-local reset-and-rebuild governance spec that declares the prior implementation superseded, makes AstroDeck the required substrate, classifies existing website specs, preserves legacy task history, and requires fresh AstroDeck-specific replanning.
|
|
||||||
- **Explicit non-goals**: No final page visuals, no final copy, no file-by-file migration guide, no platform or Filament changes, no automatic reapproval of every website spec, and no unrestricted greenfield redesign.
|
|
||||||
- **Permanent complexity imported**: A website-local rebuild contract, AstroDeck primitive inventory and mapping vocabulary, a superseded-task policy, and a narrow exception workflow for cases where the chosen base primitives are insufficient.
|
|
||||||
- **Why now**: More website work is already blocked by uncertainty about whether the old implementation still matters and whether new work should inherit template defaults or existing website specs.
|
|
||||||
- **Why not local**: A one-off migration note or template import would not preserve task history, would not classify which website specs still govern the site, and would not force future work onto one visible substrate.
|
|
||||||
- **Approval class**: Cleanup
|
|
||||||
- **Red flags triggered**: #1 introduces a new planning classification layer; #4 uses foundation/substrate language; #6 naturally leads to follow-up planning specs. The scope remains justified because it replaces ambiguity with one local contract and prevents silent drift during a full implementation reset.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: workspace
|
|
||||||
- **Primary Routes**: All public routes in `apps/website`, starting with the current website route family governed by Specs 213, 214, 215, 217, and 218, including `/`, `/product`, `/trust`, `/changelog`, `/contact`, `/privacy`, and `/imprint`.
|
|
||||||
- **Data Ownership**: Website-owned planning truth only: implementation-basis selection, AstroDeck primitive inventory and mapping, continuing-spec classification, superseded legacy task treatment, and documented exception records. No tenant-owned records, platform runtime data, or shared persistence are introduced.
|
|
||||||
- **RBAC**: None. This feature governs repository planning and public-website implementation rules only.
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
|
||||||
|
|
||||||
N/A - no operator-facing surface change. This feature governs website implementation basis, planning artifacts, and legacy task treatment inside `apps/website`.
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: yes
|
|
||||||
- **New persisted entity/table/artifact?**: no
|
|
||||||
- **New abstraction?**: yes
|
|
||||||
- **New enum/state/reason family?**: no
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: yes, but only within `apps/website` planning and implementation governance
|
|
||||||
- **Current operator problem**: Website contributors and reviewers cannot currently tell which website specs survive the reset, which tasks are obsolete, and whether new work must start from AstroDeck or from custom rebuilding.
|
|
||||||
- **Existing structure is insufficient because**: The current website specs describe public-surface truth, but they do not define how a full implementation reset should preserve that truth, retire legacy tasks, or make AstroDeck mandatory for forward work.
|
|
||||||
- **Narrowest correct implementation**: One website-local reset spec that fixes the implementation basis, legacy-task treatment, replanning workflow, and bounded exception policy without redefining page design or expanding into platform governance.
|
|
||||||
- **Ownership cost**: Future website planning must keep continuing specs classified, legacy tasks visibly superseded, AstroDeck mappings explicit, and exceptions tightly bounded instead of allowing ad hoc drift.
|
|
||||||
- **Alternative intentionally rejected**: Deleting the old website, importing AstroDeck, and reopening old tasks was rejected because it would erase the strategy shift, hide replanning work, and make implementation history unreliable.
|
|
||||||
- **Release truth**: Current-release truth for the public website only; this is not a platform-wide workflow contract.
|
|
||||||
|
|
||||||
### Compatibility posture
|
|
||||||
|
|
||||||
This feature assumes a pre-production environment.
|
|
||||||
|
|
||||||
Backward compatibility for the discarded website implementation, migration shims for legacy pages, and preservation of obsolete implementation structure are out of scope unless a later spec explicitly requires them.
|
|
||||||
|
|
||||||
Canonical replacement through the new substrate is preferred over preservation.
|
|
||||||
|
|
||||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
|
||||||
|
|
||||||
- **Test purpose / classification**: N/A
|
|
||||||
- **Validation lane(s)**: N/A
|
|
||||||
- **Why this classification and these lanes are sufficient**: This feature changes specification and planning truth only. It does not by itself change runtime behavior, browser surfaces, or automated test obligations.
|
|
||||||
- **New or expanded test families**: none
|
|
||||||
- **Fixture / helper cost impact**: none
|
|
||||||
- **Heavy-family visibility / justification**: none
|
|
||||||
- **Special surface test profile**: N/A
|
|
||||||
- **Standard-native relief or required special coverage**: none; follow-up implementation specs must define their own build and browser proof.
|
|
||||||
- **Reviewer handoff**: Reviewers must confirm that the spec clearly separates discarded implementation from continuing spec truth, preserves task history, makes the implementation substrate explicit, and bounds exceptions instead of allowing greenfield bypass.
|
|
||||||
- **Budget / baseline / trend impact**: none
|
|
||||||
- **Escalation needed**: none
|
|
||||||
- **Active feature PR close-out entry**: N/A
|
|
||||||
- **Planned validation commands**: N/A - specification quality review only
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Start rebuild work from one visible substrate (Priority: P1)
|
|
||||||
|
|
||||||
A website contributor begins a new `apps/website` implementation slice and can tell immediately that the old website implementation no longer governs forward work and that the rebuild must start from AstroDeck primitives.
|
|
||||||
|
|
||||||
**Why this priority**: If the implementation basis remains ambiguous, every follow-up website task risks mixing discarded code, template defaults, and unreviewed custom work.
|
|
||||||
|
|
||||||
**Independent Test**: Review the reset spec and one follow-up planning artifact and confirm that the implementation starts from identified base pages, sections, and components rather than from the discarded website or a generic "build" task.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the previous website implementation still exists in repository history, **When** a contributor starts new website planning, **Then** they treat that implementation as superseded history rather than as the active build base.
|
|
||||||
2. **Given** a contributor needs to plan a new website slice, **When** they begin the work, **Then** they first identify base pages, sections, or components from the chosen substrate before proposing custom construction.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Preserve old task history while creating a new forward plan (Priority: P1)
|
|
||||||
|
|
||||||
A reviewer can see which older website implementation tasks no longer govern current work and which new task list now owns delivery for each continuing website spec.
|
|
||||||
|
|
||||||
**Why this priority**: The reset is only trustworthy if it preserves why previous work no longer applies instead of pretending the same tasks can simply be started again.
|
|
||||||
|
|
||||||
**Independent Test**: Review a continuing website spec and confirm that legacy implementation tasks are visibly superseded and that a separate forward-looking task list exists for the rebuild.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a website spec remains valid after the reset, **When** forward planning is created, **Then** its legacy implementation tasks stay visible as superseded history rather than being reset to unchecked.
|
|
||||||
2. **Given** a reviewer inspects a continuing website spec, **When** they compare old and new planning, **Then** they can distinguish the legacy task record from the rebuild task list without relying on tribal knowledge.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Allow bounded exceptions only when the base primitives are insufficient (Priority: P2)
|
|
||||||
|
|
||||||
A website planner can introduce a non-standard page, section, or component only after documenting why the existing base primitives cannot satisfy a continuing website spec.
|
|
||||||
|
|
||||||
**Why this priority**: The rebuild only stays disciplined if custom work is a bounded exception rather than the silent default.
|
|
||||||
|
|
||||||
**Independent Test**: Inspect any proposed custom primitive during replanning and confirm that it points to a missing candidate, a specific unmet requirement, and a dedicated exception record.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** no adequate base primitive satisfies a continuing website spec, **When** a planner proposes custom work, **Then** they create a documented exception tied to that unmet requirement.
|
|
||||||
2. **Given** an adequate base primitive exists, **When** a planner proposes custom rebuilding anyway, **Then** the proposal is rejected as a default path and must return to mapping or adaptation.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- What happens when an existing website spec contains both enduring public-surface truth and outdated implementation assumptions? It must be classified as partially valid, updated or supplemented as needed, and then replanned on the new base rather than treated as fully current or fully discarded.
|
|
||||||
- What happens when Spec 213 remains continuing or partially valid after classification? It must receive its own dedicated rebuild-plan artifact instead of being absorbed implicitly into Specs 214, 215, 217, or 218.
|
|
||||||
- What happens when multiple base primitives could satisfy the same spec requirement? The mapping must state the chosen candidate and rationale before task creation so later work stays traceable.
|
|
||||||
- How does the rebuild handle imported demo pages, demo sections, or demo copy that do not conform to active website specs? They must generate explicit removal or adaptation tasks instead of surviving by default.
|
|
||||||
- How does the rebuild handle a missing base primitive for a required capability? The gap must become a documented exception with a bounded replacement task rather than a silent greenfield detour.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** This feature introduces no Microsoft Graph calls, no queueing, no long-running operations, no authorization changes, and no Filament operator surfaces. Its contract is strictly local to `apps/website` and the planning artifacts that govern website implementation.
|
|
||||||
|
|
||||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature adds a narrow website-local rebuild contract and exception workflow, not new runtime persistence or a shared platform abstraction. The rule set is justified only because a full implementation reset would otherwise blur continuing website truth, legacy task history, and forward planning.
|
|
||||||
|
|
||||||
**Constitution alignment (TEST-GOV-001):** This feature changes no runtime behavior and adds no automated runtime tests. Follow-up implementation specs remain responsible for build and browser validation. This spec is validated through repository review and the specification quality checklist only.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The existing `apps/website` implementation MUST be treated as discarded implementation history and MUST NOT remain the primary starting point for forward website work.
|
|
||||||
- **FR-002**: AstroDeck MUST become the required technical and structural substrate for new `apps/website` implementation work.
|
|
||||||
- **FR-003**: This reset-and-rebuild contract MUST remain strictly local to `apps/website` and MUST NOT impose platform, Filament, or cross-app obligations.
|
|
||||||
- **FR-004**: Existing website specs that still define valid public-surface truth MUST remain authoritative unless a later spec explicitly replaces or narrows them.
|
|
||||||
- **FR-005**: Each existing website spec in scope MUST be classified as continuing, partially valid, or superseded before new implementation planning begins.
|
|
||||||
- **FR-006**: Legacy implementation tasks tied to the discarded website MUST remain visible as historical context and MUST NOT be reset to unchecked or reopened as if the underlying implementation basis were unchanged.
|
|
||||||
- **FR-007**: Legacy implementation tasks affected by the rebuild MUST use the canonical historical marker `superseded by AstroDeck rebuild` so their non-authoritative status stays consistent across all affected website specs.
|
|
||||||
- **FR-008**: Each continuing or partially valid website spec MUST receive a separate new implementation plan for the rebuild; if Spec 213 remains continuing or partially valid after classification, it MUST receive its own dedicated rebuild-plan artifact rather than being folded implicitly into Specs 214, 215, 217, or 218.
|
|
||||||
- **FR-009**: Each new implementation plan MUST identify candidate AstroDeck pages, sections, and components that act as the starting point for that spec's rebuild work.
|
|
||||||
- **FR-010**: Each identified AstroDeck candidate in a new plan MUST be classified as keep, adapt, remove, or exception.
|
|
||||||
- **FR-011**: New implementation tasks MUST explicitly name the relevant AstroDeck page, section, component, or mapping activity rather than describing custom rebuilding in generic terms.
|
|
||||||
- **FR-012**: New implementation tasks MUST cover removal of demo pages, demo sections, demo components, and demo copy that do not conform to active website specs.
|
|
||||||
- **FR-013**: New implementation tasks MUST cover any required adaptation to structure, routing, content slots, styling, or composition needed to satisfy active website specs.
|
|
||||||
- **FR-014**: New implementation work MUST begin with AstroDeck primitive identification and mapping before any freeform new page, section, or component is proposed.
|
|
||||||
- **FR-015**: A freeform new page, section, or component MUST NOT be created when an adequate AstroDeck-derived candidate already exists; a candidate counts as adequate only when keep or bounded adaptation can satisfy the active requirement without inventing a net-new IA contract or unsupported interaction model.
|
|
||||||
- **FR-016**: A non-AstroDeck page, section, or component MAY be introduced only through a documented exception approved in the mapping review by a named website reviewer, owner, or equivalent feature approver and tied to a specific unmet requirement in an active website spec.
|
|
||||||
- **FR-017**: Each documented exception MUST record the missing AstroDeck candidate, the unmet requirement, why the available AstroDeck candidates failed the adequacy test, the bounded deviation, the named approver, and the dedicated task or planning entry that owns the exception.
|
|
||||||
- **FR-018**: The rebuild workflow MUST occur in this order: discard prior implementation basis, establish AstroDeck as base, inventory AstroDeck primitives, classify existing website specs, mark legacy tasks superseded, create mappings, create new tasks, document exceptions, then begin implementation.
|
|
||||||
- **FR-019**: The planning output for each continuing or partially valid website spec MUST include AstroDeck primitive mapping, keep/adapt/remove/exception decisions, a new task list embedded in the same per-spec mapping or disposition artifact or linked from it explicitly, and explicit acceptance mapping back to the active spec.
|
|
||||||
- **FR-020**: Any material change to page inventory, CTA logic, navigation, or trust messaging introduced through AstroDeck adoption MUST appear in updated existing website specs or explicitly created follow-up website specs referenced from the rebuild planning artifacts rather than entering silently through template import.
|
|
||||||
- **FR-021**: The rebuild deliverables MUST include an AstroDeck inventory for `apps/website`, a classification of in-scope website specs, preserved historical task treatment using the canonical superseded marker, a new task list or explicit supersession closure for each in-scope website spec, a material-drift follow-up record for any required spec updates, and an exception register that records approved exceptions and explicit no-exception outcomes.
|
|
||||||
- **FR-022**: Follow-up classification MUST explicitly evaluate the current known website spec set that already governs `apps/website`, including Specs 213, 214, 215, 217, and 218.
|
|
||||||
- **FR-023**: Task wording and planning artifacts MUST keep AstroDeck visible as the implementation substrate and MUST NOT hide it behind generic build verbs that make the mapping step invisible.
|
|
||||||
|
|
||||||
### Assumptions
|
|
||||||
|
|
||||||
- This reset occurs in a pre-production website track, so preserving the discarded implementation for backward compatibility is not required.
|
|
||||||
- Existing website specs remain the current normative baseline until they are explicitly classified or superseded through follow-up planning.
|
|
||||||
- The chosen AstroDeck distribution provides reusable pages, sections, and components sufficient to support an inventory and mapping pass.
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
- AstroDeck assets and primitives must be available to the repository or local implementation workspace so that inventory and mapping can be performed.
|
|
||||||
- Follow-up planning work must create separate task lists or replanning artifacts for the continuing website specs after this reset spec is accepted.
|
|
||||||
- The planning workflow must support a visible superseded marker or equivalent notation for legacy implementation tasks.
|
|
||||||
|
|
||||||
### Key Entities
|
|
||||||
|
|
||||||
- **Legacy Website Implementation**: The previous `apps/website` codebase and assembly approach that remains part of repository history but no longer serves as the forward-looking implementation base.
|
|
||||||
- **Continuing Website Spec**: An existing website spec whose public-surface truth remains active after classification, even though its prior implementation tasks may no longer govern delivery.
|
|
||||||
- **AstroDeck Primitive Mapping**: The per-spec record of which AstroDeck pages, sections, and components serve as the rebuild starting point and whether each is kept, adapted, removed, or treated as an exception.
|
|
||||||
- **Superseded Legacy Task**: A historical implementation task preserved for traceability but no longer authoritative for current website delivery.
|
|
||||||
- **Documented Exception**: A bounded approval record for introducing a non-AstroDeck page, section, or component when no adequate AstroDeck candidate satisfies an active website requirement.
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: A reviewer can inspect the reset artifacts and determine in one pass that the previous `apps/website` implementation no longer governs forward work.
|
|
||||||
- **SC-002**: 100% of continuing or partially valid website specs in scope, including Spec 213 whenever it remains active after classification, have a forward-looking rebuild task list that is clearly separated from legacy implementation tasks.
|
|
||||||
- **SC-003**: 100% of forward-looking website plans identify starting pages, sections, and components and assign keep, adapt, remove, or exception outcomes before implementation begins.
|
|
||||||
- **SC-004**: 100% of freeform non-AstroDeck primitives introduced during follow-up planning carry an explicit exception record tied to an unmet active-spec requirement.
|
|
||||||
- **SC-005**: 100% of the currently known website spec set in scope is classified as continuing, partially valid, or superseded before rebuild implementation starts.
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
# Tasks: Website Reset and AstroDeck Rebuild
|
|
||||||
|
|
||||||
**Input**: Design documents from `/specs/223-astrodeck-website-rebuild/`
|
|
||||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/rebuild-planning-artifacts.yaml`
|
|
||||||
|
|
||||||
**Tests**: N/A - docs-only planning feature. This slice adds no runtime behavior, so no automated test tasks are required here. Follow-up implementation slices own `corepack pnpm build:website` and `cd apps/website && corepack pnpm exec playwright test`.
|
|
||||||
|
|
||||||
## Test Governance Checklist
|
|
||||||
|
|
||||||
- [X] Lane assignment is `N/A` because this feature changes planning artifacts only.
|
|
||||||
- [X] No runtime or browser test family is added in this slice.
|
|
||||||
- [X] No new helpers, fixtures, seeds, or shared defaults are introduced.
|
|
||||||
- [X] Runtime proof remains explicitly deferred to follow-up website implementation slices.
|
|
||||||
- [X] The governance outcome stays documented in-feature rather than becoming a separate cleanup item.
|
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Infrastructure)
|
|
||||||
|
|
||||||
**Purpose**: Create the planning workspace files that the inventory, classification, mapping, and exception workflow will use.
|
|
||||||
|
|
||||||
- [X] T001 Create the current-site and AstroDeck inventory stubs in `specs/223-astrodeck-website-rebuild/current-website-inventory.md`, `specs/223-astrodeck-website-rebuild/astrodeck-source-intake.md`, and `specs/223-astrodeck-website-rebuild/astrodeck-primitive-inventory.md`
|
|
||||||
- [X] T002 [P] Create the governing-spec, legacy-task disposition, and material-drift follow-up stubs in `specs/223-astrodeck-website-rebuild/governing-website-spec-classification.md`, `specs/223-astrodeck-website-rebuild/legacy-task-disposition.md`, and `specs/223-astrodeck-website-rebuild/material-drift-follow-up.md`
|
|
||||||
- [X] T003 [P] Create the per-spec mapping workspace and exception stub in `specs/223-astrodeck-website-rebuild/exception-register.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md`, and `specs/223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
|
||||||
|
|
||||||
**Purpose**: Establish the shared baseline, intake rules, and crosswalk logic that every user story depends on.
|
|
||||||
|
|
||||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
|
||||||
|
|
||||||
- [X] T004 Capture the current route, content, component-family, and smoke-suite baseline in `specs/223-astrodeck-website-rebuild/current-website-inventory.md`
|
|
||||||
- [X] T005 [P] Record AstroDeck source provenance, intake constraints, and review assumptions in `specs/223-astrodeck-website-rebuild/astrodeck-source-intake.md`
|
|
||||||
- [X] T006 [P] Define the page, section, and component inventory columns plus demo-content flags in `specs/223-astrodeck-website-rebuild/astrodeck-primitive-inventory.md`
|
|
||||||
- [X] T007 Build the shared crosswalk from current website surfaces to Specs 213, 214, 215, 217, and 218 in `specs/223-astrodeck-website-rebuild/governing-website-spec-classification.md`
|
|
||||||
|
|
||||||
**Checkpoint**: The planning workspace, current-site baseline, AstroDeck intake rules, and governing-spec crosswalk are in place. Story work can now proceed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - Start Rebuild Work From One Visible Substrate (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: Make the retired current website and the new AstroDeck substrate visible side by side so future work starts from the right base.
|
|
||||||
|
|
||||||
**Independent Test**: A reviewer can open the current-site inventory and AstroDeck inventory documents and see that the old `apps/website` implementation is historical context while forward work starts from identified AstroDeck pages, sections, and components.
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [X] T008 [P] [US1] Complete the current website surface inventory with route roles, source files, dependencies, and initial keep/adapt/remove/redirect candidates in `specs/223-astrodeck-website-rebuild/current-website-inventory.md`
|
|
||||||
- [X] T009 [P] [US1] Complete the AstroDeck primitive inventory with page, section, and component candidates plus demo-content flags in `specs/223-astrodeck-website-rebuild/astrodeck-primitive-inventory.md`
|
|
||||||
- [X] T010 [US1] Reconcile the current-site baseline with the AstroDeck intake summary so the forward substrate is explicit in `specs/223-astrodeck-website-rebuild/astrodeck-source-intake.md`, `specs/223-astrodeck-website-rebuild/current-website-inventory.md`, and `specs/223-astrodeck-website-rebuild/astrodeck-primitive-inventory.md`
|
|
||||||
|
|
||||||
**Checkpoint**: The rebuild now has one visible implementation substrate and a clear retired baseline.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Preserve Old Task History While Creating a New Forward Plan (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: Keep legacy website task history readable while producing AstroDeck-specific replacement planning for the continuing website specs.
|
|
||||||
|
|
||||||
**Independent Test**: A reviewer can inspect the classification and legacy-task artifacts and then open the per-spec mapping sheets to see which old tasks were superseded and which new AstroDeck-specific task lists now own delivery.
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [X] T011 [US2] Classify Specs 213, 214, 215, 217, and 218 as continuing, partially valid, or superseded and record the classification `rationale`, `scopeSummary`, and `followUpPlan` reference for each spec in `specs/223-astrodeck-website-rebuild/governing-website-spec-classification.md`, including whether Spec 213 points to a dedicated rebuild plan in `specs/223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`
|
|
||||||
- [X] T012 [US2] Mark legacy implementation tasks as `superseded by AstroDeck rebuild` in `specs/213-website-foundation-v0/tasks.md`, `specs/214-website-visual-foundation/tasks.md`, `specs/215-website-core-pages/tasks.md`, `specs/217-homepage-structure/tasks.md`, and `specs/218-homepage-hero/tasks.md`, and summarize the replacement references in `specs/223-astrodeck-website-rebuild/legacy-task-disposition.md`
|
|
||||||
- [X] T013 [P] [US2] Create the Spec 213 disposition-or-rebuild mapping sheet, recording either a full AstroDeck plan with its embedded replacement task list naming the relevant AstroDeck page, section, component, or mapping activity or an explicit supersession closure, in `specs/223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`
|
|
||||||
- [X] T014 [P] [US2] Create the Spec 214 and Spec 215 disposition-or-rebuild mapping sheets, recording either full AstroDeck plans with embedded replacement task lists naming the relevant AstroDeck pages, sections, components, or mapping activities or explicit supersession closures, in `specs/223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md` and `specs/223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md`
|
|
||||||
- [X] T015 [P] [US2] Create the Spec 217 and Spec 218 disposition-or-rebuild mapping sheets, recording either full AstroDeck plans with embedded replacement task lists naming the relevant AstroDeck pages, sections, components, or mapping activities or explicit supersession closures, in `specs/223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md` and `specs/223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md`
|
|
||||||
- [X] T016 [US2] Cross-check the per-spec mapping sheets against the spec classifications, `followUpPlan` references, legacy-task replacements, and material-drift follow-up ledger in `specs/223-astrodeck-website-rebuild/governing-website-spec-classification.md`, `specs/223-astrodeck-website-rebuild/legacy-task-disposition.md`, `specs/223-astrodeck-website-rebuild/material-drift-follow-up.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md`, and `specs/223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md`
|
|
||||||
|
|
||||||
**Checkpoint**: Legacy work remains readable, and the forward-looking AstroDeck task ownership exists for every continuing website spec.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Allow Bounded Exceptions Only When the Base Primitives Are Insufficient (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Make custom non-AstroDeck work possible only through an explicit, scoped exception path.
|
|
||||||
|
|
||||||
**Independent Test**: A reviewer can inspect any custom-primitive proposal and see the failed AstroDeck search, the unmet spec requirement, the bounded deviation, and the approval reference without ambiguity.
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [X] T017 [US3] Define the exception workflow, adequacy rubric, required evidence, approval boundary, and embedded documented-exception record shape for non-AstroDeck primitives in `specs/223-astrodeck-website-rebuild/exception-register.md`
|
|
||||||
- [X] T018 [P] [US3] Review the Spec 213, Spec 214, and Spec 215 mapping sheets for unmet requirements and record either approved exceptions or explicit no-exception outcomes in `specs/223-astrodeck-website-rebuild/exception-register.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md`, and `specs/223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md`
|
|
||||||
- [X] T019 [P] [US3] Review the Spec 217 and Spec 218 mapping sheets for unmet requirements and record either approved exceptions or explicit no-exception outcomes in `specs/223-astrodeck-website-rebuild/exception-register.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md`, and `specs/223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md`
|
|
||||||
- [X] T020 [US3] Add spread-control and acceptance-trace notes for every exception-backed or no-exception mapping in `specs/223-astrodeck-website-rebuild/exception-register.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-214-website-visual-foundation.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-215-website-core-pages.md`, `specs/223-astrodeck-website-rebuild/mappings/spec-217-homepage-structure.md`, and `specs/223-astrodeck-website-rebuild/mappings/spec-218-homepage-hero.md`
|
|
||||||
|
|
||||||
**Checkpoint**: The rebuild has a controlled exception path instead of a silent greenfield bypass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Reconcile the finished planning artifacts with the contract, force explicit spec-update follow-up for material drift, and lock the handoff order for implementation slices.
|
|
||||||
|
|
||||||
- [X] T021 [P] Reconcile the completed inventory, classification, mapping, exception, and material-drift artifacts against `specs/223-astrodeck-website-rebuild/contracts/rebuild-planning-artifacts.yaml` and `specs/223-astrodeck-website-rebuild/quickstart.md`
|
|
||||||
- [X] T022 [P] Update the affected continuing website specs or create named follow-up website specs when material page inventory, CTA logic, navigation, or trust messaging drift is logged in `specs/213-website-foundation-v0/spec.md`, `specs/214-website-visual-foundation/spec.md`, `specs/215-website-core-pages/spec.md`, `specs/217-homepage-structure/spec.md`, `specs/218-homepage-hero/spec.md`, and `specs/223-astrodeck-website-rebuild/material-drift-follow-up.md`
|
|
||||||
- [X] T023 [P] Verify that superseded legacy-task markers and replacement references are visible from `specs/213-website-foundation-v0/tasks.md`, `specs/214-website-visual-foundation/tasks.md`, `specs/215-website-core-pages/tasks.md`, `specs/217-homepage-structure/tasks.md`, `specs/218-homepage-hero/tasks.md`, and `specs/223-astrodeck-website-rebuild/legacy-task-disposition.md`
|
|
||||||
- [X] T024 Record the final follow-up execution order for the AstroDeck inventory slice, the conditional Spec 213 slice, and the Spec 214/215/217/218 mapping slices, each already carrying its embedded replacement task list or supersession closure, in `specs/223-astrodeck-website-rebuild/plan.md`, `specs/223-astrodeck-website-rebuild/quickstart.md`, and `specs/223-astrodeck-website-rebuild/governing-website-spec-classification.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Phase Dependencies
|
|
||||||
|
|
||||||
- **Setup (Phase 1)**: Starts immediately.
|
|
||||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories.
|
|
||||||
- **User Story 1 (Phase 3)**: Depends on Foundational only.
|
|
||||||
- **User Story 2 (Phase 4)**: Depends on User Story 1 because the completed inventories from US1 are required before classification and mapping can start.
|
|
||||||
- **User Story 3 (Phase 5)**: Depends on the mapping sheets from US2.
|
|
||||||
- **Polish (Phase 6)**: Depends on all targeted user stories being complete.
|
|
||||||
|
|
||||||
### User Story Dependencies
|
|
||||||
|
|
||||||
- **US1**: MVP slice. No dependency on US2 or US3.
|
|
||||||
- **US2**: Depends on US1 because the current-site and AstroDeck inventories must be complete before classification and mapping.
|
|
||||||
- **US3**: Depends on the per-spec mapping sheets, including the conditional Spec 213 artifact, so exceptions can be evaluated against real candidate searches.
|
|
||||||
|
|
||||||
### Within Each User Story
|
|
||||||
|
|
||||||
- Establish or complete the relevant artifact first.
|
|
||||||
- Record explicit dispositions before writing replacement task lists.
|
|
||||||
- Link exceptions only after the mapping decision shows a real gap.
|
|
||||||
- Finish each story’s cross-check task before moving to the next story.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Opportunities
|
|
||||||
|
|
||||||
- `T002` and `T003` can run in parallel after `T001`.
|
|
||||||
- `T005` and `T006` can run in parallel after `T004` starts.
|
|
||||||
- In US1, `T008` and `T009` can run in parallel before `T010`.
|
|
||||||
- In US2, `T013`, `T014`, and `T015` can run in parallel after `T012`.
|
|
||||||
- In US3, `T018` and `T019` can run in parallel after `T017`.
|
|
||||||
- In Phase 6, `T021`, `T022`, and `T023` can run in parallel before `T024`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Example: User Story 2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# First record legacy-task supersession after classification:
|
|
||||||
Task: "T012 [US2] Mark legacy implementation tasks as superseded"
|
|
||||||
|
|
||||||
# After legacy-task supersession is recorded, launch the per-spec planning artifacts in parallel:
|
|
||||||
Task: "T013 [US2] Create the Spec 213 disposition-or-rebuild mapping sheet"
|
|
||||||
Task: "T014 [US2] Create the Spec 214 and Spec 215 disposition-or-rebuild mapping sheets"
|
|
||||||
Task: "T015 [US2] Create the Spec 217 and Spec 218 disposition-or-rebuild mapping sheets"
|
|
||||||
|
|
||||||
# Then run the consistency pass:
|
|
||||||
Task: "T016 [US2] Cross-check the mapping sheets against the classification, legacy-task, and material-drift records"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### MVP First (User Story 1 Only)
|
|
||||||
|
|
||||||
1. Complete Phase 1: Setup.
|
|
||||||
2. Complete Phase 2: Foundational.
|
|
||||||
3. Complete Phase 3: User Story 1.
|
|
||||||
4. **STOP and VALIDATE**: Review the current-site and AstroDeck inventories together.
|
|
||||||
5. Use that MVP substrate view as the basis for the follow-up mapping work.
|
|
||||||
|
|
||||||
### Incremental Delivery
|
|
||||||
|
|
||||||
1. Complete Setup + Foundational to establish the planning workspace.
|
|
||||||
2. Add US1 to make the rebuild substrate explicit.
|
|
||||||
3. Add US2 to preserve legacy history and produce forward AstroDeck task ownership.
|
|
||||||
4. Add US3 to harden the exception path.
|
|
||||||
5. Finish with Phase 6 to reconcile the artifacts and publish the handoff order.
|
|
||||||
|
|
||||||
### Suggested MVP Scope
|
|
||||||
|
|
||||||
- Deliver through **User Story 1** if the smallest first slice is needed.
|
|
||||||
- Add **User Story 2** next to preserve task history and create per-spec forward plans.
|
|
||||||
- Finish with **User Story 3** to ensure custom work remains exception-bound.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `[P]` tasks touch different files or independent artifacts and can run in parallel once dependencies are satisfied.
|
|
||||||
- `[US1]`, `[US2]`, and `[US3]` map directly to the user stories in `spec.md`.
|
|
||||||
- This is a docs-only planning feature, so no runtime test tasks are included here.
|
|
||||||
- The intended follow-up split after this feature is one AstroDeck inventory slice, a conditional Spec 213 slice, and per-spec mapping slices for Specs 214, 215, 217, and 218, with each per-spec artifact carrying its replacement task list or explicit supersession closure and with spec updates or follow-up specs when material drift is discovered.
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
# Specification Quality Checklist: Findings Notifications & Escalation v1
|
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
- Validated against the existing findings workflow, ownership, inbox, intake, and alerts foundations.
|
|
||||||
- No clarification markers remain.
|
|
||||||
@ -1,349 +0,0 @@
|
|||||||
openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: Findings Notifications & Escalation Surface Contract
|
|
||||||
version: 1.0.0
|
|
||||||
summary: Logical internal contract for Spec 224 delivery, alert-rule exposure, and finding deep links.
|
|
||||||
description: |
|
|
||||||
This contract documents the structured payloads and UI-facing surfaces that Spec 224 must satisfy.
|
|
||||||
It is intentionally logical rather than public-API only: the feature reuses existing Filament resources,
|
|
||||||
database notifications, and background jobs 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/notification-events:
|
|
||||||
post:
|
|
||||||
summary: Dispatch one finding event to direct personal delivery and optional external alert copies.
|
|
||||||
description: |
|
|
||||||
Logical internal contract implemented by the bounded finding-notification delivery seam.
|
|
||||||
It normalizes one finding event, resolves at most one entitled direct recipient, writes one
|
|
||||||
Filament-compatible database notification when appropriate, and forwards the same event to the
|
|
||||||
existing workspace alert dispatch pipeline.
|
|
||||||
operationId: dispatchFindingNotificationEvent
|
|
||||||
x-not-public-http: true
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/vnd.tenantpilot.finding-notification-event+json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/FindingNotificationEventDispatch'
|
|
||||||
responses:
|
|
||||||
'202':
|
|
||||||
description: Event accepted and evaluated for direct and optional external delivery.
|
|
||||||
content:
|
|
||||||
application/vnd.tenantpilot.finding-notification-dispatch-result+json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/FindingNotificationDispatchResult'
|
|
||||||
/admin/alert-rules:
|
|
||||||
get:
|
|
||||||
summary: Existing alert-rule surfaces expose the four new finding event types.
|
|
||||||
description: |
|
|
||||||
Existing Filament resource pages continue to render HTML, while this logical media type documents
|
|
||||||
the event-type options that must appear in create, edit, and filter flows.
|
|
||||||
operationId: viewFindingAlertRuleOptions
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Alert-rule surfaces show the finding event options and reuse the existing delivery families.
|
|
||||||
content:
|
|
||||||
text/html:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
application/vnd.tenantpilot.finding-alert-rule-options+json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/FindingAlertRuleOptionsSurface'
|
|
||||||
'403':
|
|
||||||
description: Workspace operator lacks permission to view alert-rule configuration.
|
|
||||||
'404':
|
|
||||||
description: Workspace operator is not a member of the active workspace or the alert surface is outside their visible scope.
|
|
||||||
/admin/alert-deliveries:
|
|
||||||
get:
|
|
||||||
summary: Existing alert-delivery history can filter and label finding event copies.
|
|
||||||
operationId: viewFindingAlertDeliveries
|
|
||||||
parameters:
|
|
||||||
- name: event_type
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/FindingEventType'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Alert-delivery viewer surfaces show finding-event labels and safe summaries.
|
|
||||||
content:
|
|
||||||
text/html:
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
application/vnd.tenantpilot.finding-alert-deliveries+json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/FindingAlertDeliveriesSurface'
|
|
||||||
'403':
|
|
||||||
description: Workspace operator lacks permission to inspect alert deliveries.
|
|
||||||
'404':
|
|
||||||
description: Workspace operator is not a member of the active workspace or the alert-delivery surface is outside their visible scope.
|
|
||||||
/admin/t/{tenant}/findings/{finding}:
|
|
||||||
get:
|
|
||||||
summary: The direct notification action opens the existing tenant finding detail route.
|
|
||||||
operationId: openFindingFromNotification
|
|
||||||
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
|
|
||||||
application/vnd.tenantpilot.finding-notification-context+json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/FindingNotificationContext'
|
|
||||||
'403':
|
|
||||||
description: Recipient still has tenant visibility but lacks current capability to inspect the finding.
|
|
||||||
'404':
|
|
||||||
description: Recipient no longer has tenant or record visibility.
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
FindingEventType:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- findings.assigned
|
|
||||||
- findings.reopened
|
|
||||||
- findings.due_soon
|
|
||||||
- findings.overdue
|
|
||||||
FindingSeverity:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- low
|
|
||||||
- medium
|
|
||||||
- high
|
|
||||||
- critical
|
|
||||||
RecipientReason:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- new_assignee
|
|
||||||
- current_assignee
|
|
||||||
- current_owner
|
|
||||||
DirectRecipient:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- userId
|
|
||||||
- reason
|
|
||||||
properties:
|
|
||||||
userId:
|
|
||||||
type: integer
|
|
||||||
displayName:
|
|
||||||
type: string
|
|
||||||
reason:
|
|
||||||
$ref: '#/components/schemas/RecipientReason'
|
|
||||||
FindingNotificationEventDispatch:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- eventType
|
|
||||||
- workspaceId
|
|
||||||
- tenantId
|
|
||||||
- findingId
|
|
||||||
- severity
|
|
||||||
- title
|
|
||||||
- body
|
|
||||||
- fingerprintKey
|
|
||||||
- metadata
|
|
||||||
properties:
|
|
||||||
eventType:
|
|
||||||
$ref: '#/components/schemas/FindingEventType'
|
|
||||||
workspaceId:
|
|
||||||
type: integer
|
|
||||||
tenantId:
|
|
||||||
type: integer
|
|
||||||
findingId:
|
|
||||||
type: integer
|
|
||||||
severity:
|
|
||||||
$ref: '#/components/schemas/FindingSeverity'
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
body:
|
|
||||||
type: string
|
|
||||||
directRecipient:
|
|
||||||
oneOf:
|
|
||||||
- $ref: '#/components/schemas/DirectRecipient'
|
|
||||||
- type: 'null'
|
|
||||||
fingerprintKey:
|
|
||||||
type: string
|
|
||||||
dueCycleKey:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
metadata:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- tenantName
|
|
||||||
- recipientReason
|
|
||||||
properties:
|
|
||||||
tenantName:
|
|
||||||
type: string
|
|
||||||
recipientReason:
|
|
||||||
$ref: '#/components/schemas/RecipientReason'
|
|
||||||
ownerUserId:
|
|
||||||
type:
|
|
||||||
- integer
|
|
||||||
- 'null'
|
|
||||||
assigneeUserId:
|
|
||||||
type:
|
|
||||||
- integer
|
|
||||||
- 'null'
|
|
||||||
dueAt:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
format: date-time
|
|
||||||
reopenedAt:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
format: date-time
|
|
||||||
summary:
|
|
||||||
type: string
|
|
||||||
allOf:
|
|
||||||
- if:
|
|
||||||
properties:
|
|
||||||
eventType:
|
|
||||||
enum:
|
|
||||||
- findings.due_soon
|
|
||||||
- findings.overdue
|
|
||||||
then:
|
|
||||||
required:
|
|
||||||
- dueCycleKey
|
|
||||||
properties:
|
|
||||||
dueCycleKey:
|
|
||||||
type: string
|
|
||||||
- if:
|
|
||||||
properties:
|
|
||||||
eventType:
|
|
||||||
enum:
|
|
||||||
- findings.assigned
|
|
||||||
- findings.reopened
|
|
||||||
then:
|
|
||||||
properties:
|
|
||||||
dueCycleKey:
|
|
||||||
type: 'null'
|
|
||||||
FindingNotificationDispatchResult:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- eventType
|
|
||||||
- fingerprintKey
|
|
||||||
- directDeliveryStatus
|
|
||||||
- externalDeliveryCount
|
|
||||||
properties:
|
|
||||||
eventType:
|
|
||||||
$ref: '#/components/schemas/FindingEventType'
|
|
||||||
fingerprintKey:
|
|
||||||
type: string
|
|
||||||
directDeliveryStatus:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- sent
|
|
||||||
- suppressed
|
|
||||||
- deduped
|
|
||||||
- no_recipient
|
|
||||||
externalDeliveryCount:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
externalDeliveryStatuses:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- queued
|
|
||||||
- deferred
|
|
||||||
- suppressed
|
|
||||||
- none
|
|
||||||
EventTypeOption:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- value
|
|
||||||
- label
|
|
||||||
properties:
|
|
||||||
value:
|
|
||||||
$ref: '#/components/schemas/FindingEventType'
|
|
||||||
label:
|
|
||||||
type: string
|
|
||||||
FindingAlertRuleOptionsSurface:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- eventTypes
|
|
||||||
- existingDeliveryFamiliesReused
|
|
||||||
properties:
|
|
||||||
eventTypes:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/EventTypeOption'
|
|
||||||
existingDeliveryFamiliesReused:
|
|
||||||
type: boolean
|
|
||||||
notes:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
FindingAlertDeliveryRow:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- deliveryId
|
|
||||||
- eventType
|
|
||||||
- label
|
|
||||||
- status
|
|
||||||
properties:
|
|
||||||
deliveryId:
|
|
||||||
type: integer
|
|
||||||
eventType:
|
|
||||||
$ref: '#/components/schemas/FindingEventType'
|
|
||||||
label:
|
|
||||||
type: string
|
|
||||||
tenantId:
|
|
||||||
type:
|
|
||||||
- integer
|
|
||||||
- 'null'
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
destinationName:
|
|
||||||
type: string
|
|
||||||
summary:
|
|
||||||
type: string
|
|
||||||
FindingAlertDeliveriesSurface:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- filterEventTypes
|
|
||||||
- rows
|
|
||||||
properties:
|
|
||||||
filterEventTypes:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/EventTypeOption'
|
|
||||||
rows:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/FindingAlertDeliveryRow'
|
|
||||||
FindingNotificationContext:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- findingId
|
|
||||||
- tenantId
|
|
||||||
- eventType
|
|
||||||
- recipientReason
|
|
||||||
properties:
|
|
||||||
findingId:
|
|
||||||
type: integer
|
|
||||||
tenantId:
|
|
||||||
type: integer
|
|
||||||
eventType:
|
|
||||||
$ref: '#/components/schemas/FindingEventType'
|
|
||||||
recipientReason:
|
|
||||||
$ref: '#/components/schemas/RecipientReason'
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
body:
|
|
||||||
type: string
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
# Data Model: Findings Notifications & Escalation v1
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This feature introduces no new persisted business entity. Existing finding truth, alert rules, alert deliveries, database notifications, and tenant-membership or capability truth remain canonical. The new work is a bounded derived-event layer over those existing records.
|
|
||||||
|
|
||||||
## Existing Persistent Entities
|
|
||||||
|
|
||||||
### Finding
|
|
||||||
|
|
||||||
**Purpose**: Canonical tenant-scoped finding truth for ownership, lifecycle, severity, and due-date evaluation.
|
|
||||||
|
|
||||||
**Key fields used by this feature**:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `workspace_id`
|
|
||||||
- `tenant_id`
|
|
||||||
- `severity`
|
|
||||||
- `status`
|
|
||||||
- `due_at`
|
|
||||||
- `sla_days`
|
|
||||||
- `owner_user_id`
|
|
||||||
- `assignee_user_id`
|
|
||||||
- `reopened_at`
|
|
||||||
- `resolved_at`
|
|
||||||
- `closed_at`
|
|
||||||
- `finding_type`
|
|
||||||
- `subject_type`
|
|
||||||
- `subject_external_id`
|
|
||||||
|
|
||||||
**Relationships**:
|
|
||||||
|
|
||||||
- belongs to one tenant
|
|
||||||
- belongs to one workspace through tenant ownership
|
|
||||||
- may reference one current owner user
|
|
||||||
- may reference one current assignee user
|
|
||||||
|
|
||||||
**Rules relevant to notifications**:
|
|
||||||
|
|
||||||
- Only open findings participate in assignment, due-soon, and overdue notification evaluation.
|
|
||||||
- Terminal findings suppress due-soon and overdue delivery even if they previously entered a reminder window.
|
|
||||||
- The current due cycle is keyed by `due_at`; `reopened_at` remains explanatory lifecycle context and only matters when the existing lifecycle recalculates `due_at`. No extra reminder-state field is added.
|
|
||||||
- Existing aggregate `sla_due` alerts remain separate and are not replaced by finding-level delivery.
|
|
||||||
|
|
||||||
### AlertRule
|
|
||||||
|
|
||||||
**Purpose**: Workspace-scoped configuration for optional external delivery copies.
|
|
||||||
|
|
||||||
**Key fields used by this feature**:
|
|
||||||
|
|
||||||
- `workspace_id`
|
|
||||||
- `event_type`
|
|
||||||
- `min_severity`
|
|
||||||
- `destination_ids`
|
|
||||||
- `cooldown_minutes`
|
|
||||||
- `quiet_hours`
|
|
||||||
- `enabled`
|
|
||||||
|
|
||||||
**Rules relevant to notifications**:
|
|
||||||
|
|
||||||
- The feature adds four new `event_type` values only.
|
|
||||||
- A direct personal notification does not depend on an alert rule.
|
|
||||||
- External copies still require an enabled matching rule and destination.
|
|
||||||
|
|
||||||
### AlertDelivery
|
|
||||||
|
|
||||||
**Purpose**: Existing persisted artifact for external-copy dispatch outcomes.
|
|
||||||
|
|
||||||
**Key fields used by this feature**:
|
|
||||||
|
|
||||||
- `workspace_id`
|
|
||||||
- `tenant_id`
|
|
||||||
- `event_type`
|
|
||||||
- `status`
|
|
||||||
- `destination_snapshot`
|
|
||||||
- `payload`
|
|
||||||
- `fingerprint`
|
|
||||||
- `suppressed_reason`
|
|
||||||
|
|
||||||
**Rules relevant to notifications**:
|
|
||||||
|
|
||||||
- Finding-level external copies reuse the same delivery pipeline, cooldown, suppression, and quiet-hours semantics as other alerts.
|
|
||||||
- Delivery-history viewing remains read-only and only gains the new event labels and safe summaries.
|
|
||||||
|
|
||||||
### Database Notification (`notifications` table)
|
|
||||||
|
|
||||||
**Purpose**: Existing persisted artifact for direct in-app notification delivery.
|
|
||||||
|
|
||||||
**Key fields used by this feature**:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `type`
|
|
||||||
- `notifiable_type`
|
|
||||||
- `notifiable_id`
|
|
||||||
- `data`
|
|
||||||
- `read_at`
|
|
||||||
- `created_at`
|
|
||||||
|
|
||||||
**Rules relevant to notifications**:
|
|
||||||
|
|
||||||
- The feature stores direct-delivery metadata and the deterministic `fingerprint_key` inside `data`; no new table is introduced.
|
|
||||||
- The persisted payload remains Filament-compatible so the existing notification drawer can render it unchanged.
|
|
||||||
|
|
||||||
### Tenant Membership and User Entitlement Context
|
|
||||||
|
|
||||||
**Purpose**: Current authorization truth for whether a resolved direct recipient may still inspect the tenant and finding at send time.
|
|
||||||
|
|
||||||
**Key inputs used by this feature**:
|
|
||||||
|
|
||||||
- `tenant_memberships.tenant_id`
|
|
||||||
- `tenant_memberships.user_id`
|
|
||||||
- `User::canAccessTenant($tenant)`
|
|
||||||
- `CapabilityResolver::can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)` or the existing findings-view equivalent used by the implementation seam
|
|
||||||
|
|
||||||
**Rules relevant to notifications**:
|
|
||||||
|
|
||||||
- Direct delivery is suppressed when the resolved recipient is no longer entitled.
|
|
||||||
- Open-time route authorization remains authoritative even after send-time validation.
|
|
||||||
|
|
||||||
## Derived Models
|
|
||||||
|
|
||||||
### FindingNotificationEvent
|
|
||||||
|
|
||||||
**Purpose**: Canonical derived event envelope used by both direct personal delivery and optional external alert copies.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
|
|
||||||
- `event_type`: one of `findings.assigned`, `findings.reopened`, `findings.due_soon`, `findings.overdue`
|
|
||||||
- `workspace_id`
|
|
||||||
- `tenant_id`
|
|
||||||
- `finding_id`
|
|
||||||
- `severity`
|
|
||||||
- `title`
|
|
||||||
- `body`
|
|
||||||
- `recipient_reason`: one of `new_assignee`, `current_assignee`, `current_owner`
|
|
||||||
- `resolved_recipient_user_id`: nullable
|
|
||||||
- `fingerprint_key`
|
|
||||||
- `due_cycle_key`: nullable, derived from current `due_at`
|
|
||||||
- `metadata`: object with finding summary, owner and assignee ids, due date, reopen timestamp, and deep-link-safe context
|
|
||||||
|
|
||||||
**Validation rules**:
|
|
||||||
|
|
||||||
- Event type must be one of the four new finding events.
|
|
||||||
- `recipient_reason` must match the event-specific precedence rule.
|
|
||||||
- `fingerprint_key` must deterministically distinguish the specific assignment change, reopen occurrence, or due cycle.
|
|
||||||
- `due_cycle_key` is required for `findings.due_soon` and `findings.overdue`, and omitted or null for assignment and reopen.
|
|
||||||
|
|
||||||
### RecipientResolutionResult
|
|
||||||
|
|
||||||
**Purpose**: Bounded contract that picks at most one direct recipient from existing owner and assignee truth without creating a second ownership model.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
|
|
||||||
- `user_id`: nullable
|
|
||||||
- `reason`: one of `new_assignee`, `current_assignee`, `current_owner`
|
|
||||||
- `is_entitled`: boolean
|
|
||||||
- `suppression_reason`: nullable string
|
|
||||||
|
|
||||||
**Rules**:
|
|
||||||
|
|
||||||
- `findings.assigned` resolves to the new assignee only.
|
|
||||||
- `findings.reopened` resolves to current assignee, else current owner.
|
|
||||||
- `findings.due_soon` resolves to current assignee, else current owner.
|
|
||||||
- `findings.overdue` resolves to current owner, else current assignee.
|
|
||||||
- A recipient who is not currently entitled becomes a suppression result, not a broadened-delivery fallback.
|
|
||||||
|
|
||||||
### DirectFindingNotificationMessage
|
|
||||||
|
|
||||||
**Purpose**: Filament database-notification payload rendered in the existing notification drawer.
|
|
||||||
|
|
||||||
**Fields**:
|
|
||||||
|
|
||||||
- `format = filament`
|
|
||||||
- `title`
|
|
||||||
- `body`
|
|
||||||
- `actions[0].label = Open finding`
|
|
||||||
- `actions[0].url = /admin/t/{tenant}/findings/{finding}`
|
|
||||||
- `finding_event.event_type`
|
|
||||||
- `finding_event.recipient_reason`
|
|
||||||
- `finding_event.fingerprint_key`
|
|
||||||
- `finding_event.tenant_name`
|
|
||||||
- `finding_event.severity`
|
|
||||||
|
|
||||||
**Rules**:
|
|
||||||
|
|
||||||
- One notification row represents one direct delivery to one entitled user.
|
|
||||||
- The payload must explain why the operator received the notification.
|
|
||||||
- The payload must not include hidden-tenant data beyond what the recipient is entitled to inspect.
|
|
||||||
|
|
||||||
## Event Matrix
|
|
||||||
|
|
||||||
| Event type | Trigger | Recipient precedence | Fingerprint components | Suppression rules |
|
|
||||||
|------------|---------|----------------------|------------------------|-------------------|
|
|
||||||
| `findings.assigned` | An open finding is assigned to a new assignee | new assignee | finding id + target assignee id + assignment change marker | suppress for owner-only changes, assignee clears, no-op saves, terminal findings, or non-entitled recipient |
|
|
||||||
| `findings.reopened` | A terminal finding is reopened by system detection | current assignee, else current owner | finding id + reopened occurrence marker | suppress for manual reopen, missing recipient, or non-entitled recipient |
|
|
||||||
| `findings.due_soon` | An open finding first enters the 24-hour pre-due window for the current due cycle | current assignee, else current owner | finding id + current `due_at` + event type | suppress for terminal findings, missing `due_at`, no entitled recipient, or duplicate within the same due cycle |
|
|
||||||
| `findings.overdue` | An open finding first becomes overdue for the current due cycle | current owner, else current assignee | finding id + current `due_at` + event type | suppress for terminal findings, no entitled recipient, or duplicate within the same due cycle |
|
|
||||||
|
|
||||||
## Persistence Boundaries
|
|
||||||
|
|
||||||
- No new table, enum-backed persistence, or reminder-state model is introduced.
|
|
||||||
- `notifications.data` stores direct-delivery fingerprint metadata only as a delivery artifact.
|
|
||||||
- `alert_deliveries` stores external-copy artifacts only as it already does today.
|
|
||||||
- `Finding` remains the sole business-truth model for ownership, lifecycle, and due-cycle resets.
|
|
||||||
@ -1,253 +0,0 @@
|
|||||||
# Implementation Plan: Findings Notifications & Escalation v1
|
|
||||||
|
|
||||||
**Branch**: `224-findings-notifications-escalation` | **Date**: 2026-04-22 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/224-findings-notifications-escalation/spec.md`
|
|
||||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/224-findings-notifications-escalation/spec.md`
|
|
||||||
|
|
||||||
**Note**: This plan keeps the work inside the existing findings workflow, workspace alerting, and Filament database-notification primitives. The intended implementation adds four finding event types, one narrow finding-notification service, one database notification class, and focused extensions to the existing alert evaluation and alert-management surfaces. It does not add a new table, a notification center, a preference system, a second findings queue, or a generic workflow engine.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Extend the existing workspace Alerts event vocabulary with `findings.assigned`, `findings.reopened`, `findings.due_soon`, and `findings.overdue`, then add a narrow `FindingNotificationService` that sends one entitlement-safe direct database notification to the currently responsible operator and forwards the same event into `AlertDispatchService` for optional external copies. Emit assignment and system-reopen events from `FindingWorkflowService` after committed mutations, evaluate due-soon and overdue windows inside the existing `EvaluateAlertsJob` cadence, reuse the existing `notifications` table and Filament database-notification drawer for direct delivery, and keep finding follow-up on the existing tenant finding detail route.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade
|
|
||||||
**Primary Dependencies**: Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource`
|
|
||||||
**Storage**: PostgreSQL via existing `findings`, `alert_rules`, `alert_deliveries`, `notifications`, `tenant_memberships`, and `audit_logs`; no schema changes planned
|
|
||||||
**Testing**: Pest v4 feature tests with Filament/Livewire assertions and notification-payload checks
|
|
||||||
**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 direct notification dispatch and external alert-copy generation DB-backed and queue-safe, avoid N+1 tenant or recipient lookups, and keep due-window scans bounded to workspace-scoped open findings with due-date indexes already present
|
|
||||||
**Constraints**: No new persisted notification-truth table, no notification-preference center, no new capability family, no hidden-tenant leakage, no manual-reopen notification in v1, no repeated overdue spam within the same due cycle, and no new frontend assets
|
|
||||||
**Scale/Scope**: Four new event types, one narrow finding-notification service, one new database notification class, extensions to two existing alert resources, one extension to the existing alerts evaluation job, and four focused feature suites
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Plan
|
|
||||||
|
|
||||||
- **Guardrail scope**: changed surfaces
|
|
||||||
- **Native vs custom classification summary**: native Filament resources and existing database-notification primitives only
|
|
||||||
- **Shared-family relevance**: workspace alert configuration and delivery-history family, existing admin and tenant database-notification family, existing tenant finding detail family
|
|
||||||
- **State layers in scope**: shell, detail
|
|
||||||
- **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 extends existing alert resources and notification primitives rather than creating a new page 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 | All event production stays derived from existing `Finding` lifecycle, ownership, severity, and due-date truth; notifications and alert deliveries remain delivery artifacts only |
|
|
||||||
| Read/write separation | PASS | PASS | The feature adds no new operator mutation surface; assignment and reopen writes stay inside existing workflow actions, while due reminders remain scheduled evaluation side effects |
|
|
||||||
| Graph contract path | PASS | PASS | No Microsoft Graph call paths or contract-registry changes are introduced |
|
|
||||||
| Deterministic capabilities / RBAC-UX | PASS | PASS | Workspace-scoped alert configuration remains capability-gated, direct recipients are re-validated against current tenant membership plus findings-view capability at send time, non-members remain `404`, and in-scope capability failures remain `403` |
|
|
||||||
| Workspace / tenant isolation | PASS | PASS | Alert rules and alert deliveries stay workspace-scoped, while direct notifications always deep-link to tenant-scoped finding detail and must not expose hidden tenant data |
|
|
||||||
| Run observability / Ops-UX | PASS | PASS | Scheduled due-event evaluation stays inside the existing `alerts.evaluate` cadence; no `OperationRun` notification semantics are changed and no queued/running DB notifications are introduced |
|
|
||||||
| Proportionality / no premature abstraction | PASS | PASS | The plan allows one narrow `FindingNotificationService` because direct-recipient resolution, dedupe, payload composition, and external alert-copy forwarding would otherwise be duplicated across workflow mutations and scheduled due evaluation |
|
|
||||||
| Persisted truth / few layers | PASS | PASS | No new table or persisted workflow state is added; existing `notifications` JSONB and `alert_deliveries` rows remain the only delivery artifacts |
|
|
||||||
| Behavioral state discipline | PASS | PASS | The four new values are delivery event types, not a new finding lifecycle or responsibility taxonomy |
|
|
||||||
| Filament-native UI (UI-FIL-001) | PASS | PASS | Alert rules and alert deliveries remain native Filament resources; direct notifications use existing Filament database-notification payloads |
|
|
||||||
| Decision-first / action-surface contract | PASS | PASS | Notifications stay as secondary drill-in entry points, alert rules remain config-first, and alert deliveries remain read-only diagnostics |
|
|
||||||
| Test governance (TEST-GOV-001) | PASS | PASS | Proof stays in focused feature suites for event production, routing, alert-rule integration, and deep-link safety, with no browser or heavy-governance expansion |
|
|
||||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | The feature uses existing Filament v5 resources and Livewire v4-compatible database notifications only |
|
|
||||||
| Provider registration / global search / assets | PASS | PASS | Panel providers already live in `apps/platform/bootstrap/providers.php`; no globally searchable resource is added or changed; no new assets are required, so the existing deploy `filament:assets` step remains unchanged |
|
|
||||||
|
|
||||||
## Test Governance Check
|
|
||||||
|
|
||||||
- **Test purpose / classification by changed surface**: `Feature` for finding-event production, direct-recipient routing, alert-rule UI integration, and deep-link authorization behavior
|
|
||||||
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
|
||||||
- **Why this lane mix is the narrowest sufficient proof**: The main risk is integrated workflow behavior: which event fires, who gets it, whether direct delivery leaks scope, whether external copies remain optional, and whether alert-management surfaces expose the new event types coherently. Focused feature tests prove that without adding unit-only abstractions or browser cost.
|
|
||||||
- **Narrowest proving command(s)**:
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php`
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php tests/Feature/Alerts/SlaDueAlertTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
|
||||||
- **Fixture / helper / factory / seed / context cost risks**: Moderate. Tests need workspace and tenant context, current and removed memberships, owner-versus-assignee combinations, existing alert rules and destinations, notification-table assertions, and time-travel across due windows.
|
|
||||||
- **Expensive defaults or shared helper growth introduced?**: no; any notification-specific helper should stay local to the new tests and reuse existing `createUserWithTenant(...)`, `Finding::factory()`, and alert destination factories
|
|
||||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
|
||||||
- **Surface-class relief / special coverage rule**: `standard-native-filament` for alert resources and `global-context-shell` for database notifications that bridge admin shell context to tenant finding detail
|
|
||||||
- **Closing validation and reviewer handoff**: Reviewers should rely on the exact commands above and verify that owner-only changes do not emit assignment notifications, manual reopen still emits nothing, due-soon and overdue stay one-per-due-cycle, same-user owner/assignee resolution creates one notification, direct delivery suppresses when entitlement is lost, and external alert copies only appear when a matching alert rule exists.
|
|
||||||
- **Budget / baseline / trend follow-up**: none
|
|
||||||
- **Review-stop questions**: Did the implementation introduce new persistence, a preference layer, or a generic workflow-notification engine? Did any path leak hidden tenant information in the notification title, body, or action URL? Did due evaluation widen beyond the existing alert-evaluation cadence without need? Did alert-rule resource changes stay native and read clearly?
|
|
||||||
- **Escalation path**: document-in-feature unless a second delivery abstraction, a preference center, or a new findings-specific notification surface 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 four concrete event types and one direct-delivery contract built on existing infrastructure
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/224-findings-notifications-escalation/
|
|
||||||
├── plan.md
|
|
||||||
├── research.md
|
|
||||||
├── data-model.md
|
|
||||||
├── quickstart.md
|
|
||||||
├── contracts/
|
|
||||||
│ └── findings-notifications-escalation.logical.openapi.yaml
|
|
||||||
├── checklists/
|
|
||||||
│ └── requirements.md
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/platform/
|
|
||||||
├── app/
|
|
||||||
│ ├── Filament/
|
|
||||||
│ │ └── Resources/
|
|
||||||
│ │ ├── AlertDeliveryResource.php
|
|
||||||
│ │ └── AlertRuleResource.php
|
|
||||||
│ ├── Jobs/
|
|
||||||
│ │ └── Alerts/
|
|
||||||
│ │ └── EvaluateAlertsJob.php
|
|
||||||
│ ├── Models/
|
|
||||||
│ │ └── AlertRule.php
|
|
||||||
│ ├── Notifications/
|
|
||||||
│ │ └── Findings/
|
|
||||||
│ │ └── FindingEventNotification.php
|
|
||||||
│ └── Services/
|
|
||||||
│ ├── Alerts/
|
|
||||||
│ │ └── AlertDispatchService.php
|
|
||||||
│ └── Findings/
|
|
||||||
│ ├── FindingNotificationService.php
|
|
||||||
│ ├── FindingSlaPolicy.php
|
|
||||||
│ └── FindingWorkflowService.php
|
|
||||||
├── database/
|
|
||||||
│ └── factories/
|
|
||||||
│ └── FindingFactory.php
|
|
||||||
└── tests/
|
|
||||||
└── Feature/
|
|
||||||
├── Alerts/
|
|
||||||
│ └── FindingsAlertRuleIntegrationTest.php
|
|
||||||
├── Findings/
|
|
||||||
│ ├── FindingsNotificationEventTest.php
|
|
||||||
│ └── FindingsNotificationRoutingTest.php
|
|
||||||
└── Notifications/
|
|
||||||
└── FindingNotificationLinkTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Standard Laravel monolith. The feature stays inside existing finding workflow, alerting, and notification 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**: Finding assignment, automatic reopen, and due-state changes remain silent unless operators keep polling findings pages.
|
|
||||||
- **Existing structure is insufficient because**: `FindingWorkflowService` knows about ownership changes but not delivery, `EvaluateAlertsJob` knows about workspace alert events but not direct responsible-user delivery, and the existing alert-rule UI cannot express these finding-specific workflow events yet.
|
|
||||||
- **Narrowest correct implementation**: Add four event types, extend the existing alert-rule and delivery viewer labels, create one narrow `FindingNotificationService` to unify recipient resolution plus direct and external delivery, and emit events only from existing workflow and alert-evaluation seams.
|
|
||||||
- **Ownership cost created**: One service, one notification class, incremental logic in `FindingWorkflowService` and `EvaluateAlertsJob`, and four focused feature suites.
|
|
||||||
- **Alternative intentionally rejected**: A new `FindingNotificationDelivery` table or generic workflow-notification engine. Both add persistence or framework complexity that current-release truth does not require because existing `notifications` and `alert_deliveries` already capture delivery artifacts.
|
|
||||||
- **Release truth**: Current-release truth. The feature closes an existing workflow loop now rather than preparing a later escalation framework.
|
|
||||||
|
|
||||||
## Phase 0 Research
|
|
||||||
|
|
||||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/224-findings-notifications-escalation/research.md`.
|
|
||||||
|
|
||||||
Key decisions:
|
|
||||||
|
|
||||||
- Add the four new finding event types to the existing `AlertRule` constant registry and `AlertRuleResource::eventTypeOptions()` instead of creating a second event catalog.
|
|
||||||
- Reuse existing Filament database notifications on the admin panel rather than building a findings-specific notification surface or page.
|
|
||||||
- Emit `findings.assigned` and `findings.reopened` from `FindingWorkflowService` after the write transaction commits so delivery does not race an uncommitted finding state.
|
|
||||||
- Evaluate `findings.due_soon` and `findings.overdue` inside the existing `EvaluateAlertsJob` workspace cadence rather than adding a second scheduler, command, or `OperationRun` family.
|
|
||||||
- Use the existing `notifications` table `data` payload to store a finding-event fingerprint for direct-delivery dedupe; do not add a new persistence model.
|
|
||||||
- Reuse `FindingResource::getUrl(..., panel: 'tenant', tenant: $tenant)` for notification deep links and re-check entitlement before sending any direct notification.
|
|
||||||
|
|
||||||
## Phase 1 Design
|
|
||||||
|
|
||||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/224-findings-notifications-escalation/`:
|
|
||||||
|
|
||||||
- `research.md`: event-registry, delivery, dedupe, and evaluation-seam decisions
|
|
||||||
- `data-model.md`: existing entities plus the derived finding-event and direct-notification payload models
|
|
||||||
- `contracts/findings-notifications-escalation.logical.openapi.yaml`: internal logical contract for finding-event dispatch, alert-rule event exposure, alert-delivery viewing, and finding deep-link payloads
|
|
||||||
- `quickstart.md`: focused validation workflow for implementation and review
|
|
||||||
|
|
||||||
Design decisions:
|
|
||||||
|
|
||||||
- No schema migration is required; direct notifications use the existing `notifications` table and external copies use existing `alert_deliveries` rows.
|
|
||||||
- The canonical new seam is one narrow `FindingNotificationService`, not a reusable workflow-notification framework.
|
|
||||||
- Direct-recipient dedupe uses a fingerprint stored in the existing database notification payload; due-cycle reset keys are derived from the current `due_at` value.
|
|
||||||
- Alert rules remain optional for external copies; direct responsible-user notifications do not depend on any matching alert rule.
|
|
||||||
- Existing tenant finding detail remains the only follow-up surface, and current `404` versus `403` route behavior remains authoritative at open time.
|
|
||||||
|
|
||||||
## Phase 1 Agent Context Update
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
|
||||||
|
|
||||||
## Constitution Check — Post-Design Re-evaluation
|
|
||||||
|
|
||||||
- PASS — the design remains inside current findings, alerts, and database-notification seams with no new persistence, no Graph work, no new capability family, and no new frontend 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 new destructive action path is introduced.
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### Phase A — Extend the existing alert-event vocabulary and operator labels
|
|
||||||
|
|
||||||
**Goal**: Teach existing alert-management surfaces about the four new finding workflow events.
|
|
||||||
|
|
||||||
| Step | File | Change |
|
|
||||||
|------|------|--------|
|
|
||||||
| A.1 | `apps/platform/app/Models/AlertRule.php` | Add the four new `EVENT_FINDINGS_*` constants alongside the existing alert event types |
|
|
||||||
| A.2 | `apps/platform/app/Filament/Resources/AlertRuleResource.php` | Extend `eventTypeOptions()` and `eventTypeLabel()` with operator-facing labels for assignment, reopened, due soon, and overdue events |
|
|
||||||
| A.3 | `apps/platform/app/Filament/Resources/AlertDeliveryResource.php` | Ensure list, view, and filter surfaces continue to render the new event labels cleanly through the existing event-type label seam without adding a new delivery viewer |
|
|
||||||
|
|
||||||
### Phase B — Add one narrow finding-notification delivery seam on existing primitives
|
|
||||||
|
|
||||||
**Goal**: Send one entitlement-safe direct database notification and one optional external alert copy from the same event envelope.
|
|
||||||
|
|
||||||
| Step | File | Change |
|
|
||||||
|------|------|--------|
|
|
||||||
| B.1 | `apps/platform/app/Services/Findings/FindingNotificationService.php` | Add a focused service that builds finding-event payloads, resolves the direct recipient by the spec precedence rules, checks current tenant entitlement plus findings-view capability, computes a delivery fingerprint, suppresses duplicates by querying existing database notifications, and forwards the same event array to `AlertDispatchService` for external copies |
|
|
||||||
| B.2 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Add a database notification class that returns Filament notification payloads with tenant-safe title/body copy, recipient-reason copy, one finding-detail action URL, and embedded metadata including the event type and fingerprint |
|
|
||||||
| B.3 | `apps/platform/app/Services/Alerts/AlertDispatchService.php` | Keep the existing workspace alert-copy path intact and only absorb any payload-shape normalization needed for the new finding event titles, body copy, and metadata |
|
|
||||||
|
|
||||||
### Phase C — Emit assignment and automatic-reopen notifications from existing finding workflow mutations
|
|
||||||
|
|
||||||
**Goal**: Turn current write seams into finding-event producers without changing workflow truth.
|
|
||||||
|
|
||||||
| Step | File | Change |
|
|
||||||
|------|------|--------|
|
|
||||||
| C.1 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` | After committed `assign(...)` mutations, compare before and after owner and assignee state, suppress no-op, owner-only, and assignee-clear transitions, and dispatch `findings.assigned` only when a new assignee is set on an open finding |
|
|
||||||
| C.2 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` | After committed `reopenBySystem(...)` mutations, dispatch `findings.reopened` using the refreshed finding state; keep manual `reopen(...)` out of scope for v1 delivery |
|
|
||||||
| C.3 | `apps/platform/app/Services/Findings/FindingWorkflowService.php` and `apps/platform/database/factories/FindingFactory.php` | Preserve existing due-date reset and reopen semantics so notification due-cycle logic stays derived from the recalculated `due_at` value, with reopen metadata remaining explanatory rather than becoming a second cycle key |
|
|
||||||
|
|
||||||
### Phase D — Evaluate due-soon and overdue events inside the existing alerts evaluation cadence
|
|
||||||
|
|
||||||
**Goal**: Keep scheduled due reminders and escalations inside the already-established workspace alert window.
|
|
||||||
|
|
||||||
| Step | File | Change |
|
|
||||||
|------|------|--------|
|
|
||||||
| D.1 | `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php` | Add finding-level due-soon and overdue candidate queries over workspace-scoped open findings with `due_at`, using the existing window semantics and a fixed v1 due-soon horizon of 24 hours |
|
|
||||||
| D.2 | `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php` | For each candidate finding event, call `FindingNotificationService` so direct delivery and optional external alert copies stay consistent and no second dispatch pipeline appears |
|
|
||||||
| D.3 | `apps/platform/app/Services/Findings/FindingNotificationService.php` | Define due-cycle fingerprints from the current `due_at` value so due-soon and overdue notifications emit once per cycle and reset only when `due_at` is recalculated by existing lifecycle semantics |
|
|
||||||
|
|
||||||
### Phase E — Preserve tenant-safe deep links and existing notification shell behavior
|
|
||||||
|
|
||||||
**Goal**: Reuse the current notification drawer and finding detail route without widening visibility.
|
|
||||||
|
|
||||||
| Step | File | Change |
|
|
||||||
|------|------|--------|
|
|
||||||
| E.1 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Build finding action URLs with `FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)` so links target the tenant panel explicitly |
|
|
||||||
| E.2 | Existing admin panel notification configuration | Rely on the already-configured `->databaseNotifications()` behavior in `AdminPanelProvider`; do not add polling, a new page, or a new notification surface |
|
|
||||||
| E.3 | Existing finding detail route behavior | Keep current route authorization authoritative so an operator who lost access after send time still receives the existing `404` or `403` outcome instead of leaked detail |
|
|
||||||
|
|
||||||
### Phase F — Protect event truth, routing, and link safety with focused regression coverage
|
|
||||||
|
|
||||||
**Goal**: Lock down event production, recipient precedence, alert-rule integration, and tenant-safe finding drilldown.
|
|
||||||
|
|
||||||
| Step | File | Change |
|
|
||||||
|------|------|--------|
|
|
||||||
| F.1 | `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php` | Cover assignment-event production, system-reopen event production, rapid reassignment and repeated automatic-reopen dedupe, due-soon and overdue evaluation windows, terminal-finding suppression, and one-per-due-cycle behavior |
|
|
||||||
| F.2 | `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php` | Cover recipient precedence, same-user owner and assignee dedupe, owner-only assignment suppression, entitlement-loss suppression, and no direct delivery without a current eligible recipient |
|
|
||||||
| F.3 | `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php` and `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php` | Cover alert-rule event-type option exposure, `ALERTS_VIEW` read versus `ALERTS_MANAGE` mutation boundaries, inherited Alerts v1 external-copy behavior including minimum severity, tenant scoping, cooldown, quiet hours, and dedupe, delivery-history labels and filters, the rule-free case where direct personal delivery still occurs, and non-regression of existing aggregate `sla_due` behavior |
|
|
||||||
| F.4 | `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php` | Cover database notification payload shape, explicit tenant-panel URLs, and finding-detail open behavior with correct tenant-safe `404` and `403` semantics |
|
|
||||||
| F.5 | `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 |
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
# Quickstart: Findings Notifications & Escalation v1
|
|
||||||
|
|
||||||
## 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 one tenant, two tenant users, and existing findings data.
|
|
||||||
|
|
||||||
3. Ensure you can create or edit alert rules and inspect alert deliveries in the admin panel.
|
|
||||||
|
|
||||||
4. Remember that Filament database notification polling is intentionally disabled in this repo, so reload the page or reopen the notification drawer after each trigger when validating manually.
|
|
||||||
|
|
||||||
## 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/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php tests/Feature/Alerts/SlaDueAlertTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manual Validation Flow
|
|
||||||
|
|
||||||
### 1. Confirm alert-rule event types are available
|
|
||||||
|
|
||||||
1. Open the admin panel alert-rule create or edit form.
|
|
||||||
2. Verify the event selector contains:
|
|
||||||
- `Finding assigned`
|
|
||||||
- `Finding reopened`
|
|
||||||
- `Finding due soon`
|
|
||||||
- `Finding overdue`
|
|
||||||
3. Save a rule that targets one of the new event types and confirm the Alert deliveries viewer can filter and label it correctly.
|
|
||||||
|
|
||||||
### 1b. Validate admin alert-surface RBAC semantics
|
|
||||||
|
|
||||||
1. As a workspace member with `ALERTS_VIEW` but without `ALERTS_MANAGE`, confirm the existing alert-rule and alert-delivery read surfaces open successfully while alert-rule mutation stays forbidden.
|
|
||||||
2. As an in-scope workspace member without `ALERTS_VIEW`, confirm the existing alert-rule and alert-delivery view surfaces return `403`.
|
|
||||||
3. As a non-member or wrong-workspace user, confirm the existing alert-rule and alert-delivery surfaces return `404`.
|
|
||||||
|
|
||||||
### 2. Validate direct assignment notification
|
|
||||||
|
|
||||||
1. Start with an open finding that has no assignee or a different assignee.
|
|
||||||
2. Assign the finding to a new entitled operator.
|
|
||||||
3. Reload the shell and open the database notification drawer.
|
|
||||||
4. Confirm the new assignee receives exactly one notification with:
|
|
||||||
- the finding vocabulary
|
|
||||||
- an explanation that the finding was assigned to them
|
|
||||||
- one `Open finding` action
|
|
||||||
5. Confirm owner-only changes, assignee clears, and no-op saves emit no assignment notification.
|
|
||||||
|
|
||||||
### 3. Validate automatic reopen notification
|
|
||||||
|
|
||||||
1. Start with a terminal finding that still has an assignee or owner.
|
|
||||||
2. Trigger the existing system path that reopens the finding through recurring detection.
|
|
||||||
3. Reload the shell and confirm one `Finding reopened` notification reaches the current assignee, or the current owner if no assignee exists.
|
|
||||||
4. Confirm manual reopen remains silent in v1.
|
|
||||||
|
|
||||||
### 4. Validate due-soon and overdue direct delivery
|
|
||||||
|
|
||||||
1. Prepare two open findings:
|
|
||||||
- one with `due_at` inside the next 24 hours
|
|
||||||
- one with `due_at` already in the past
|
|
||||||
2. Run the existing alert-evaluation command for the target workspace.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/platform && ./vendor/bin/sail artisan tenantpilot:alerts:dispatch --workspace=<workspace-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Reload the notification drawer.
|
|
||||||
4. Confirm:
|
|
||||||
- due soon goes to the current assignee, else the current owner
|
|
||||||
- overdue goes to the current owner, else the current assignee
|
|
||||||
- if owner and assignee are the same user, only one direct notification is created
|
|
||||||
5. Run the same command again without changing `due_at` or reopening the finding and confirm no duplicate due-soon or overdue direct notification is created for the current cycle.
|
|
||||||
6. Recalculate the due cycle through the existing lifecycle contract, then rerun evaluation and confirm one fresh due notification can emit for the new cycle.
|
|
||||||
|
|
||||||
### 5. Validate optional external copies through existing alert rules
|
|
||||||
|
|
||||||
1. Enable an alert rule for one of the new finding event types with a real destination.
|
|
||||||
2. Trigger the corresponding event again.
|
|
||||||
3. Confirm the direct responsible-user notification still appears.
|
|
||||||
4. Confirm one or more `Alert deliveries` rows are created only when a matching enabled rule exists.
|
|
||||||
5. Confirm the existing tenant-level aggregate `sla_due` alert behavior remains unchanged.
|
|
||||||
|
|
||||||
### 6. Validate entitlement-safe deep links
|
|
||||||
|
|
||||||
1. Create a direct finding notification for an entitled recipient.
|
|
||||||
2. Remove that user’s tenant membership or findings-view capability.
|
|
||||||
3. Open the notification action URL.
|
|
||||||
4. Confirm the existing route behavior remains authoritative:
|
|
||||||
- hidden tenant or record paths stay `404`
|
|
||||||
- in-scope but unauthorized access stays `403`
|
|
||||||
5. Confirm the notification title and body never broaden disclosure beyond the existing finding summary vocabulary.
|
|
||||||
|
|
||||||
## 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, so no new confirmation flow is required.
|
|
||||||
- Asset strategy is unchanged: no new panel or shared assets, and the existing deploy `filament:assets` step remains sufficient.
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
# Research: Findings Notifications & Escalation v1
|
|
||||||
|
|
||||||
## Decision 1: Reuse the existing alert event registry and alert-rule surfaces
|
|
||||||
|
|
||||||
**Decision**: Add the four finding event constants to `AlertRule` and expose them through `AlertRuleResource::eventTypeOptions()` and `AlertRuleResource::eventTypeLabel()`.
|
|
||||||
|
|
||||||
**Rationale**: Workspace alert rules, destinations, deliveries, cooldowns, quiet hours, and delivery-history viewing already exist and are the approved external-copy path. Extending that registry keeps rule configuration and delivery viewing in one place and avoids a second event catalog.
|
|
||||||
|
|
||||||
**Alternatives considered**:
|
|
||||||
|
|
||||||
- Create a findings-specific alert configuration surface. Rejected because the spec explicitly requires reuse of the existing alert system and forbids a second preference or notification center.
|
|
||||||
- Pass new event strings ad hoc without updating the central registry. Rejected because alert-rule selectors, delivery filters, and labels would drift immediately.
|
|
||||||
|
|
||||||
## Decision 2: Use existing Filament database notifications for direct personal delivery
|
|
||||||
|
|
||||||
**Decision**: Deliver direct operator notifications through Laravel database notifications with Filament payloads stored in the existing `notifications` table.
|
|
||||||
|
|
||||||
**Rationale**: `AdminPanelProvider` already enables `databaseNotifications()`, and the existing `OperationRunQueued` and `OperationRunCompleted` notification classes show the repository’s established pattern for title, body, and action-link payloads. This satisfies the requirement for in-app personal notifications without creating a new page or asset surface.
|
|
||||||
|
|
||||||
**Alternatives considered**:
|
|
||||||
|
|
||||||
- Build a custom Livewire inbox or findings-notification page. Rejected because it introduces a second notification surface and broader UI scope than the spec allows.
|
|
||||||
- Deliver only external copies through email or Teams. Rejected because the spec requires direct in-app notifications first and treats external channels as optional copies controlled by alert rules.
|
|
||||||
|
|
||||||
## Decision 3: Emit assignment and automatic-reopen events from `FindingWorkflowService` after commit
|
|
||||||
|
|
||||||
**Decision**: Hook `findings.assigned` and `findings.reopened` at the workflow-service boundary after `mutateAndAudit(...)` returns a refreshed `Finding` record.
|
|
||||||
|
|
||||||
**Rationale**: `FindingWorkflowService` already owns assignment, reopen, due-date reset, authorization, and audit semantics. Emitting after commit avoids dispatching notification side effects for rolled-back writes and keeps event truth attached to the seam that actually changes responsibility or lifecycle.
|
|
||||||
|
|
||||||
**Alternatives considered**:
|
|
||||||
|
|
||||||
- Emit from controllers, Filament actions, or pages. Rejected because some finding changes are system-driven and those surfaces do not own the canonical mutation truth.
|
|
||||||
- Emit inside the transaction. Rejected because direct or external delivery could start before the write commits.
|
|
||||||
- Notify on manual `reopen(...)` in the same pass. Rejected for v1 because the spec is limited to automatic reopen notification and keeping manual reopen silent reduces scope.
|
|
||||||
|
|
||||||
## Decision 4: Keep due-soon and overdue evaluation inside `EvaluateAlertsJob`
|
|
||||||
|
|
||||||
**Decision**: Extend `EvaluateAlertsJob` with finding due-soon and overdue candidate scans and route each candidate through the finding-notification delivery seam.
|
|
||||||
|
|
||||||
**Rationale**: The job already evaluates workspace-scoped alert events on a scheduled cadence, carries evaluation-window semantics, and runs inside the existing `alerts.evaluate` operational path. Reusing it avoids a second scheduler, command, or run family.
|
|
||||||
|
|
||||||
**Alternatives considered**:
|
|
||||||
|
|
||||||
- Create a dedicated due-notification job or Artisan command. Rejected because it duplicates scheduling, workspace scoping, and operational observability for no functional gain.
|
|
||||||
- Trigger due reminders from the UI. Rejected because reminder delivery must not depend on an operator visiting a page.
|
|
||||||
|
|
||||||
## Decision 5: Use notification-payload metadata for direct-delivery dedupe
|
|
||||||
|
|
||||||
**Decision**: Store a `fingerprint_key` and event metadata in the existing database notification `data` JSONB payload and query the existing `notifications` table to suppress duplicate direct deliveries.
|
|
||||||
|
|
||||||
**Rationale**: The spec forbids new persistence while still requiring one notification per due cycle and fingerprint-aware delivery semantics. Existing notification rows are already persisted delivery artifacts, so they are the narrowest place to record direct-delivery fingerprints without adding a new table.
|
|
||||||
|
|
||||||
**Alternatives considered**:
|
|
||||||
|
|
||||||
- Create a new `FindingNotificationDelivery` table. Rejected because it adds persistence and lifecycle ownership that the spec explicitly avoids.
|
|
||||||
- Skip direct dedupe entirely. Rejected because due-soon and overdue would repeat on every scheduled evaluation pass.
|
|
||||||
|
|
||||||
## Decision 6: Reuse tenant finding detail links and current entitlement checks
|
|
||||||
|
|
||||||
**Decision**: Build notification actions against the existing tenant-panel finding detail route and suppress send-time direct delivery if the selected recipient no longer has current tenant membership plus findings-view capability.
|
|
||||||
|
|
||||||
**Rationale**: The spec requires the notification to explain why the user is seeing it and send them to the existing finding detail route while preserving current `404` and `403` semantics. Rechecking entitlement at send time prevents stale assignments or ownership from generating a misleading in-app notification.
|
|
||||||
|
|
||||||
**Alternatives considered**:
|
|
||||||
|
|
||||||
- Link to the My Findings inbox instead of the single finding detail. Rejected because the spec calls for direct follow-up on the existing finding detail and the inbox adds avoidable navigation indirection.
|
|
||||||
- Add a new notification-detail page. Rejected because it duplicates finding detail and creates a second follow-up surface.
|
|
||||||
- Skip send-time entitlement revalidation. Rejected because tenant membership and capability state can change between assignment and delivery.
|
|
||||||
@ -1,257 +0,0 @@
|
|||||||
# Feature Specification: Findings Notifications & Escalation v1
|
|
||||||
|
|
||||||
**Feature Branch**: `224-findings-notifications-escalation`
|
|
||||||
**Created**: 2026-04-22
|
|
||||||
**Status**: Draft
|
|
||||||
**Input**: User description: "Findings Notifications & Escalation v1"
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: Findings now have clear owner-versus-assignee semantics plus personal and shared work surfaces, but assignment, automatic reopen, and aging transitions still remain silent unless operators keep polling findings pages.
|
|
||||||
- **Today's failure**: A finding can be assigned, auto-reopened, become due soon, or become overdue without the responsible operator noticing in time. Due dates become passive metadata instead of a real control loop.
|
|
||||||
- **User-visible improvement**: Responsible operators receive calm, tenant-safe notifications with one clear deep link when work is assigned, reopens automatically, approaches its due date, or becomes overdue. Workspace teams can optionally add existing external alert copies without building a second findings-specific notification system.
|
|
||||||
- **Smallest enterprise-capable version**: Add four finding event types, one bounded recipient-resolution contract over existing owner and assignee truth, direct personal notifications for actionable finding events, and optional workspace-level external copies through the existing Alerts rules and destinations.
|
|
||||||
- **Explicit non-goals**: No multi-stage escalation chains, no notification-preference center, no comments or chat, no external ticket synchronization, no new owner-only queue, and no generic workflow engine.
|
|
||||||
- **Permanent complexity imported**: Four finding event types, one bounded recipient-resolution contract, one direct-notification copy contract, and focused regression coverage for entitlement-safe delivery and dedupe.
|
|
||||||
- **Why now**: Spec 219 clarified responsibility, Spec 221 created the personal assignee queue, and Spec 222 created shared intake. The next missing slice is to close the loop so those surfaces no longer rely on manual polling.
|
|
||||||
- **Why not local**: A badge or queue-only polish fix would still leave assignment, reopen, and aging changes silent across the workspace. The gap is cross-cutting workflow feedback, not one page.
|
|
||||||
- **Approval class**: Core Enterprise
|
|
||||||
- **Red flags triggered**: One bounded new recipient-resolution rule and one new event family. Scope stays acceptable because it reuses existing finding truth, existing alert delivery infrastructure, and existing notification primitives.
|
|
||||||
- **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**: workspace
|
|
||||||
- **Primary Routes**:
|
|
||||||
- Admin UI → Workspace → Monitoring → Alerts → Alert rules
|
|
||||||
- Admin UI → Workspace → Monitoring → Alerts → Alert deliveries
|
|
||||||
- `/admin/t/{tenant}/findings/{finding}` as the primary follow-up destination from finding notifications
|
|
||||||
- **Data Ownership**:
|
|
||||||
- Tenant-owned findings remain the only source of truth for assignment, reopen state, due state, severity, and tenant scope.
|
|
||||||
- Workspace-owned alert rules and alert destinations remain the only source of truth for external copies and shared escalation delivery.
|
|
||||||
- Existing user notification records remain delivery artifacts only and must not become a second findings workflow state store.
|
|
||||||
- **RBAC**:
|
|
||||||
- Workspace membership is required for alert rule and alert delivery surfaces.
|
|
||||||
- `ALERTS_VIEW` gates viewing alert-rule configuration and alert delivery history.
|
|
||||||
- `ALERTS_MANAGE` gates creating, editing, enabling, disabling, or deleting external alert-copy rules.
|
|
||||||
- Direct finding notifications are only delivered to currently entitled operators who may already inspect the target tenant and finding scope.
|
|
||||||
- Non-members or wrong-plane requests remain deny-as-not-found. Members missing capability receive `403` on protected configuration surfaces.
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| Alert rules resource gains finding notification event types and routing copy | yes | Native Filament resource forms + existing alert configuration primitives | Same workspace monitoring configuration family as existing alert rules | form, event labels, routing helper copy | no | Existing surface extended; no new alert page |
|
|
||||||
| Alert deliveries viewer gains finding-event labels and delivery visibility | yes | Native Filament table + existing alert delivery viewer | Same workspace alert observability family | table, filter labels, delivery summaries | no | Existing viewer extended; still read-only |
|
|
||||||
| Direct finding notifications and deep links | yes | Existing in-app notification primitives + shared link helpers | Same personal workflow entry-point family as other operator notifications | notification payload, deep link, recipient explanation | no | Reuses current notification surface; no new notification center |
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| Alert rules resource gains finding notification event types and routing copy | Secondary Context Surface | A workspace operator decides which finding events should also create external or shared-team copies | Event type, tenant scope, severity threshold, cooldown, quiet-hours, and destinations | Delivery history, raw event keys, and failure diagnostics | Secondary because it configures workflow feedback rather than hosting the work itself | Keeps findings notification policy inside existing Alerts administration | Avoids inventing a second findings-specific settings area |
|
|
||||||
| Alert deliveries viewer gains finding-event labels and delivery visibility | Tertiary Evidence / Diagnostics Surface | A workspace operator verifies whether rule-based copies were sent, deferred, suppressed, or failed | Event label, tenant, severity, status, and timestamp | Safe diagnostics and historical delivery details | Tertiary because it explains delivery history after the notification decision already happened | Preserves one audit-style surface for external delivery truth | Avoids debugging notification behavior by searching logs or guessing destination behavior |
|
|
||||||
| Direct finding notifications and deep links | Secondary Context Surface | An operator receives a finding event and decides whether to open the finding now | Why the operator was notified, the affected tenant, the finding summary, severity, and one deep link | Full finding detail, evidence, audit trail, and workflow actions after opening the finding | Secondary because the notification points into the real work surface instead of replacing it | Aligns assignment and aging signals with the already-established findings workflow surfaces | Removes repeated polling of My Findings, intake, and tenant-local findings pages |
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Alert rules resource gains finding notification event types and routing copy | Config / Settings | Workflow routing configuration | Save or update a rule for finding events | Alert rule | required on the existing list | Existing `More` group on the alert-rules list | Existing destructive actions remain grouped and confirmed on the resource | Workspace Monitoring → Alerts → Alert rules | Existing alert-rule edit surface | Workspace scope, event type, severity, tenant targeting, destinations | Alert rules / Alert rule | Which finding events are routed externally and under which scope rules | none |
|
|
||||||
| Alert deliveries viewer gains finding-event labels and delivery visibility | Utility / System | Read-only log / report surface | Inspect whether a finding-event delivery was sent or suppressed | Alert delivery | allowed on the existing viewer | Existing read-only filter and inspect controls only | none | Workspace Monitoring → Alerts → Alert deliveries | Existing delivery inspect surface | Workspace scope, tenant label, delivery status, event type | Alert deliveries / Alert delivery | Whether a finding-event copy was sent, deferred, suppressed, or failed | none |
|
|
||||||
| Direct finding notifications and deep links | Utility / System | Notification / drill-in entry point | Open the finding that needs follow-up | Finding | forbidden | No competing mutation; one deep link only | none | Existing in-app notification surface | `/admin/t/{tenant}/findings/{finding}` | Tenant name, severity, recipient reason, event label | Findings / Finding | Why this operator was notified and what finding requires attention | Existing notification surface reused; no new standalone page |
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Alert rules resource gains finding notification event types and routing copy | Workspace operator with alert-management responsibility | Decide which finding events should create external copies or shared escalation signals | Configuration surface | Which finding workflow events should notify beyond the directly responsible operator? | Event type, minimum severity, tenant scope, destinations, cooldown, quiet hours | Delivery failures, suppressed history, low-level event keys | alert-event type, severity threshold, tenant scope | Workspace configuration only | Create rule, Edit rule, Enable or disable rule | Delete rule |
|
|
||||||
| Alert deliveries viewer gains finding-event labels and delivery visibility | Workspace operator reviewing alert behavior | Verify whether external finding-event copies were delivered as configured | Read-only history surface | Did the configured finding notification copy actually go out? | Event label, tenant, severity, timestamp, delivery status | Safe failure reasons and delivery metadata | delivery status, event type, severity | none | View delivery | none |
|
|
||||||
| Direct finding notifications and deep links | Tenant operator or tenant manager | Decide whether to open a specific finding now because work was assigned, reopened, is due soon, or is overdue | Notification entry point | Why am I being notified, and what do I need to look at? | Tenant, finding summary, severity, event label, recipient reason, deep link | Full evidence, audit trail, lifecycle history, and related context after opening the finding | assignment change, reopen truth, due-state aging | none on the notification itself | Open finding | 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 recipient-resolution contract over existing finding owner and assignee truth plus existing alert destinations
|
|
||||||
- **New enum/state/reason family?**: yes — four finding notification event types and one bounded recipient-reason vocabulary
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: no
|
|
||||||
- **Current operator problem**: Findings already move through assignment, reopen, and due-state transitions, but the responsible operator still has to rediscover those changes by polling queues and tenant pages.
|
|
||||||
- **Existing structure is insufficient because**: Existing findings queues only show current backlog once an operator opens them. Existing Alerts rules handle shared external delivery, but they do not by themselves define who the directly responsible operator should be for assignment, reopen, and aging signals.
|
|
||||||
- **Narrowest correct implementation**: Reuse the current finding owner and assignee contract, add four event types, resolve one direct recipient per event when possible, and let existing Alerts rules optionally produce external copies.
|
|
||||||
- **Ownership cost**: Ongoing maintenance for one bounded recipient-resolution rule set, event labels, and regression coverage for entitlement-safe delivery and dedupe.
|
|
||||||
- **Alternative intentionally rejected**: A full notification-preference center or a generic workflow-notification engine was rejected because it imports durable product complexity before the smaller control-loop problem is proven.
|
|
||||||
- **Release truth**: Current-release truth. This spec turns existing findings workflow changes into actionable operator feedback now rather than preparing for a distant automation layer.
|
|
||||||
|
|
||||||
### 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 changes visible workflow consequences for findings and workspace alert configuration, but it does not add a new browser-only interaction model or a heavy-governance computation lane. Focused feature coverage proves event production, direct recipient routing, external rule integration, deep-link safety, and no-leak authorization behavior.
|
|
||||||
- **New or expanded test families**: Add focused finding-notification event tests, direct-recipient routing tests, due-cycle dedupe tests, alert-rule event-type option tests, and deep-link authorization tests.
|
|
||||||
- **Fixture / helper cost impact**: Moderate. Tests need findings with explicit owner and assignee combinations, due dates across notification thresholds, workspace alert rules and destinations, and hidden versus visible 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 notification deep links honor current tenant entitlement and that hidden tenants do not leak through notification text, event routing, or delivery history.
|
|
||||||
- **Reviewer handoff**: Reviewers must confirm that each event type has a deterministic trigger, that direct recipient precedence matches the spec, that owner-only changes do not emit assignment notifications, and that external rule-based copies remain optional rather than becoming the only notification path.
|
|
||||||
- **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/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php`
|
|
||||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Receive direct notification when work changes hands or reopens (Priority: P1)
|
|
||||||
|
|
||||||
As a responsible operator, I want assignment and automatic reopen events to notify me directly, so findings do not silently appear in my workload.
|
|
||||||
|
|
||||||
**Why this priority**: This is the smallest workflow-closing slice. If assignment and automatic reopen remain silent, ownership semantics and personal queues still rely on manual polling.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by assigning findings to a user, triggering an automatic reopen, and verifying that the correct recipient receives one tenant-safe notification with a deep link.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an open finding is assigned to a new assignee, **When** the assignment is saved, **Then** the new assignee receives one direct notification explaining that the finding was assigned to them.
|
|
||||||
2. **Given** a previously resolved finding is automatically reopened by system detection and still has an assignee, **When** the reopen is recorded, **Then** the assignee receives one direct reopen notification with a deep link to the finding.
|
|
||||||
3. **Given** only the accountable owner changes while the assignee remains unchanged, **When** the owner update is saved, **Then** no assignment notification is emitted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Get due-soon reminders and overdue escalation without spam (Priority: P1)
|
|
||||||
|
|
||||||
As an operator responsible for a finding, I want the system to remind me before a due date and escalate overdue items predictably, so aging work does not disappear until the next manual review.
|
|
||||||
|
|
||||||
**Why this priority**: Due dates without reminder and escalation behavior are not operational controls. This story turns due metadata into an actionable workflow signal.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by placing findings into the due-soon window and overdue state across different owner and assignee combinations, then verifying the correct recipient, one-time due-cycle behavior, and no duplicate notifications when the same person holds both roles.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an open finding enters the due-soon reminder window and has a current assignee, **When** evaluation runs, **Then** the assignee receives one due-soon reminder for that due cycle.
|
|
||||||
2. **Given** an open finding becomes overdue and has an owner distinct from the assignee, **When** evaluation runs, **Then** the owner receives the overdue escalation and the system does not send a second duplicate direct notification if owner and assignee are the same person.
|
|
||||||
3. **Given** a finding has already emitted a due-soon or overdue notification for the current due cycle, **When** later evaluations run without a due-date reset or reopen, **Then** no duplicate direct notification is emitted for that same cycle.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Configure external copies through existing Alerts management (Priority: P2)
|
|
||||||
|
|
||||||
As a workspace operator, I want the existing Alerts rules to support finding assignment, reopen, due-soon, and overdue event types, so shared team destinations can receive the same workflow signals without a second notification product.
|
|
||||||
|
|
||||||
**Why this priority**: The direct user notification closes the personal loop, but enterprise teams still need optional shared or external copies through the current alerting foundation.
|
|
||||||
|
|
||||||
**Independent Test**: Can be fully tested by adding the new event types to an alert rule, triggering one matching event, and verifying that an external delivery is created while the direct personal notification behavior remains available.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a workspace operator edits an alert rule, **When** they open the event-type selector, **Then** the four new findings notification event types are available.
|
|
||||||
2. **Given** a matching alert rule exists for an overdue findings event, **When** an open finding becomes overdue, **Then** the existing alert delivery pipeline creates one external copy per matching enabled destination.
|
|
||||||
3. **Given** no matching alert rule exists for an assignment event, **When** a finding is assigned to an entitled operator, **Then** the direct operator notification still occurs while no external delivery is created.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- The same entitled user may be both owner and assignee; the system must send one direct notification, not two copies with different labels.
|
|
||||||
- A finding may lose its assignee or owner entitlement after the workflow event was created but before delivery; direct delivery must re-check current entitlement and suppress the personal notification instead of leaking scope.
|
|
||||||
- A finding may become terminal after entering the due-soon window but before evaluation sends reminders; due-soon and overdue notifications must suppress for terminal findings.
|
|
||||||
- Rapid reassignment or repeated automatic reopen within the same notification fingerprint window must not fan out duplicate direct notifications.
|
|
||||||
- Existing tenant-level `sla_due` summary alerts remain valid and separate; this feature must not redefine or remove that aggregate overdue signal.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** This feature adds no Microsoft Graph calls and no new `OperationRun` type. It reuses the existing alert-evaluation and alert-delivery operations plus existing in-app notification primitives. Scheduled evaluation remains system-run, so no new initiator-only terminal DB notification rule is introduced. Tenant isolation, delivery history, and test coverage remain mandatory because the feature emits new finding event types into existing background alert flows.
|
|
||||||
|
|
||||||
**Constitution alignment (TEST-GOV-001):** The proof burden is focused feature coverage for finding event production, recipient resolution, direct notification deep-link safety, alert-rule UI options, external delivery integration, and hidden-tenant suppression. No browser or heavy-governance lane is required.
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** The feature spans workspace-context alert configuration and tenant-scoped finding follow-up. Non-members, wrong-workspace users, and hidden-tenant requests remain `404`. Workspace members without `ALERTS_VIEW` or `ALERTS_MANAGE` remain `403` on protected alert pages. Notification payloads and deep links must not reveal finding or tenant details to operators who are no longer entitled to inspect the finding at open time.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001):** Existing Filament `AlertRuleResource` and alert delivery viewer remain the only configuration and delivery-history surfaces. Existing notification primitives and shared link helpers must be reused for direct finding notifications. No findings-specific notification center, badge system, or page-local alert markup may be introduced.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** The target object is always the `finding`. Primary operator verbs are `assigned`, `reopened`, `due soon`, `overdue`, and `Open finding`. The same finding vocabulary must hold across alert-rule labels, notification titles, delivery history, and deep-link copy. Implementation-first terms such as fingerprint, recipient resolver, or evaluation window remain secondary.
|
|
||||||
|
|
||||||
**Constitution alignment (DECIDE-001):** Direct notifications are entry points, not replacement work surfaces. They must make the first decision obvious in one glance and then defer to the existing finding detail as the durable decision context.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / HDR-001):** No new list or detail page is introduced. Existing alert configuration surfaces stay config-first. Direct notifications expose one inspect model only: the finding. They must not compete with page-local mutation affordances or invent a second queue model.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** This feature derives notification truth directly from existing finding lifecycle, ownership, due-date, and tenant-entitlement data. It must not add a second persisted workflow state, a local notification-only status, or a duplicate owner or assignee meaning.
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: The system MUST support four new finding notification event types for the existing alert dispatch pipeline: `findings.assigned`, `findings.reopened`, `findings.due_soon`, and `findings.overdue`.
|
|
||||||
- **FR-002**: `findings.assigned` MUST be produced when an open finding is assigned to a new assignee. Owner-only changes, assignee clears, and no-op saves MUST NOT emit this event.
|
|
||||||
- **FR-003**: `findings.reopened` MUST be produced only for system-driven reopen of an existing finding caused by recurring detection. Manual reopen remains out of scope for v1 notifications.
|
|
||||||
- **FR-004**: `findings.due_soon` MUST be produced once per due cycle when an open finding first enters the due-soon reminder window. The default v1 reminder window is 24 hours before `due_at`.
|
|
||||||
- **FR-005**: `findings.overdue` MUST be produced once per due cycle when an open finding first becomes overdue.
|
|
||||||
- **FR-006**: A finding due cycle resets only when the finding’s `due_at` is recalculated by the existing lifecycle contract, such as after a system reopen that resets due dates. `reopened_at` may explain why the cycle changed, but only a new `due_at` value may produce a new due-soon or overdue event for the same finding.
|
|
||||||
- **FR-007**: The system MUST resolve one direct personal recipient for each event when possible using this precedence:
|
|
||||||
- `findings.assigned` → new assignee
|
|
||||||
- `findings.reopened` → current assignee, else current owner
|
|
||||||
- `findings.due_soon` → current assignee, else current owner
|
|
||||||
- `findings.overdue` → current owner, else current assignee
|
|
||||||
- **FR-008**: Direct personal delivery MUST only occur when the resolved recipient is still currently entitled to inspect the target tenant and finding at send time. If no currently entitled direct recipient exists, the system MUST suppress direct personal delivery rather than broaden disclosure.
|
|
||||||
- **FR-009**: When the resolved owner and assignee are the same currently entitled user, the system MUST send only one direct personal notification for that event.
|
|
||||||
- **FR-010**: Each finding event MUST carry a deterministic fingerprint suitable for dedupe and cooldown. Assignment fingerprints must distinguish the target assignee change, automatic reopen fingerprints must distinguish the reopen occurrence, and due-soon or overdue fingerprints must roll when the due cycle resets.
|
|
||||||
- **FR-011**: Direct personal notifications MUST be available without requiring a matching workspace alert rule. Workspace alert rules remain optional copies for external or shared-team delivery.
|
|
||||||
- **FR-012**: Existing Alerts rules MUST support the four new finding notification event types in the event-type selector. No new alert pages, no new destination type family, and no findings-specific preference center may be introduced in v1.
|
|
||||||
- **FR-013**: Existing Alerts v1 behavior for minimum severity, tenant scoping, cooldown, dedupe, quiet hours, and destination fan-out MUST apply unchanged to external rule-based copies of the new finding events.
|
|
||||||
- **FR-014**: Direct personal notification payloads MUST include the finding summary, tenant context, severity, event label, why the current operator received it, and one deep link to open the finding.
|
|
||||||
- **FR-015**: Notification titles, body copy, delivery history labels, and alert-rule event labels MUST stay domain-first and must not expose raw internal event keys as the primary operator language.
|
|
||||||
- **FR-016**: Direct and external notifications MUST deep-link to the existing tenant finding detail route for the target finding. Opening that link MUST continue to honor current `404` versus `403` entitlement semantics at the time of use.
|
|
||||||
- **FR-017**: Existing tenant-level `sla_due` summary alert behavior from Spec 111 MUST remain in place. The new due-soon and overdue events are finding-level actionable signals and MUST NOT replace or silently redefine the aggregate `sla_due` event.
|
|
||||||
- **FR-018**: Alert delivery history MUST show external finding-event deliveries in the existing workspace alert deliveries viewer with the new event labels. The feature MUST NOT introduce a second workspace-wide history page for direct in-app personal notifications.
|
|
||||||
- **FR-019**: The feature MUST NOT introduce a new findings lifecycle state, a second assignment model, a notification-only queue, or a repeated daily overdue escalation loop by default.
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Alert Rules Resource | Workspace Monitoring → Alerts → Alert rules | Existing create action only | Clickable row to existing edit surface | Existing Edit and `More` actions only | none | Existing create CTA only | n/a | Save / Cancel | Yes for existing rule mutations | Surface is extended only with four event types and routing helper copy. Action Surface Contract remains satisfied by the existing resource pattern. |
|
|
||||||
| Alert Deliveries viewer | Workspace Monitoring → Alerts → Alert deliveries | none | Existing inspect behavior only | Existing read-only inspect action only | none | none | n/a | n/a | No new mutation audit because the surface stays read-only | Existing viewer only gains finding-event labels and safe recipient-facing delivery summaries. |
|
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
|
||||||
|
|
||||||
- **Finding notification event**: A derived workflow event produced from an existing finding transition or aging threshold and routed through the existing alerting pipeline.
|
|
||||||
- **Direct finding notification**: A personal in-app notification sent to the single currently responsible operator resolved from existing finding owner and assignee truth.
|
|
||||||
- **Alert rule (extended)**: The existing workspace alert rule model, extended with the four new finding notification event types.
|
|
||||||
- **Recipient-resolution contract**: The bounded precedence rule that selects the directly responsible operator for assignment, automatic reopen, due-soon, and overdue events without creating a second ownership model.
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: In acceptance review, an operator can understand why they received an assignment, reopen, due-soon, or overdue notification and open the correct finding in one interaction.
|
|
||||||
- **SC-002**: 100% of covered automated tests route each of the four event types to the correct direct recipient or correctly suppress direct delivery when no currently entitled recipient exists.
|
|
||||||
- **SC-003**: 100% of covered due-cycle tests emit at most one direct due-soon reminder and one direct overdue escalation per finding due cycle unless the due cycle resets.
|
|
||||||
- **SC-004**: 100% of covered alert-rule tests show that the four new event types are available in the existing Alerts UI and create external deliveries only when matching rules exist.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- The existing notification-routing foundation can deliver direct in-app notifications to currently entitled operators without introducing a second notification product.
|
|
||||||
- The due-soon horizon is fixed at 24 hours before `due_at` in v1.
|
|
||||||
- Existing finding detail remains the canonical follow-up surface reached from the notification deep link.
|
|
||||||
- Membership and assignment hygiene after a person loses tenant access remains a separate hardening slice and is not solved fully here.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
- Introduce multi-stage escalation chains or automatic reassign logic.
|
|
||||||
- Build a findings-specific notification-preference center or destination management UX.
|
|
||||||
- Replace the existing aggregate `sla_due` alert semantics with item-level reminders.
|
|
||||||
- Add comments, chat, or external ticket handoff.
|
|
||||||
- Create a second findings queue or a notification-only workflow state.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- Spec 099, Alerts v1, remains the source of truth for workspace alert rules, destinations, cooldown, quiet hours, and delivery history.
|
|
||||||
- Spec 100, Alert target test actions, remains the source of truth for alert destination testability and delivery-viewer conventions.
|
|
||||||
- Spec 111, Findings Workflow + SLA, remains the source of truth for finding lifecycle, due dates, reopen behavior, and aggregate `sla_due` alerts.
|
|
||||||
- Spec 219, Finding Ownership Semantics Clarification, remains the source of truth for owner-versus-assignee meaning.
|
|
||||||
- Spec 221, Findings Operator Inbox V1, and Spec 222, Findings Intake & Team Queue V1, remain the established work surfaces that benefit from these notifications even though this spec does not create new queue pages.
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
# Tasks: Findings Notifications & Escalation v1
|
|
||||||
|
|
||||||
**Input**: Design documents from `/specs/224-findings-notifications-escalation/`
|
|
||||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/findings-notifications-escalation.logical.openapi.yaml`, `quickstart.md`
|
|
||||||
|
|
||||||
**Tests**: Required. This feature changes runtime behavior in finding workflow mutations, scheduled alert evaluation, Laravel database notifications, and existing Filament alert-management surfaces, so Pest coverage must be added in `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`, `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php`, `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php`, and `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`.
|
|
||||||
**Operations**: No new `OperationRun` is introduced. Scheduled due-soon and overdue evaluation must remain on the existing `alerts.evaluate` cadence via `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php` and the existing `tenantpilot:alerts:dispatch` command.
|
|
||||||
**RBAC**: Workspace alert configuration stays on the admin `/admin` plane and finding follow-up stays on the tenant `/admin/t/{tenant}/findings/{finding}` plane. The implementation must preserve `ALERTS_VIEW`-gated read access to alert rules and alert deliveries, `ALERTS_MANAGE`-gated alert-rule mutation, non-member and hidden-scope `404`, in-scope missing-capability `403`, and send-time entitlement rechecks before direct personal delivery.
|
|
||||||
**UI / Surface Guardrails**: `Alert rules` and `Alert deliveries` stay `standard-native-filament` surfaces. The database notification drawer plus tenant finding detail link is a `global-context-shell` seam and must keep one calm `Open finding` drill-in path only.
|
|
||||||
**Filament UI Action Surfaces**: `AlertRuleResource` and `AlertDeliveryResource` remain existing resource patterns with no new page family, no new destructive action, and no second notification center. Direct finding notifications expose one inspect model only: the finding.
|
|
||||||
**Badges**: Existing finding severity, lifecycle, and alert-delivery status semantics remain authoritative. No page-local badge taxonomy or ad-hoc 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 direct responsible-user delivery closes the smallest workflow gap first, due-cycle reminders build on the same delivery seam second, and external-copy management is safest after direct-event 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 (Notification Scaffolding)
|
|
||||||
|
|
||||||
**Purpose**: Prepare the shared delivery seam and focused regression suites used across all stories.
|
|
||||||
|
|
||||||
- [X] T001 [P] Create the finding notification service scaffold in `apps/platform/app/Services/Findings/FindingNotificationService.php`
|
|
||||||
- [X] T002 [P] Create the finding event database-notification scaffold in `apps/platform/app/Notifications/Findings/FindingEventNotification.php`
|
|
||||||
- [X] T003 [P] Create focused Pest scaffolding in `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`, `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php`, and `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
|
||||||
|
|
||||||
**Checkpoint**: The shared service, notification class, and focused test files exist and are ready for implementation work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Event Vocabulary And Delivery Seams)
|
|
||||||
|
|
||||||
**Purpose**: Establish the canonical finding event keys, direct-delivery baseline, and shared dispatch contract every story depends on.
|
|
||||||
|
|
||||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
|
||||||
|
|
||||||
- [X] T004 Add the four canonical finding event constants in `apps/platform/app/Models/AlertRule.php`
|
|
||||||
- [X] T005 Implement the shared finding-event envelope builder, recipient-resolution precedence, send-time entitlement recheck, fingerprint helpers, and direct-delivery dedupe baseline in `apps/platform/app/Services/Findings/FindingNotificationService.php`
|
|
||||||
- [X] T006 Implement the base Filament database notification payload, recipient-reason copy, and tenant finding deep link in `apps/platform/app/Notifications/Findings/FindingEventNotification.php`
|
|
||||||
- [X] T007 Add foundational database-notification payload-shape and tenant-safe `404` versus `403` link coverage in `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
|
||||||
- [X] T008 Wire the shared optional external-copy dispatch baseline from `apps/platform/app/Services/Findings/FindingNotificationService.php` into `apps/platform/app/Services/Alerts/AlertDispatchService.php`
|
|
||||||
|
|
||||||
**Checkpoint**: Shared event vocabulary, recipient resolution, direct personal delivery, and external-copy handoff are available for all stories.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - Receive direct notification when work changes hands or reopens (Priority: P1) 🎯 MVP
|
|
||||||
|
|
||||||
**Goal**: Notify the directly responsible operator when an open finding is assigned to them or a previously terminal finding is reopened by system detection.
|
|
||||||
|
|
||||||
**Independent Test**: Assign an open finding to a new assignee and trigger a system reopen on a terminal finding, then verify one entitled recipient receives one tenant-safe notification with a deep link while owner-only changes remain silent.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [X] T009 [P] [US1] Add assignment and system-reopen event production plus rapid reassignment and repeated automatic-reopen dedupe coverage in `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`
|
|
||||||
- [X] T010 [P] [US1] Add recipient precedence, owner-only suppression, assignee-clear suppression, and entitlement-loss coverage for assignment and reopen flows in `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [X] T011 [US1] Emit `findings.assigned` only after committed assignee changes and suppress owner-only, assignee-clear, and no-op saves in `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
|
||||||
- [X] T012 [US1] Emit `findings.reopened` only from the system-driven reopen path in `apps/platform/app/Services/Findings/FindingWorkflowService.php`
|
|
||||||
- [X] T013 [US1] Align assignment and reopen notification titles, bodies, and recipient-reason vocabulary in `apps/platform/app/Services/Findings/FindingNotificationService.php` and `apps/platform/app/Notifications/Findings/FindingEventNotification.php`
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 1 is independently functional and the responsible operator no longer needs to poll findings pages to notice assignment or automatic reopen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Get due-soon reminders and overdue escalation without spam (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: Turn finding due dates into one-per-cycle due-soon reminders and overdue escalations without duplicate personal notification noise.
|
|
||||||
|
|
||||||
**Independent Test**: Seed open findings across due-soon and overdue thresholds with different owner and assignee combinations, run alert evaluation, and verify correct recipient precedence, terminal suppression, same-user dedupe, and one notification per due cycle.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [X] T014 [P] [US2] Add due-soon and overdue window, one-per-cycle, and `due_at`-driven due-cycle-reset coverage in `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`
|
|
||||||
- [X] T015 [P] [US2] Add due recipient precedence, same-user dedupe, terminal-finding suppression, and no-entitled-recipient suppression coverage in `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [X] T016 [US2] Add workspace-scoped due-soon and overdue candidate collection with a 24-hour reminder window and non-open-finding suppression in `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`
|
|
||||||
- [X] T017 [US2] Route due-soon and overdue candidates through the shared delivery seam and derive per-cycle fingerprints from the recalculated `due_at` value in `apps/platform/app/Services/Findings/FindingNotificationService.php`
|
|
||||||
- [X] T018 [US2] Add due-window and owner-versus-assignee factory states needed for due-cycle reminder coverage in `apps/platform/database/factories/FindingFactory.php`
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 2 is independently functional and due dates now produce predictable, non-spammy direct reminders and overdue escalation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Configure external copies through existing Alerts management (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Let workspace operators configure and inspect optional external copies for the same finding events through the existing Alerts management surfaces.
|
|
||||||
|
|
||||||
**Independent Test**: Add the new finding event types to an alert rule, trigger a matching event through the shared delivery seam, and verify external deliveries appear in the existing viewer while direct personal delivery still works without a matching rule.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [X] T019 [US3] Add alert-rule event option exposure, alert-rule and alert-delivery non-member or hidden-scope `404` versus in-scope capability `403` coverage, `ALERTS_VIEW` read versus `ALERTS_MANAGE` mutation boundaries, inherited external-copy behavior covering minimum severity, tenant scoping, cooldown, quiet hours, dedupe, delivery-history label and filter coverage, and direct-without-rule behavior in `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php`; extend `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php` to prove aggregate `sla_due` behavior remains unchanged
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [X] T020 [P] [US3] Expose the four finding notification event types in the existing selector and label mapping in `apps/platform/app/Filament/Resources/AlertRuleResource.php`
|
|
||||||
- [X] T021 [P] [US3] Render finding-event labels and filter options in the existing viewer in `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`
|
|
||||||
- [X] T022 [US3] Finalize finding-event payload normalization and rule-driven external-copy fan-out behavior in `apps/platform/app/Services/Alerts/AlertDispatchService.php` and `apps/platform/app/Services/Findings/FindingNotificationService.php`
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 3 is independently functional and workspace teams can reuse existing Alerts rules and delivery history for finding assignment, reopen, due-soon, and overdue copies.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Finish guardrail alignment, formatting, and focused verification across the full feature.
|
|
||||||
|
|
||||||
- [X] T023 Review operator-facing finding vocabulary, recipient-reason copy, and guardrail alignment in `apps/platform/app/Services/Findings/FindingNotificationService.php`, `apps/platform/app/Notifications/Findings/FindingEventNotification.php`, `apps/platform/app/Filament/Resources/AlertRuleResource.php`, and `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`
|
|
||||||
- [X] T024 Run formatting for `apps/platform/app/Services/Findings/FindingNotificationService.php`, `apps/platform/app/Notifications/Findings/FindingEventNotification.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`, `apps/platform/app/Services/Alerts/AlertDispatchService.php`, `apps/platform/app/Models/AlertRule.php`, `apps/platform/app/Filament/Resources/AlertRuleResource.php`, `apps/platform/app/Filament/Resources/AlertDeliveryResource.php`, `apps/platform/database/factories/FindingFactory.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`, `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php`, and `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php` with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
|
||||||
- [X] T025 Run the focused verification workflow from `specs/224-findings-notifications-escalation/quickstart.md` against `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php`, `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php`, `apps/platform/tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php`, `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php`, and `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Phase Dependencies
|
|
||||||
|
|
||||||
- **Setup (Phase 1)**: Starts immediately and prepares the shared service, notification class, and focused Pest files.
|
|
||||||
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories until shared event vocabulary, recipient resolution, and dispatch seams 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 direct-delivery seam with scheduled due evaluation.
|
|
||||||
- **User Story 3 (Phase 5)**: Depends on Foundational completion and extends the existing Alerts management surfaces with the same event vocabulary.
|
|
||||||
- **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, but it reuses the same delivery seam and should preserve direct-delivery vocabulary established there.
|
|
||||||
- **US3**: No hard dependency on US1 or US2 after Foundational, but it must stay aligned with the shared event vocabulary and direct-delivery contract they use.
|
|
||||||
|
|
||||||
### Within Each User Story
|
|
||||||
|
|
||||||
- Write the story tests first and confirm they fail before implementation is considered complete.
|
|
||||||
- Keep shared delivery behavior authoritative in `FindingNotificationService.php` before duplicating logic in `FindingWorkflowService.php` or `EvaluateAlertsJob.php`.
|
|
||||||
- Finish story-level verification before moving to the next priority slice.
|
|
||||||
|
|
||||||
### Parallel Opportunities
|
|
||||||
|
|
||||||
- `T001`, `T002`, and `T003` can run in parallel during Setup.
|
|
||||||
- `T009` and `T010` can run in parallel for User Story 1.
|
|
||||||
- `T014` and `T015` can run in parallel for User Story 2.
|
|
||||||
- `T020` and `T021` can run in parallel for User Story 3.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Example: User Story 1
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User Story 1 tests in parallel
|
|
||||||
T009 apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php
|
|
||||||
T010 apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parallel Example: User Story 2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User Story 2 tests in parallel
|
|
||||||
T014 apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php
|
|
||||||
T015 apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
## Parallel Example: User Story 3
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# User Story 3 surface work in parallel
|
|
||||||
T020 apps/platform/app/Filament/Resources/AlertRuleResource.php
|
|
||||||
T021 apps/platform/app/Filament/Resources/AlertDeliveryResource.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 direct assignment and automatic-reopen feedback gap.
|
|
||||||
2. Add US2 to turn due dates into actionable reminders and overdue escalation.
|
|
||||||
3. Add US3 to let workspace teams configure optional external copies through the existing Alerts UI.
|
|
||||||
4. Finish with copy review, formatting, and the focused verification pack.
|
|
||||||
|
|
||||||
### Parallel Team Strategy
|
|
||||||
|
|
||||||
1. One contributor can scaffold the shared service and notification class while another prepares the focused Pest suites.
|
|
||||||
2. After Foundational work lands, one contributor can wire workflow-event production while another implements due-evaluation logic.
|
|
||||||
3. Alert-rule and delivery-viewer surface work can proceed in parallel with direct-delivery trigger work once the shared event vocabulary is 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.
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,276 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,271 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,244 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,216 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
# Specification Quality Checklist: AstroDeck Inventory Planning for Website Rebuild
|
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
- Validation passed in first iteration; no unresolved issues.
|
|
||||||
- Ready for `/speckit.plan`.
|
|
||||||
@ -1,287 +0,0 @@
|
|||||||
openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: AstroDeck Inventory Logical Contract
|
|
||||||
version: 0.1.0
|
|
||||||
description: >-
|
|
||||||
Logical planning contract for publishing and querying AstroDeck primitive inventory
|
|
||||||
used by website rebuild mapping specs. This contract defines artifact shapes only
|
|
||||||
for planning governance and does not imply runtime implementation in this feature.
|
|
||||||
servers:
|
|
||||||
- url: https://planning.local
|
|
||||||
paths:
|
|
||||||
/inventory/catalogs:
|
|
||||||
post:
|
|
||||||
summary: Publish a new inventory catalog snapshot
|
|
||||||
operationId: publishInventoryCatalog
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/InventoryCatalogCreateRequest'
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Catalog created
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/InventoryCatalog'
|
|
||||||
/inventory/catalogs/{catalogId}:
|
|
||||||
get:
|
|
||||||
summary: Retrieve one inventory catalog with entries
|
|
||||||
operationId: getInventoryCatalog
|
|
||||||
parameters:
|
|
||||||
- name: catalogId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Catalog found
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/InventoryCatalog'
|
|
||||||
/inventory/catalogs/{catalogId}/summary:
|
|
||||||
get:
|
|
||||||
summary: Retrieve suitability and risk summary
|
|
||||||
operationId: getInventorySuitabilitySummary
|
|
||||||
parameters:
|
|
||||||
- name: catalogId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Summary found
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SuitabilitySummary'
|
|
||||||
/inventory/catalogs/{catalogId}/entries:
|
|
||||||
get:
|
|
||||||
summary: List inventory entries with optional filters
|
|
||||||
operationId: listInventoryEntries
|
|
||||||
parameters:
|
|
||||||
- name: catalogId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: primitiveClass
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/PrimitiveClass'
|
|
||||||
- name: suitabilityClass
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SuitabilityClass'
|
|
||||||
- name: marker
|
|
||||||
in: query
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Marker'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Entries returned
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
data:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/InventoryEntry'
|
|
||||||
required: [data]
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
PrimitiveClass:
|
|
||||||
type: string
|
|
||||||
enum: [Page, Section, Component]
|
|
||||||
SuitabilityClass:
|
|
||||||
type: string
|
|
||||||
enum: [A, B, C, D]
|
|
||||||
TenantAtlasRelevance:
|
|
||||||
type: string
|
|
||||||
enum: [high, medium, low, none]
|
|
||||||
Marker:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- tenantatlas-likely
|
|
||||||
- needs-heavy-adaptation
|
|
||||||
- visual-risk
|
|
||||||
- semantic-risk
|
|
||||||
- demo-only
|
|
||||||
- remove-likely
|
|
||||||
- hero-candidate
|
|
||||||
- trust-candidate
|
|
||||||
- navigation-candidate
|
|
||||||
- footer-candidate
|
|
||||||
- contact-candidate
|
|
||||||
- product-explainer-candidate
|
|
||||||
- changelog-candidate
|
|
||||||
InventoryEntry:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- entryId
|
|
||||||
- identifier
|
|
||||||
- primitiveClass
|
|
||||||
- fileRef
|
|
||||||
- functionalRole
|
|
||||||
- defaultSemantics
|
|
||||||
- defaultVisualCharacter
|
|
||||||
- tenantatlasRelevance
|
|
||||||
- suitabilityClass
|
|
||||||
properties:
|
|
||||||
entryId:
|
|
||||||
type: string
|
|
||||||
identifier:
|
|
||||||
type: string
|
|
||||||
primitiveClass:
|
|
||||||
$ref: '#/components/schemas/PrimitiveClass'
|
|
||||||
fileRef:
|
|
||||||
type: string
|
|
||||||
functionalRole:
|
|
||||||
type: string
|
|
||||||
defaultSemantics:
|
|
||||||
type: string
|
|
||||||
defaultVisualCharacter:
|
|
||||||
type: string
|
|
||||||
tenantatlasRelevance:
|
|
||||||
$ref: '#/components/schemas/TenantAtlasRelevance'
|
|
||||||
suitabilityClass:
|
|
||||||
$ref: '#/components/schemas/SuitabilityClass'
|
|
||||||
markers:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/Marker'
|
|
||||||
notes:
|
|
||||||
type: string
|
|
||||||
SuitabilitySummary:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- countA
|
|
||||||
- countB
|
|
||||||
- countC
|
|
||||||
- countD
|
|
||||||
- riskVisualCount
|
|
||||||
- riskSemanticCount
|
|
||||||
- demoOnlyCount
|
|
||||||
- surfaceCandidates
|
|
||||||
properties:
|
|
||||||
countA:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
countB:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
countC:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
countD:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
riskVisualCount:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
riskSemanticCount:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
demoOnlyCount:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
surfaceCandidates:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- homepage
|
|
||||||
- hero
|
|
||||||
- product
|
|
||||||
- trust
|
|
||||||
- changelog
|
|
||||||
- contactDemo
|
|
||||||
- navigation
|
|
||||||
- footer
|
|
||||||
properties:
|
|
||||||
homepage:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
hero:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
product:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
trust:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
changelog:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
contactDemo:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
navigation:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
footer:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
InventoryCatalog:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- catalogId
|
|
||||||
- scope
|
|
||||||
- createdAt
|
|
||||||
- sourceCommit
|
|
||||||
- status
|
|
||||||
- entries
|
|
||||||
- summary
|
|
||||||
properties:
|
|
||||||
catalogId:
|
|
||||||
type: string
|
|
||||||
scope:
|
|
||||||
type: string
|
|
||||||
const: apps/website
|
|
||||||
createdAt:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
sourceCommit:
|
|
||||||
type: string
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
enum: [draft, reviewed, baselined]
|
|
||||||
entries:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/InventoryEntry'
|
|
||||||
summary:
|
|
||||||
$ref: '#/components/schemas/SuitabilitySummary'
|
|
||||||
InventoryCatalogCreateRequest:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- catalogId
|
|
||||||
- sourceCommit
|
|
||||||
- entries
|
|
||||||
properties:
|
|
||||||
catalogId:
|
|
||||||
type: string
|
|
||||||
sourceCommit:
|
|
||||||
type: string
|
|
||||||
entries:
|
|
||||||
type: array
|
|
||||||
minItems: 1
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/InventoryEntry'
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
# Data Model: AstroDeck Inventory Planning (Website)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This feature introduces no database schema. The model defines planning artifacts that describe AstroDeck primitive availability in `apps/website`.
|
|
||||||
|
|
||||||
## Entities
|
|
||||||
|
|
||||||
### InventoryCatalog
|
|
||||||
|
|
||||||
- **Purpose**: Canonical inventory container for one website planning baseline.
|
|
||||||
- **Fields**:
|
|
||||||
- `catalog_id` (string, format: `astrodeck-v<YYYY-MM-DD>`, e.g. `astrodeck-v2026-04-22`; use `astrodeck-226-baseline` for the first Spec 226 catalog)
|
|
||||||
- `scope` (fixed: `apps/website`)
|
|
||||||
- `created_at` (ISO datetime)
|
|
||||||
- `source_commit` (git SHA or reference)
|
|
||||||
- `status` (`draft`, `reviewed`, `baselined`)
|
|
||||||
- **Relationships**:
|
|
||||||
- Has many `InventoryEntry`
|
|
||||||
- Has one `SuitabilitySummary`
|
|
||||||
- **Validation rules**:
|
|
||||||
- Must include entries for all three primitive classes.
|
|
||||||
- Cannot reach `baselined` unless every entry has required attributes and suitability.
|
|
||||||
|
|
||||||
### InventoryEntry
|
|
||||||
|
|
||||||
- **Purpose**: One documented AstroDeck primitive candidate.
|
|
||||||
- **Fields**:
|
|
||||||
- `entry_id` (string, unique within catalog)
|
|
||||||
- `identifier` (human-readable or technical name)
|
|
||||||
- `primitive_class` (`Page`, `Section`, `Component`)
|
|
||||||
- `file_ref` (workspace-relative path)
|
|
||||||
- `functional_role` (string)
|
|
||||||
- `default_semantics` (string)
|
|
||||||
- `default_visual_character` (closed enum: `minimal` | `enterprise-neutral` | `content-heavy` | `bold-visual` | `utility-dense` | `decorative`; add `notes` for anything outside this set)
|
|
||||||
- `tenantatlas_relevance` (`high`, `medium`, `low`, `none`)
|
|
||||||
- `suitability_class` (`A`, `B`, `C`, `D`)
|
|
||||||
- `markers` (string array)
|
|
||||||
- `notes` (optional string)
|
|
||||||
- **Relationships**:
|
|
||||||
- Belongs to `InventoryCatalog`
|
|
||||||
- **Validation rules**:
|
|
||||||
- Required core fields must be non-empty.
|
|
||||||
- `primitive_class` and `suitability_class` values are constrained.
|
|
||||||
- `markers` must come from allowed marker vocabulary.
|
|
||||||
|
|
||||||
### SuitabilitySummary
|
|
||||||
|
|
||||||
- **Purpose**: Aggregated view of candidate distribution and risk visibility.
|
|
||||||
- **Fields**:
|
|
||||||
- `count_a`, `count_b`, `count_c`, `count_d` (integers)
|
|
||||||
- `risk_visual_count` (integer)
|
|
||||||
- `risk_semantic_count` (integer)
|
|
||||||
- `demo_only_count` (integer)
|
|
||||||
- `surface_candidates` (object keyed by surface: homepage, hero, product, trust, changelog, contact-demo, navigation, footer)
|
|
||||||
- **Relationships**:
|
|
||||||
- Belongs to `InventoryCatalog`
|
|
||||||
- **Validation rules**:
|
|
||||||
- Aggregate counts must reconcile with `InventoryEntry` rows.
|
|
||||||
- Every required surface key must be present.
|
|
||||||
|
|
||||||
### MarkerVocabulary
|
|
||||||
|
|
||||||
- **Purpose**: Allowed marker set for consistent tagging.
|
|
||||||
- **Allowed values**:
|
|
||||||
- `tenantatlas-likely`
|
|
||||||
- `needs-heavy-adaptation`
|
|
||||||
- `visual-risk`
|
|
||||||
- `semantic-risk`
|
|
||||||
- `demo-only`
|
|
||||||
- `remove-likely`
|
|
||||||
- `hero-candidate`
|
|
||||||
- `trust-candidate`
|
|
||||||
- `navigation-candidate`
|
|
||||||
- `footer-candidate`
|
|
||||||
- `contact-candidate`
|
|
||||||
- `product-explainer-candidate`
|
|
||||||
- `changelog-candidate`
|
|
||||||
|
|
||||||
## Primitive Class Mapping
|
|
||||||
|
|
||||||
- `Page` — route entrypoints in `apps/website/src/pages/`
|
|
||||||
- `Section` — composite sections in `apps/website/src/components/sections/`
|
|
||||||
- `Component` — all reusable units across three sub-families:
|
|
||||||
- `primitives/` (structural atoms)
|
|
||||||
- `content/` (semantic content blocks)
|
|
||||||
- `layout/` (shell, navigation, footer) — **layout/ maps to `primitive_class: Component`; there is no fourth primitive class**
|
|
||||||
|
|
||||||
## Lifecycle
|
|
||||||
|
|
||||||
### Catalog Status Transitions
|
|
||||||
|
|
||||||
- `draft` -> `reviewed`
|
|
||||||
- Condition: required fields complete and class coverage verified.
|
|
||||||
- `reviewed` -> `baselined`
|
|
||||||
- Condition: suitability summary reconciled and candidate/risk visibility confirmed.
|
|
||||||
- `baselined` -> `draft`
|
|
||||||
- Condition: source primitive set changes and inventory requires refresh.
|
|
||||||
|
|
||||||
## Derived Rules
|
|
||||||
|
|
||||||
- No mapping spec may claim candidate selection without referencing `entry_id` from the active baselined catalog.
|
|
||||||
- Non-candidates (`D`) remain first-class records and cannot be hidden from summary outputs.
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
# AstroDeck Inventory Catalog
|
|
||||||
|
|
||||||
- catalog_id: astrodeck-226-baseline
|
|
||||||
- scope: apps/website
|
|
||||||
- created_at: 2026-04-22T07:52:32Z
|
|
||||||
- source_commit: 71f94c3afa897c00f6a8d35b63f40882ae86e348
|
|
||||||
- status: reviewed
|
|
||||||
- source_file_count: 50
|
|
||||||
- inventory_entry_target: 51
|
|
||||||
|
|
||||||
## Source Discovery
|
|
||||||
|
|
||||||
- discovery_command: `cd apps/website && rg --files src/pages src/components/sections src/components/primitives src/components/content src/components/layout`
|
|
||||||
- discovery_commit: `71f94c3afa897c00f6a8d35b63f40882ae86e348`
|
|
||||||
- discovery_classification_rule: Each discovered file maps to exactly one primitive class from `Page`, `Section`, or `Component`.
|
|
||||||
|
|
||||||
### Pages
|
|
||||||
|
|
||||||
- `apps/website/src/pages/changelog.astro`
|
|
||||||
- `apps/website/src/pages/contact.astro`
|
|
||||||
- `apps/website/src/pages/imprint.astro`
|
|
||||||
- `apps/website/src/pages/index.astro`
|
|
||||||
- `apps/website/src/pages/integrations.astro`
|
|
||||||
- `apps/website/src/pages/legal.astro`
|
|
||||||
- `apps/website/src/pages/privacy.astro`
|
|
||||||
- `apps/website/src/pages/product.astro`
|
|
||||||
- `apps/website/src/pages/security-trust.astro`
|
|
||||||
- `apps/website/src/pages/sitemap.xml.ts`
|
|
||||||
- `apps/website/src/pages/solutions.astro`
|
|
||||||
- `apps/website/src/pages/terms.astro`
|
|
||||||
- `apps/website/src/pages/trust.astro`
|
|
||||||
|
|
||||||
### Sections
|
|
||||||
|
|
||||||
- `apps/website/src/components/sections/CTASection.astro`
|
|
||||||
- `apps/website/src/components/sections/CapabilityGrid.astro`
|
|
||||||
- `apps/website/src/components/sections/FeatureGrid.astro`
|
|
||||||
- `apps/website/src/components/sections/LogoStrip.astro`
|
|
||||||
- `apps/website/src/components/sections/OutcomeSection.astro`
|
|
||||||
- `apps/website/src/components/sections/PageHero.astro`
|
|
||||||
- `apps/website/src/components/sections/ProgressTeaser.astro`
|
|
||||||
- `apps/website/src/components/sections/TrustGrid.astro`
|
|
||||||
|
|
||||||
### Components
|
|
||||||
|
|
||||||
- `apps/website/src/components/primitives/Badge.astro`
|
|
||||||
- `apps/website/src/components/primitives/Button.astro`
|
|
||||||
- `apps/website/src/components/primitives/Card.astro`
|
|
||||||
- `apps/website/src/components/primitives/Cluster.astro`
|
|
||||||
- `apps/website/src/components/primitives/Container.astro`
|
|
||||||
- `apps/website/src/components/primitives/Grid.astro`
|
|
||||||
- `apps/website/src/components/primitives/Input.astro`
|
|
||||||
- `apps/website/src/components/primitives/Section.astro`
|
|
||||||
- `apps/website/src/components/primitives/SectionHeader.astro`
|
|
||||||
- `apps/website/src/components/primitives/Stack.astro`
|
|
||||||
- `apps/website/src/components/primitives/Textarea.astro`
|
|
||||||
- `apps/website/src/components/content/AudienceRow.astro`
|
|
||||||
- `apps/website/src/components/content/Callout.astro`
|
|
||||||
- `apps/website/src/components/content/ContactPanel.astro`
|
|
||||||
- `apps/website/src/components/content/DemoPrompt.astro`
|
|
||||||
- `apps/website/src/components/content/Eyebrow.astro`
|
|
||||||
- `apps/website/src/components/content/FeatureItem.astro`
|
|
||||||
- `apps/website/src/components/content/Headline.astro`
|
|
||||||
- `apps/website/src/components/content/HeroDashboard.astro`
|
|
||||||
- `apps/website/src/components/content/IntegrationBadge.astro`
|
|
||||||
- `apps/website/src/components/content/Lead.astro`
|
|
||||||
- `apps/website/src/components/content/Metric.astro`
|
|
||||||
- `apps/website/src/components/content/PrimaryCTA.astro`
|
|
||||||
- `apps/website/src/components/content/RichText.astro`
|
|
||||||
- `apps/website/src/components/content/SecondaryCTA.astro`
|
|
||||||
- `apps/website/src/components/content/TrustPrincipleCard.astro`
|
|
||||||
- `apps/website/src/components/layout/Footer.astro`
|
|
||||||
- `apps/website/src/components/layout/Navbar.astro`
|
|
||||||
- `apps/website/src/components/layout/PageShell.astro`
|
|
||||||
|
|
||||||
## Related Layout Dependencies Outside T002 Discovery
|
|
||||||
|
|
||||||
- `apps/website/src/layouts/BaseLayout.astro`
|
|
||||||
|
|
||||||
BaseLayout is intentionally inventoried because T005 names it explicitly and `PageShell.astro` cannot render without it, even though the quickstart discovery command is limited to `src/pages` and `src/components/*`.
|
|
||||||
|
|
||||||
## Reviewer Sign-off
|
|
||||||
|
|
||||||
- reviewed_at: 2026-04-22
|
|
||||||
- reviewer: Codex
|
|
||||||
- summary_reconciled: yes
|
|
||||||
- notes: Validated 51 entries across pages, sections, and components; suitability totals and risk counts reconcile with `summary.md`.
|
|
||||||
@ -1,431 +0,0 @@
|
|||||||
# Component Inventory
|
|
||||||
|
|
||||||
- catalog_id: astrodeck-226-baseline
|
|
||||||
- primitive_class: Component
|
|
||||||
- entry_count: 30
|
|
||||||
|
|
||||||
### Primitives
|
|
||||||
|
|
||||||
#### component.button
|
|
||||||
|
|
||||||
- entry_id: component.button
|
|
||||||
- identifier: Button
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/primitives/Button.astro
|
|
||||||
- functional_role: Shared interaction primitive for links and buttons across CTA surfaces.
|
|
||||||
- default_semantics: Reusable primary, secondary, or ghost action control with shared size variants.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Foundational CTA primitive used throughout the site shell and surface sections.
|
|
||||||
|
|
||||||
#### component.card
|
|
||||||
|
|
||||||
- entry_id: component.card
|
|
||||||
- identifier: Card
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/primitives/Card.astro
|
|
||||||
- functional_role: Wraps grouped content inside consistent rounded surface treatments.
|
|
||||||
- default_semantics: Generic surface container with default, accent, and subtle variants plus optional hover behavior.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Central structural primitive for nearly every section family.
|
|
||||||
|
|
||||||
#### component.container
|
|
||||||
|
|
||||||
- entry_id: component.container
|
|
||||||
- identifier: Container
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/primitives/Container.astro
|
|
||||||
- functional_role: Constrains horizontal width and page gutters.
|
|
||||||
- default_semantics: Layout wrapper for content, measure, and wide-width surfaces.
|
|
||||||
- default_visual_character: minimal
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Core layout primitive that stays valid regardless of final surface composition.
|
|
||||||
|
|
||||||
#### component.grid
|
|
||||||
|
|
||||||
- entry_id: component.grid
|
|
||||||
- identifier: Grid
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/primitives/Grid.astro
|
|
||||||
- functional_role: Provides the standard responsive card and content grid behavior.
|
|
||||||
- default_semantics: Responsive grid helper with fixed column presets and shared gap sizing.
|
|
||||||
- default_visual_character: minimal
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Generic layout utility already proven across product, trust, and contact surfaces.
|
|
||||||
|
|
||||||
#### component.stack
|
|
||||||
|
|
||||||
- entry_id: component.stack
|
|
||||||
- identifier: Stack
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/primitives/Stack.astro
|
|
||||||
- functional_role: Offers vertical spacing control for simple stacked layouts.
|
|
||||||
- default_semantics: Flex-column spacing helper with small through extra-large gap presets.
|
|
||||||
- default_visual_character: minimal
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: B
|
|
||||||
- markers: []
|
|
||||||
- notes: Useful as a narrow layout helper, but it is not currently exercised by the shipped route set.
|
|
||||||
|
|
||||||
#### component.section
|
|
||||||
|
|
||||||
- entry_id: component.section
|
|
||||||
- identifier: Section
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/primitives/Section.astro
|
|
||||||
- functional_role: Normalizes vertical rhythm, tone, and disclosure layering for page sections.
|
|
||||||
- default_semantics: Section shell primitive with density, tone, and disclosure-layer options.
|
|
||||||
- default_visual_character: minimal
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Required base primitive for keeping section spacing and surface shells consistent.
|
|
||||||
|
|
||||||
#### component.section-header
|
|
||||||
|
|
||||||
- entry_id: component.section-header
|
|
||||||
- identifier: SectionHeader
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/primitives/SectionHeader.astro
|
|
||||||
- functional_role: Standardizes eyebrow, headline, and supporting copy for section intros.
|
|
||||||
- default_semantics: Content header primitive with alignment and width controls.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Direct fit for any rebuild surface that needs consistent section framing.
|
|
||||||
|
|
||||||
#### component.cluster
|
|
||||||
|
|
||||||
- entry_id: component.cluster
|
|
||||||
- identifier: Cluster
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/primitives/Cluster.astro
|
|
||||||
- functional_role: Groups inline items such as CTA pairs and small metadata sets.
|
|
||||||
- default_semantics: Flexible inline cluster helper with gap and justification presets.
|
|
||||||
- default_visual_character: minimal
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Reusable utility for CTAs, badges, and compact link groups.
|
|
||||||
|
|
||||||
#### component.badge
|
|
||||||
|
|
||||||
- entry_id: component.badge
|
|
||||||
- identifier: Badge
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/primitives/Badge.astro
|
|
||||||
- functional_role: Applies small signal labels for eyebrows, dates, and metadata accents.
|
|
||||||
- default_semantics: Pill-style label primitive with accent, neutral, signal, and warm tones.
|
|
||||||
- default_visual_character: minimal
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Lightweight signaling primitive used in hero, progress, and ecosystem contexts.
|
|
||||||
|
|
||||||
#### component.input
|
|
||||||
|
|
||||||
- entry_id: component.input
|
|
||||||
- identifier: Input
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/primitives/Input.astro
|
|
||||||
- functional_role: Provides a text-input shell for the static contact preview.
|
|
||||||
- default_semantics: Single-line form-control primitive with readonly support but no labels, validation, or submission wiring.
|
|
||||||
- default_visual_character: utility-dense
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: B
|
|
||||||
- markers: []
|
|
||||||
- notes: Structurally reusable, but interactive contact flows would need labels, errors, and submission states layered around it.
|
|
||||||
|
|
||||||
#### component.textarea
|
|
||||||
|
|
||||||
- entry_id: component.textarea
|
|
||||||
- identifier: Textarea
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/primitives/Textarea.astro
|
|
||||||
- functional_role: Provides a multiline text shell for the static contact preview.
|
|
||||||
- default_semantics: Multiline form-control primitive with readonly support but no validation or helper-state system.
|
|
||||||
- default_visual_character: utility-dense
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: B
|
|
||||||
- markers: []
|
|
||||||
- notes: Reusable if the rebuild keeps a preview-only contact pattern; richer interaction would need additional semantics.
|
|
||||||
|
|
||||||
### Content
|
|
||||||
|
|
||||||
#### component.headline
|
|
||||||
|
|
||||||
- entry_id: component.headline
|
|
||||||
- identifier: Headline
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/Headline.astro
|
|
||||||
- functional_role: Provides the shared heading scale across hero, section, and card contexts.
|
|
||||||
- default_semantics: Typography primitive for display, page, section, and card-level headings.
|
|
||||||
- default_visual_character: minimal
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Core type primitive for the current visual system and likely any close rebuild variant.
|
|
||||||
|
|
||||||
#### component.lead
|
|
||||||
|
|
||||||
- entry_id: component.lead
|
|
||||||
- identifier: Lead
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/Lead.astro
|
|
||||||
- functional_role: Renders supporting descriptive copy with a shared size scale.
|
|
||||||
- default_semantics: Body-copy primitive for hero, card, and legal-prose support text.
|
|
||||||
- default_visual_character: minimal
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Generic copy primitive used across every page family.
|
|
||||||
|
|
||||||
#### component.eyebrow
|
|
||||||
|
|
||||||
- entry_id: component.eyebrow
|
|
||||||
- identifier: Eyebrow
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/Eyebrow.astro
|
|
||||||
- functional_role: Introduces sections and cards with compact uppercase framing labels.
|
|
||||||
- default_semantics: Small-type label primitive with accent, neutral, and signal tones.
|
|
||||||
- default_visual_character: minimal
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Shared content primitive that anchors information hierarchy without adding structural complexity.
|
|
||||||
|
|
||||||
#### component.metric
|
|
||||||
|
|
||||||
- entry_id: component.metric
|
|
||||||
- identifier: Metric
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/Metric.astro
|
|
||||||
- functional_role: Packages hero-supporting metrics into small card surfaces.
|
|
||||||
- default_semantics: Metric card with a numeric or label-like headline, eyebrow label, and support copy.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, hero-candidate]
|
|
||||||
- notes: Directly useful for hero-adjacent proof or product framing when the rebuild keeps quantified support signals.
|
|
||||||
|
|
||||||
#### component.primary-cta
|
|
||||||
|
|
||||||
- entry_id: component.primary-cta
|
|
||||||
- identifier: PrimaryCTA
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/PrimaryCTA.astro
|
|
||||||
- functional_role: Wraps the primary button treatment used across hero and closing CTA surfaces.
|
|
||||||
- default_semantics: Primary action wrapper with optional helper copy and size variants.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Stable CTA wrapper used across hero, progress, footer, and closing conversion surfaces.
|
|
||||||
|
|
||||||
#### component.secondary-cta
|
|
||||||
|
|
||||||
- entry_id: component.secondary-cta
|
|
||||||
- identifier: SecondaryCTA
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/SecondaryCTA.astro
|
|
||||||
- functional_role: Provides the supporting secondary action pattern for route handoffs.
|
|
||||||
- default_semantics: Secondary action wrapper with optional helper text and size variants.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Pairs cleanly with the primary CTA pattern and is already part of the approved route handoff model.
|
|
||||||
|
|
||||||
#### component.feature-item
|
|
||||||
|
|
||||||
- entry_id: component.feature-item
|
|
||||||
- identifier: FeatureItem
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/FeatureItem.astro
|
|
||||||
- functional_role: Renders feature or rule cards with optional iconography, metadata, and link treatment.
|
|
||||||
- default_semantics: Card-based feature explanation primitive for product, solutions, and integration rule surfaces.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, product-explainer-candidate]
|
|
||||||
- notes: Strong product-explainer building block for any rebuild surface that needs repeatable explanatory cards.
|
|
||||||
|
|
||||||
#### component.callout
|
|
||||||
|
|
||||||
- entry_id: component.callout
|
|
||||||
- identifier: Callout
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/Callout.astro
|
|
||||||
- functional_role: Shows compact narrative or trust-supporting callouts in card form.
|
|
||||||
- default_semantics: Callout card with optional eyebrow, headline, supporting copy, and tone selection.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, trust-candidate]
|
|
||||||
- notes: Works well for trust or product-support narratives without demanding a new section pattern.
|
|
||||||
|
|
||||||
#### component.rich-text
|
|
||||||
|
|
||||||
- entry_id: component.rich-text
|
|
||||||
- identifier: RichText
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/RichText.astro
|
|
||||||
- functional_role: Renders structured legal or policy prose as a stack of readable cards.
|
|
||||||
- default_semantics: Rich text wrapper for titled legal sections with paragraphs and optional bullet lists.
|
|
||||||
- default_visual_character: content-heavy
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Strong direct-fit primitive for the retained legal and policy routes.
|
|
||||||
|
|
||||||
#### component.trust-principle-card
|
|
||||||
|
|
||||||
- entry_id: component.trust-principle-card
|
|
||||||
- identifier: TrustPrincipleCard
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/TrustPrincipleCard.astro
|
|
||||||
- functional_role: Packages trust claims into a consistent proof-card treatment.
|
|
||||||
- default_semantics: Trust card with title, supporting copy, and optional note.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, trust-candidate]
|
|
||||||
- notes: Canonical trust-content primitive already aligned with the explicit trust posture route.
|
|
||||||
|
|
||||||
#### component.demo-prompt
|
|
||||||
|
|
||||||
- entry_id: component.demo-prompt
|
|
||||||
- identifier: DemoPrompt
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/DemoPrompt.astro
|
|
||||||
- functional_role: Highlights suggested conversation prompts for the first contact exchange.
|
|
||||||
- default_semantics: Simple supporting card for contact/demo preparation prompts.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: B
|
|
||||||
- markers: [contact-candidate]
|
|
||||||
- notes: Useful for the contact path, but it is tied to a specific conversation-prep pattern rather than a universal route need.
|
|
||||||
|
|
||||||
#### component.hero-dashboard
|
|
||||||
|
|
||||||
- entry_id: component.hero-dashboard
|
|
||||||
- identifier: HeroDashboard
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/HeroDashboard.astro
|
|
||||||
- functional_role: Supplies the product-near illustrative UI mock used in hero compositions.
|
|
||||||
- default_semantics: Decorative but content-bearing product mock that implies change history, restore preview, and review queue behavior.
|
|
||||||
- default_visual_character: bold-visual
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: C
|
|
||||||
- markers: [needs-heavy-adaptation, visual-risk, hero-candidate]
|
|
||||||
- notes: The mock is strongly tied to the current TenantAtlas governance narrative and would require coordinated visual and narrative updates before reuse.
|
|
||||||
|
|
||||||
#### component.integration-badge
|
|
||||||
|
|
||||||
- entry_id: component.integration-badge
|
|
||||||
- identifier: IntegrationBadge
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/IntegrationBadge.astro
|
|
||||||
- functional_role: Compresses ecosystem, partner, or integration signals into compact badge cards.
|
|
||||||
- default_semantics: Small proof card for ecosystem-fit lists and supporting strips.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: C
|
|
||||||
- markers: [needs-heavy-adaptation, semantic-risk]
|
|
||||||
- notes: Reusable only when the rebuild can substantiate the ecosystem claim; otherwise the component risks overstating platform breadth.
|
|
||||||
|
|
||||||
#### component.audience-row
|
|
||||||
|
|
||||||
- entry_id: component.audience-row
|
|
||||||
- identifier: AudienceRow
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/AudienceRow.astro
|
|
||||||
- functional_role: Shows audience-specific fit through bullets and an optional secondary CTA.
|
|
||||||
- default_semantics: Audience-fit card primitive used on the solutions route.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: B
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Structurally useful, but the audience framing is supporting rather than core to the first-pass route model.
|
|
||||||
|
|
||||||
#### component.contact-panel
|
|
||||||
|
|
||||||
- entry_id: component.contact-panel
|
|
||||||
- identifier: ContactPanel
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/content/ContactPanel.astro
|
|
||||||
- functional_role: Frames qualified outreach with a short list of who should contact the team and one primary CTA.
|
|
||||||
- default_semantics: Accent card for the contact route that packages qualification points and a primary action.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, contact-candidate]
|
|
||||||
- notes: Directly useful for the contact and demo surface because it keeps outreach intent explicit without building a form workflow.
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
|
|
||||||
#### component.page-shell
|
|
||||||
|
|
||||||
- entry_id: component.page-shell
|
|
||||||
- identifier: PageShell
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/layout/PageShell.astro
|
|
||||||
- functional_role: Wraps every page with SEO metadata, shell attributes, navigation, and footer.
|
|
||||||
- default_semantics: Canonical page-shell orchestrator that binds route metadata to the shared layout and shell components.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Central shell component for any rebuild that preserves the current route architecture.
|
|
||||||
|
|
||||||
#### component.navbar
|
|
||||||
|
|
||||||
- entry_id: component.navbar
|
|
||||||
- identifier: Navbar
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/layout/Navbar.astro
|
|
||||||
- functional_role: Provides the sticky primary navigation with desktop and mobile variants.
|
|
||||||
- default_semantics: Top-level navigation shell with brand lockup, route links, and a secondary CTA.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, navigation-candidate]
|
|
||||||
- notes: Canonical navigation surface for the current website route set.
|
|
||||||
|
|
||||||
#### component.footer
|
|
||||||
|
|
||||||
- entry_id: component.footer
|
|
||||||
- identifier: Footer
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/components/layout/Footer.astro
|
|
||||||
- functional_role: Provides the closing navigation, trustful footer copy, and final CTA.
|
|
||||||
- default_semantics: Footer surface with route-group navigation, lead copy, and a small CTA.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, footer-candidate]
|
|
||||||
- notes: Canonical footer surface and an explicit part of the rebuild inventory.
|
|
||||||
|
|
||||||
#### component.base-layout
|
|
||||||
|
|
||||||
- entry_id: component.base-layout
|
|
||||||
- identifier: BaseLayout
|
|
||||||
- primitive_class: Component
|
|
||||||
- file_ref: apps/website/src/layouts/BaseLayout.astro
|
|
||||||
- functional_role: Owns the document shell, metadata tags, font includes, and global CSS import.
|
|
||||||
- default_semantics: Top-level HTML layout wrapper for the static Astro site.
|
|
||||||
- default_visual_character: minimal
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Included even though it sits outside the T002 discovery command because PageShell depends on it to render any route.
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
# Mapping Anchors
|
|
||||||
|
|
||||||
## Mandatory Reference Rule
|
|
||||||
|
|
||||||
- No downstream mapping spec may claim a keep, adapt, remove, or replace decision without citing at least one `entry_id` from the active baselined catalog.
|
|
||||||
- The active catalog for Spec 226 is `astrodeck-226-baseline` until a later refresh supersedes it.
|
|
||||||
- If a downstream spec references a section or component, it should also cite the owning page or surface context when that context changes the decision.
|
|
||||||
|
|
||||||
## Recommended Reference Block
|
|
||||||
|
|
||||||
Use a concrete inventory block in downstream mapping specs such as `specs/214-website-visual-foundation/`, `specs/215-website-core-pages/`, or `specs/217-homepage-structure/`:
|
|
||||||
|
|
||||||
```md
|
|
||||||
### Inventory References
|
|
||||||
|
|
||||||
- catalog_id: astrodeck-226-baseline
|
|
||||||
- selected_entries:
|
|
||||||
- page.product
|
|
||||||
- section.feature-grid
|
|
||||||
- component.feature-item
|
|
||||||
- suitability_basis:
|
|
||||||
- page.product: A / high
|
|
||||||
- section.feature-grid: A / high
|
|
||||||
- component.feature-item: A / high
|
|
||||||
- decision: adapt
|
|
||||||
- rationale: Keep the product-explainer structure but adjust copy hierarchy and visual density for the approved rebuild route.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Exception Path For Non-Candidate Decisions
|
|
||||||
|
|
||||||
- If a downstream surface has no direct candidate, write `no direct candidate` explicitly in the mapping spec and cite the closest rejected `entry_id` values plus the reason they were rejected.
|
|
||||||
- If an inventory entry is intentionally excluded, cite the `entry_id`, current `suitability_class`, and the concrete reason for exclusion instead of silently omitting it.
|
|
||||||
- If a downstream spec decides a current `A` or `B` entry should still be removed, that spec must document the higher-priority constraint that overrides the inventory suitability.
|
|
||||||
|
|
||||||
## Refresh Rule Before The Next Mapping Cycle
|
|
||||||
|
|
||||||
- Refresh the inventory whenever the source primitive set changes under `apps/website/src/pages`, `apps/website/src/components`, or `apps/website/src/layouts`.
|
|
||||||
- Re-run the T002 discovery command from repo root and compare the resulting file list with `inventory/catalog.md`.
|
|
||||||
- If the primitive set changes, update the row-level inventory files, reconcile `summary.md`, and set `catalog.md` back to `draft` until review completes again.
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
# Page Inventory
|
|
||||||
|
|
||||||
- catalog_id: astrodeck-226-baseline
|
|
||||||
- primitive_class: Page
|
|
||||||
- entry_count: 13
|
|
||||||
|
|
||||||
### page.index
|
|
||||||
|
|
||||||
- entry_id: page.index
|
|
||||||
- identifier: index
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/index.astro
|
|
||||||
- functional_role: Homepage entrypoint that sequences the launch hero, product framing, trust, progress, and contact handoff.
|
|
||||||
- default_semantics: Primary marketing route for first-visit orientation and route handoff into product, trust, changelog, and contact.
|
|
||||||
- default_visual_character: bold-visual
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: The current homepage already reflects the canonical TenantAtlas launch journey and is the main reference page for downstream mapping.
|
|
||||||
|
|
||||||
### page.product
|
|
||||||
|
|
||||||
- entry_id: page.product
|
|
||||||
- identifier: product
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/product.astro
|
|
||||||
- functional_role: Explains the connected product model before the visitor is asked to trust or contact the team.
|
|
||||||
- default_semantics: Product explainer route that combines hero framing, feature clusters, narrative callouts, and downstream CTAs.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, product-explainer-candidate]
|
|
||||||
- notes: Strong direct-fit route for rebuild planning because it already anchors the product explanation in current release truth.
|
|
||||||
|
|
||||||
### page.trust
|
|
||||||
|
|
||||||
- entry_id: page.trust
|
|
||||||
- identifier: trust
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/trust.astro
|
|
||||||
- functional_role: Dedicated trust posture route for operator safeguards, isolation boundaries, and credibility framing.
|
|
||||||
- default_semantics: Trust-first supporting page with one explicit proof surface and a clear contact handoff.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, trust-candidate]
|
|
||||||
- notes: This is the canonical trust route and should remain explicit in any AstroDeck rebuild plan.
|
|
||||||
|
|
||||||
### page.changelog
|
|
||||||
|
|
||||||
- entry_id: page.changelog
|
|
||||||
- identifier: changelog
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/changelog.astro
|
|
||||||
- functional_role: Publishes dated product progress so visitors can verify motion without relying on vague marketing claims.
|
|
||||||
- default_semantics: Content-led route with a hero, chronologically ordered update cards, and a closing CTA back into evaluation.
|
|
||||||
- default_visual_character: content-heavy
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, changelog-candidate]
|
|
||||||
- notes: The route is already aligned with the visible-progress requirement in the active website strategy.
|
|
||||||
|
|
||||||
### page.contact
|
|
||||||
|
|
||||||
- entry_id: page.contact
|
|
||||||
- identifier: contact
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/contact.astro
|
|
||||||
- functional_role: Qualifies outreach and shows what a serious first conversation should contain before anyone shares sensitive detail.
|
|
||||||
- default_semantics: Contact and demo-prep route with guidance cards, a static message preview, legal reassurance, and a CTA handoff.
|
|
||||||
- default_visual_character: utility-dense
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, contact-candidate]
|
|
||||||
- notes: Strong fit for rebuild planning because it already combines contact intent, legal reassurance, and route continuity.
|
|
||||||
|
|
||||||
### page.solutions
|
|
||||||
|
|
||||||
- entry_id: page.solutions
|
|
||||||
- identifier: solutions
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/solutions.astro
|
|
||||||
- functional_role: Shows audience fit and operating-model resonance for deeper evaluators without owning the primary journey.
|
|
||||||
- default_semantics: Supporting audience-fit route with hero framing, audience cards, supporting signals, and a closing CTA.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: B
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Reusable with limited copy and information-architecture changes, but it should stay subordinate to the primary route set.
|
|
||||||
|
|
||||||
### page.integrations
|
|
||||||
|
|
||||||
- entry_id: page.integrations
|
|
||||||
- identifier: integrations
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/integrations.astro
|
|
||||||
- functional_role: Documents ecosystem fit and integration direction without pretending broad platform coverage is already launch truth.
|
|
||||||
- default_semantics: Supporting ecosystem route with badges, rule cards, and a handoff back into product or contact.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: B
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Structurally reusable, but its value depends on keeping ecosystem claims concrete and bounded.
|
|
||||||
|
|
||||||
### page.privacy
|
|
||||||
|
|
||||||
- entry_id: page.privacy
|
|
||||||
- identifier: privacy
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/privacy.astro
|
|
||||||
- functional_role: Explains the narrow public-site privacy posture and the bounds of inquiry handling.
|
|
||||||
- default_semantics: Legal text route with an opening hero, reading-width rich text, and a simple next-step CTA.
|
|
||||||
- default_visual_character: content-heavy
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: B
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Good structural fit for the rebuild, but the content remains jurisdiction- and policy-dependent.
|
|
||||||
|
|
||||||
### page.terms
|
|
||||||
|
|
||||||
- entry_id: page.terms
|
|
||||||
- identifier: terms
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/terms.astro
|
|
||||||
- functional_role: States the website terms and clarifies that public marketing pages do not replace commercial agreements.
|
|
||||||
- default_semantics: Legal text route built from hero, reading-width prose cards, and a closing CTA.
|
|
||||||
- default_visual_character: content-heavy
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: B
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Directly reusable for the legal baseline, with expected copy and jurisdiction updates only.
|
|
||||||
|
|
||||||
### page.imprint
|
|
||||||
|
|
||||||
- entry_id: page.imprint
|
|
||||||
- identifier: imprint
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/imprint.astro
|
|
||||||
- functional_role: Publishes the public legal-notice baseline for publisher identity and jurisdiction-specific disclosure.
|
|
||||||
- default_semantics: Legal notice route with a hero, reading-width legal content, and a return CTA into trust or contact.
|
|
||||||
- default_visual_character: content-heavy
|
|
||||||
- tenantatlas_relevance: low
|
|
||||||
- suitability_class: B
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Useful for the retained legal baseline, but the content remains highly dependent on jurisdiction and publication details.
|
|
||||||
|
|
||||||
### page.legal
|
|
||||||
|
|
||||||
- entry_id: page.legal
|
|
||||||
- identifier: legal
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/legal.astro
|
|
||||||
- functional_role: Aggregates trust, privacy, terms, and imprint into one retained legal hub route.
|
|
||||||
- default_semantics: Secondary legal index page that duplicates links to leaf legal routes and adds a short notice section.
|
|
||||||
- default_visual_character: content-heavy
|
|
||||||
- tenantatlas_relevance: low
|
|
||||||
- suitability_class: C
|
|
||||||
- markers: [needs-heavy-adaptation, semantic-risk]
|
|
||||||
- notes: The hub risks adding route ambiguity because the leaf legal pages already exist; keep only if the rebuild still needs a legal index.
|
|
||||||
|
|
||||||
### page.security-trust
|
|
||||||
|
|
||||||
- entry_id: page.security-trust
|
|
||||||
- identifier: security-trust
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/security-trust.astro
|
|
||||||
- functional_role: Preserves a legacy alias by redirecting `/security-trust` to `/trust`.
|
|
||||||
- default_semantics: Redirect-only route with no user-facing content surface.
|
|
||||||
- default_visual_character: minimal
|
|
||||||
- tenantatlas_relevance: low
|
|
||||||
- suitability_class: D
|
|
||||||
- markers: []
|
|
||||||
- notes: Inventory it for route completeness, but exclude it from visual surface selection because it contributes no reusable layout or content pattern.
|
|
||||||
|
|
||||||
### page.sitemap-xml
|
|
||||||
|
|
||||||
- entry_id: page.sitemap-xml
|
|
||||||
- identifier: sitemap.xml
|
|
||||||
- primitive_class: Page
|
|
||||||
- file_ref: apps/website/src/pages/sitemap.xml.ts
|
|
||||||
- functional_role: Exposes a machine-readable sitemap for search crawlers.
|
|
||||||
- default_semantics: API-style XML endpoint rather than a human-facing content route.
|
|
||||||
- default_visual_character: utility-dense
|
|
||||||
- tenantatlas_relevance: none
|
|
||||||
- suitability_class: D
|
|
||||||
- markers: []
|
|
||||||
- notes: Keep visible in the inventory for route governance, but it is not a rebuild candidate for visual or narrative surfaces.
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
# Section Inventory
|
|
||||||
|
|
||||||
- catalog_id: astrodeck-226-baseline
|
|
||||||
- primitive_class: Section
|
|
||||||
- entry_count: 8
|
|
||||||
|
|
||||||
### section.page-hero
|
|
||||||
|
|
||||||
- entry_id: section.page-hero
|
|
||||||
- identifier: PageHero
|
|
||||||
- primitive_class: Section
|
|
||||||
- file_ref: apps/website/src/components/sections/PageHero.astro
|
|
||||||
- functional_role: Renders the primary hero composition for the homepage and all secondary pages.
|
|
||||||
- default_semantics: Hero section with badge, headline, supporting copy, CTA cluster, and optional product visual or trust proof.
|
|
||||||
- default_visual_character: bold-visual
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, hero-candidate]
|
|
||||||
- notes: This is the most important rebuild primitive because it already defines homepage and subpage hero behavior in one place.
|
|
||||||
|
|
||||||
### section.feature-grid
|
|
||||||
|
|
||||||
- entry_id: section.feature-grid
|
|
||||||
- identifier: FeatureGrid
|
|
||||||
- primitive_class: Section
|
|
||||||
- file_ref: apps/website/src/components/sections/FeatureGrid.astro
|
|
||||||
- functional_role: Presents feature or rule clusters in a repeatable grid with a section header.
|
|
||||||
- default_semantics: Product-explainer or rule-list section that relies on structured cards rather than freeform prose.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, product-explainer-candidate]
|
|
||||||
- notes: Strong direct-fit section for product, solutions, and integrations surfaces.
|
|
||||||
|
|
||||||
### section.cta-section
|
|
||||||
|
|
||||||
- entry_id: section.cta-section
|
|
||||||
- identifier: CTASection
|
|
||||||
- primitive_class: Section
|
|
||||||
- file_ref: apps/website/src/components/sections/CTASection.astro
|
|
||||||
- functional_role: Delivers the final conversion handoff from any route back into contact or the next core page.
|
|
||||||
- default_semantics: Closing CTA band with one required primary action and one optional secondary action.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, contact-candidate]
|
|
||||||
- notes: It appears across the route set and already encodes the preferred evaluation handoff pattern.
|
|
||||||
|
|
||||||
### section.capability-grid
|
|
||||||
|
|
||||||
- entry_id: section.capability-grid
|
|
||||||
- identifier: CapabilityGrid
|
|
||||||
- primitive_class: Section
|
|
||||||
- file_ref: apps/website/src/components/sections/CapabilityGrid.astro
|
|
||||||
- functional_role: Explains the grouped product model through clustered capability cards.
|
|
||||||
- default_semantics: Product-explainer section with structured clusters, optional metadata badges, and linked supporting detail.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, product-explainer-candidate]
|
|
||||||
- notes: Strong fit for any rebuild surface that needs to explain how TenantAtlas capabilities connect.
|
|
||||||
|
|
||||||
### section.outcome-section
|
|
||||||
|
|
||||||
- entry_id: section.outcome-section
|
|
||||||
- identifier: OutcomeSection
|
|
||||||
- primitive_class: Section
|
|
||||||
- file_ref: apps/website/src/components/sections/OutcomeSection.astro
|
|
||||||
- functional_role: Frames product value through outcome cards rather than a feature checklist.
|
|
||||||
- default_semantics: Mid-page section with a header plus three outcome cards.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely]
|
|
||||||
- notes: Useful for the homepage because it translates product truth into buyer outcomes without inflating surface complexity.
|
|
||||||
|
|
||||||
### section.trust-grid
|
|
||||||
|
|
||||||
- entry_id: section.trust-grid
|
|
||||||
- identifier: TrustGrid
|
|
||||||
- primitive_class: Section
|
|
||||||
- file_ref: apps/website/src/components/sections/TrustGrid.astro
|
|
||||||
- functional_role: Shows the public trust posture through structured trust-principle cards.
|
|
||||||
- default_semantics: Trust-proof section with a header and a fixed card grid.
|
|
||||||
- default_visual_character: enterprise-neutral
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, trust-candidate]
|
|
||||||
- notes: Canonical trust section for both the homepage trust block and the dedicated trust route.
|
|
||||||
|
|
||||||
### section.logo-strip
|
|
||||||
|
|
||||||
- entry_id: section.logo-strip
|
|
||||||
- identifier: LogoStrip
|
|
||||||
- primitive_class: Section
|
|
||||||
- file_ref: apps/website/src/components/sections/LogoStrip.astro
|
|
||||||
- functional_role: Displays ecosystem-fit badges or logo-like entries in a compact supporting strip.
|
|
||||||
- default_semantics: Supporting proof section that compresses partner, ecosystem, or integration signals into a narrow banner treatment.
|
|
||||||
- default_visual_character: decorative
|
|
||||||
- tenantatlas_relevance: medium
|
|
||||||
- suitability_class: C
|
|
||||||
- markers: [needs-heavy-adaptation, semantic-risk]
|
|
||||||
- notes: The pattern is reusable only when ecosystem proof is concrete; otherwise it risks overstating breadth and slipping into decorative social proof.
|
|
||||||
|
|
||||||
### section.progress-teaser
|
|
||||||
|
|
||||||
- entry_id: section.progress-teaser
|
|
||||||
- identifier: ProgressTeaser
|
|
||||||
- primitive_class: Section
|
|
||||||
- file_ref: apps/website/src/components/sections/ProgressTeaser.astro
|
|
||||||
- functional_role: Surfaces recent dated progress entries and links back to the full changelog.
|
|
||||||
- default_semantics: Changelog teaser section with compact dated cards and one follow-up CTA.
|
|
||||||
- default_visual_character: utility-dense
|
|
||||||
- tenantatlas_relevance: high
|
|
||||||
- suitability_class: A
|
|
||||||
- markers: [tenantatlas-likely, changelog-candidate]
|
|
||||||
- notes: Directly aligned with the visible-progress requirement for the current website strategy.
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
# Suitability Summary
|
|
||||||
|
|
||||||
- catalog_id: astrodeck-226-baseline
|
|
||||||
- reconciled_entry_count: 51
|
|
||||||
|
|
||||||
## Suitability Distribution
|
|
||||||
|
|
||||||
| Class | Count |
|
|
||||||
|-------|-------|
|
|
||||||
| A | 35 |
|
|
||||||
| B | 10 |
|
|
||||||
| C | 4 |
|
|
||||||
| D | 2 |
|
|
||||||
|
|
||||||
## Risk Visibility
|
|
||||||
|
|
||||||
| Marker | Count | Entry IDs |
|
|
||||||
|--------|-------|-----------|
|
|
||||||
| `visual-risk` | 1 | `component.hero-dashboard` |
|
|
||||||
| `semantic-risk` | 3 | `page.legal`, `section.logo-strip`, `component.integration-badge` |
|
|
||||||
| `demo-only` | 0 | `none` |
|
|
||||||
|
|
||||||
## Surface Candidate Visibility
|
|
||||||
|
|
||||||
| Surface | Candidate entry_ids | Notes |
|
|
||||||
|---------|---------------------|-------|
|
|
||||||
| homepage | `page.index`, `section.page-hero`, `section.outcome-section`, `section.capability-grid`, `section.trust-grid`, `section.progress-teaser`, `section.cta-section` | Current MarkerVocabulary has no dedicated `homepage-candidate` tag, so homepage visibility is tracked in this summary only. |
|
|
||||||
| hero | `section.page-hero`, `component.hero-dashboard`, `component.metric` | All non-homepage entries in this row carry `hero-candidate`. |
|
|
||||||
| product | `page.product`, `section.capability-grid`, `section.feature-grid`, `component.feature-item` | All non-page entries in this row carry `product-explainer-candidate`. |
|
|
||||||
| trust | `page.trust`, `section.trust-grid`, `component.trust-principle-card`, `component.callout` | All supporting entries in this row carry `trust-candidate`. |
|
|
||||||
| changelog | `page.changelog`, `section.progress-teaser` | Supporting section carries `changelog-candidate`. |
|
|
||||||
| contact-demo | `page.contact`, `section.cta-section`, `component.contact-panel`, `component.demo-prompt` | Supporting entries in this row carry `contact-candidate`. |
|
|
||||||
| navigation | `component.navbar` | Canonical navigation surface. |
|
|
||||||
| footer | `component.footer` | Canonical footer surface. |
|
|
||||||
|
|
||||||
## Reconciliation Notes
|
|
||||||
|
|
||||||
- Pages: 13 entries (`A=5`, `B=5`, `C=1`, `D=2`)
|
|
||||||
- Sections: 8 entries (`A=7`, `B=0`, `C=1`, `D=0`)
|
|
||||||
- Components: 30 entries (`A=23`, `B=5`, `C=2`, `D=0`)
|
|
||||||
- Non-candidates remain visible in the row-level inventory instead of being omitted from the summary.
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
# Implementation Plan: AstroDeck Inventory Planning for Website Rebuild
|
|
||||||
|
|
||||||
**Branch**: `226-astrodeck-inventory-planning` | **Date**: 2026-04-22 | **Spec**: `specs/226-astrodeck-inventory-planning/spec.md`
|
|
||||||
**Input**: Feature specification from `specs/226-astrodeck-inventory-planning/spec.md`
|
|
||||||
|
|
||||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- Establish a mandatory, repository-referenceable AstroDeck inventory for `apps/website` before any new AstroDeck-based rebuild mapping or task planning.
|
|
||||||
- Cover all three primitive classes (`Page`, `Section`, `Component`) with unified required attributes, suitability grading, relevance scoring, and risk/candidate markers.
|
|
||||||
- Keep this feature documentation-only and workspace-local: no runtime route changes, no platform coupling, no new persistence.
|
|
||||||
- Produce design artifacts (`research.md`, `data-model.md`, `contracts/`, `quickstart.md`) that make follow-up mapping specs (214/215/217 alignment work) deterministic and auditable.
|
|
||||||
|
|
||||||
## Technical Context
|
|
||||||
|
|
||||||
**Language/Version**: Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery
|
|
||||||
**Primary Dependencies**: Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`)
|
|
||||||
**Storage**: Filesystem only (`specs/226-astrodeck-inventory-planning/*`)
|
|
||||||
**Testing**: Documentation quality checks + structural completeness review (no runtime test suite changes)
|
|
||||||
**Validation Lanes**: N/A (docs-only planning artifact)
|
|
||||||
**Target Platform**: Monorepo planning workflow with website scope limited to `apps/website`
|
|
||||||
**Project Type**: Web documentation and planning spec (no executable feature code)
|
|
||||||
**Performance Goals**: Inventory must be complete enough to avoid ad-hoc primitive selection and support deterministic mapping decisions
|
|
||||||
**Constraints**: Must remain strictly local to `apps/website`; must capture demo-only and non-candidate primitives; must not pre-commit final mapping choices; must not introduce backend/runtime changes
|
|
||||||
**Scale/Scope**: Current website primitive landscape spans page entry routes in `apps/website/src/pages`, section composites in `apps/website/src/components/sections`, and component primitives/content/layout in `apps/website/src/components/*`
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Plan
|
|
||||||
|
|
||||||
- **Guardrail scope**: no operator-facing surface change
|
|
||||||
- **Native vs custom classification summary**: N/A
|
|
||||||
- **Shared-family relevance**: none
|
|
||||||
- **State layers in scope**: none
|
|
||||||
- **Handling modes by drift class or surface**: N/A
|
|
||||||
- **Repository-signal treatment**: report-only (spec artifacts)
|
|
||||||
- **Special surface test profiles**: N/A
|
|
||||||
- **Required tests or manual smoke**: N/A
|
|
||||||
- **Exception path and spread control**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
|
||||||
|
|
||||||
- Inventory-first: Pass. This feature explicitly enforces inventory-before-mapping as the primary rule.
|
|
||||||
- Read/write separation: Pass. No write flow, no queue/scheduler, no operational mutation path.
|
|
||||||
- Graph contract path: N/A. No Microsoft Graph calls.
|
|
||||||
- Deterministic capabilities: N/A. No capability resolver or runtime authorization semantics added.
|
|
||||||
- RBAC-UX and workspace/tenant isolation rules: N/A for runtime; this is repository planning documentation only.
|
|
||||||
- Run observability / Ops-UX: N/A. No `OperationRun` introduced.
|
|
||||||
- Data minimization: Pass. Only planning metadata; no secrets, no tenant records.
|
|
||||||
- Test governance (TEST-GOV-001): Pass with docs-only classification and explicit N/A lane decision.
|
|
||||||
- Proportionality and no premature abstraction: Pass. Adds only a narrow inventory taxonomy needed for immediate follow-up specs.
|
|
||||||
- Persisted truth and behavioral state: Pass. No DB entity/state machine added.
|
|
||||||
- UI semantics and layer control: Pass. No new runtime UI semantic framework; taxonomy remains spec-local.
|
|
||||||
- Filament/operator surface rules: N/A.
|
|
||||||
- Spec discipline/bloat check: Pass. Taxonomy is scoped to this feature and documented with explicit non-goals.
|
|
||||||
|
|
||||||
Status: ✅ Constitution gate passed for Phase 0.
|
|
||||||
|
|
||||||
## Test Governance Check
|
|
||||||
|
|
||||||
- **Test purpose / classification by changed surface**: N/A (docs-only)
|
|
||||||
- **Affected validation lanes**: N/A
|
|
||||||
- **Why this lane mix is the narrowest sufficient proof**: No runtime behavior changes; quality is proven via artifact completeness and reviewable structure.
|
|
||||||
- **Narrowest proving command(s)**: N/A
|
|
||||||
- **Fixture / helper / factory / seed / context cost risks**: none
|
|
||||||
- **Expensive defaults or shared helper growth introduced?**: no
|
|
||||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
|
||||||
- **Surface-class relief / special coverage rule**: N/A
|
|
||||||
- **Closing validation and reviewer handoff**: Reviewers verify required sections, no unresolved clarification markers, and contract/data-model consistency.
|
|
||||||
- **Budget / baseline / trend follow-up**: none
|
|
||||||
- **Review-stop questions**: Are all primitive classes covered? Are risks and non-candidates visible? Are follow-up specs able to reference concrete inventory IDs?
|
|
||||||
- **Escalation path**: document-in-feature
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Why no dedicated follow-up spec is needed**: This planning unit is itself the prerequisite contract; runtime work comes in later mapping/implementation specs.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/226-astrodeck-inventory-planning/
|
|
||||||
├── plan.md
|
|
||||||
├── research.md
|
|
||||||
├── data-model.md
|
|
||||||
├── quickstart.md
|
|
||||||
├── contracts/
|
|
||||||
│ └── astrodeck-inventory.logical.openapi.yaml
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/website/
|
|
||||||
├── src/
|
|
||||||
│ ├── pages/ # Astro page entrypoints (inventory class: Page)
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── sections/ # Composite sections (inventory class: Section)
|
|
||||||
│ │ ├── primitives/ # Structural primitives (inventory class: Component)
|
|
||||||
│ │ ├── content/ # Semantic content components (inventory class: Component)
|
|
||||||
│ │ └── layout/ # Shell/navigation/footer components (inventory class: Component)
|
|
||||||
│ ├── content/ # Route content definitions used for semantic context
|
|
||||||
│ ├── styles/
|
|
||||||
│ └── types/
|
|
||||||
└── tests/
|
|
||||||
└── smoke/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: Keep all implementation planning local to `apps/website` and derive inventory artifacts directly from current page/component directories without introducing a new shared package or backend service.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
None.
|
|
||||||
|
|
||||||
## Proportionality Review
|
|
||||||
|
|
||||||
- **Current operator problem**: Rebuild decisions cannot be audited or reproduced when AstroDeck primitive availability is undocumented.
|
|
||||||
- **Existing structure is insufficient because**: Existing website specs define desired outcomes but not a complete primitive candidate universe.
|
|
||||||
- **Narrowest correct implementation**: A mandatory inventory contract with required attributes, suitability classes, and candidate/risk markers.
|
|
||||||
- **Ownership cost created**: Ongoing updates when primitives change; review overhead to keep mappings anchored to inventory references.
|
|
||||||
- **Alternative intentionally rejected**: Ad-hoc mapping without inventory, because it hides omissions and increases rework risk.
|
|
||||||
- **Release truth**: Current-release truth.
|
|
||||||
|
|
||||||
*Full BLOAT-001 proportionality review (6 questions) is answered in `specs/226-astrodeck-inventory-planning/spec.md` under "Proportionality Review". The new cross-domain taxonomy (Suitability Class, MarkerVocabulary, Primitive Class, TenantAtlas Relevance) is flagged as "New cross-domain UI framework/taxonomy: yes" with all six BLOAT-001 questions answered there.*
|
|
||||||
|
|
||||||
## Phase 0 — Outline & Research (complete)
|
|
||||||
|
|
||||||
- Output: `specs/226-astrodeck-inventory-planning/research.md`
|
|
||||||
- Unknowns resolved:
|
|
||||||
- Concrete primitive discovery boundaries in `apps/website` (`pages`, `sections`, `components` families).
|
|
||||||
- Practical inventory schema pattern that supports future mapping specs.
|
|
||||||
- Risk/candidate marker strategy that keeps non-candidates visible.
|
|
||||||
- Key decisions captured:
|
|
||||||
- Use repository-derived component taxonomy as inventory source of truth.
|
|
||||||
- Keep inventory neutral (candidate visibility without premature selection).
|
|
||||||
- Standardize suitability classes A/B/C/D and relevance levels high/medium/low/none.
|
|
||||||
- Include demo-only/template-only artifacts explicitly.
|
|
||||||
|
|
||||||
## Phase 1 — Design & Contracts (complete)
|
|
||||||
|
|
||||||
### Data model
|
|
||||||
|
|
||||||
- Output: `specs/226-astrodeck-inventory-planning/data-model.md`
|
|
||||||
- Defines inventory entities, required attributes, marker vocabulary, and lifecycle/status rules for planning governance.
|
|
||||||
|
|
||||||
### Design contract
|
|
||||||
|
|
||||||
- Output: `specs/226-astrodeck-inventory-planning/contracts/astrodeck-inventory.logical.openapi.yaml`
|
|
||||||
- Logical contract for inventory ingestion/query/reporting workflows that follow-up mapping specs can consume.
|
|
||||||
|
|
||||||
### Quickstart
|
|
||||||
|
|
||||||
- Output: `specs/226-astrodeck-inventory-planning/quickstart.md`
|
|
||||||
- Documents the concrete process to generate and validate the inventory from `apps/website` source paths.
|
|
||||||
|
|
||||||
### Agent context update
|
|
||||||
|
|
||||||
- Completed via `.specify/scripts/bash/update-agent-context.sh copilot`.
|
|
||||||
|
|
||||||
### Constitution re-check (post-design)
|
|
||||||
|
|
||||||
- ✅ Still docs-only and workspace-scoped.
|
|
||||||
- ✅ No runtime mutation, no Graph, no new persistence.
|
|
||||||
- ✅ Proportionality remains narrow and directly tied to current follow-up mapping needs.
|
|
||||||
- ✅ No guardrail regressions introduced.
|
|
||||||
|
|
||||||
## Phase 2 — Implementation Plan (next)
|
|
||||||
|
|
||||||
### Story 1 (P1): Build complete primitive inventory
|
|
||||||
|
|
||||||
- Enumerate all relevant Astro page entrypoints, section composites, and component families from `apps/website/src`.
|
|
||||||
- Capture mandatory fields per entry (identifier, class, role, semantics, visual character, relevance, suitability).
|
|
||||||
- Ensure demo/template-only entries are included and marked.
|
|
||||||
|
|
||||||
### Story 2 (P1): Add suitability and risk classification
|
|
||||||
|
|
||||||
- Apply A/B/C/D suitability classes and relevance grading to every entry.
|
|
||||||
- Add marker tags (`tenantatlas-likely`, `visual-risk`, `semantic-risk`, `demo-only`, etc.) where applicable.
|
|
||||||
- Publish suitability overview and candidate visibility slices for homepage/hero/product/trust/changelog/contact/navigation/footer.
|
|
||||||
|
|
||||||
### Story 3 (P2): Anchor follow-up mappings to inventory
|
|
||||||
|
|
||||||
- Require mapping specs (214/215/217 follow-ups) to reference concrete inventory entry identifiers.
|
|
||||||
- Document exception path for remove/non-candidate decisions.
|
|
||||||
- Keep inventory updated as primitives evolve to preserve planning determinism.
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
# Quickstart: AstroDeck Inventory Planning (Spec 226)
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Create a complete, mapping-ready AstroDeck inventory for `apps/website` before any rebuild mapping or task re-planning.
|
|
||||||
|
|
||||||
## Preconditions
|
|
||||||
|
|
||||||
- Work from branch `226-astrodeck-inventory-planning`.
|
|
||||||
- Keep scope strictly inside `apps/website`.
|
|
||||||
- Do not make final keep/adapt/remove decisions in this step.
|
|
||||||
|
|
||||||
## Step 1: Discover primitive candidates from source paths
|
|
||||||
|
|
||||||
From repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/website
|
|
||||||
rg --files src/pages src/components/sections src/components/primitives src/components/content src/components/layout
|
|
||||||
```
|
|
||||||
|
|
||||||
Classify files into:
|
|
||||||
|
|
||||||
- `Page`: route entrypoints in `src/pages`
|
|
||||||
- `Section`: composites in `src/components/sections`
|
|
||||||
- `Component`: reusable units in `src/components/primitives`, `src/components/content`, `src/components/layout`
|
|
||||||
|
|
||||||
## Step 2: Build inventory entries
|
|
||||||
|
|
||||||
For each candidate, capture required attributes (satisfies FR-002 and FR-008):
|
|
||||||
|
|
||||||
- `identifier`
|
|
||||||
- `primitive_class`
|
|
||||||
- `file_ref`
|
|
||||||
- `functional_role`
|
|
||||||
- `default_semantics`
|
|
||||||
- `default_visual_character`
|
|
||||||
- `tenantatlas_relevance` (`high|medium|low|none`)
|
|
||||||
- `suitability_class` (`A|B|C|D`)
|
|
||||||
|
|
||||||
### Suitability Decision Rubric (FR-008: Bewertungslogik)
|
|
||||||
|
|
||||||
Apply the following criteria consistently across all entries. Consider structure, semantics, rework effort, risk, and reuse potential:
|
|
||||||
|
|
||||||
| Class | Meaning | Typical signals |
|
|
||||||
|-------|---------|-----------------|
|
|
||||||
| **A** | Directly reusable as-is for TenantAtlas | Matches enterprise-neutral semantics, no rework needed, no visual/semantic risk |
|
|
||||||
| **B** | Reusable with minor TenantAtlas adaptation | Needs copy/color/layout tweaks but structural fit is good; low risk |
|
|
||||||
| **C** | Reusable only with significant rework | Semantic mismatch, heavy visual risk, or complex structural changes required |
|
|
||||||
| **D** | Not suitable for TenantAtlas use | Wrong domain semantics, demo-only purpose, or fundamentally incompatible design |
|
|
||||||
|
|
||||||
### Marker Application Rules
|
|
||||||
|
|
||||||
Markers are applied per-entry from the allowed `MarkerVocabulary`. Minimum required marker rules:
|
|
||||||
|
|
||||||
- `demo-only` — **required** if the entry has `suitability_class: D` and its purpose is demo/template-only
|
|
||||||
- `visual-risk` — **required** if `suitability_class` is C and the notes mention visual incompatibility
|
|
||||||
- `semantic-risk` — **required** if `suitability_class` is C and the notes mention semantic mismatch
|
|
||||||
- Surface candidate markers (`hero-candidate`, `trust-candidate`, etc.) — **required** if the entry appears in a surface's candidate list in summary.md
|
|
||||||
|
|
||||||
All other markers are optional and additive.
|
|
||||||
|
|
||||||
Add markers where applicable:
|
|
||||||
|
|
||||||
- `tenantatlas-likely`
|
|
||||||
- `needs-heavy-adaptation`
|
|
||||||
- `visual-risk`
|
|
||||||
- `semantic-risk`
|
|
||||||
- `demo-only`
|
|
||||||
- `remove-likely`
|
|
||||||
- `hero-candidate`
|
|
||||||
- `trust-candidate`
|
|
||||||
- `navigation-candidate`
|
|
||||||
- `footer-candidate`
|
|
||||||
- `contact-candidate`
|
|
||||||
- `product-explainer-candidate`
|
|
||||||
- `changelog-candidate`
|
|
||||||
|
|
||||||
## Step 3: Produce summary visibility
|
|
||||||
|
|
||||||
Compute and document:
|
|
||||||
|
|
||||||
- Suitability distribution (`A/B/C/D`)
|
|
||||||
- Risk visibility counts (`visual-risk`, `semantic-risk`, `demo-only`)
|
|
||||||
- Candidate visibility for:
|
|
||||||
- homepage
|
|
||||||
- hero
|
|
||||||
- product
|
|
||||||
- trust
|
|
||||||
- changelog
|
|
||||||
- contact/demo
|
|
||||||
- navigation
|
|
||||||
- footer
|
|
||||||
|
|
||||||
## Step 4: Validate spec acceptance alignment
|
|
||||||
|
|
||||||
Confirm:
|
|
||||||
|
|
||||||
- All three classes (`Page`, `Section`, `Component`) are present.
|
|
||||||
- No entry misses required fields.
|
|
||||||
- Non-candidates are visible, not omitted.
|
|
||||||
- Inventory remains neutral and does not pre-empt mapping decisions.
|
|
||||||
|
|
||||||
## Step 5: Prepare downstream mapping handoff
|
|
||||||
|
|
||||||
Use stable `entry_id` references for follow-up specs and tasks so mapping choices and exceptions can be traced back to this inventory baseline.
|
|
||||||
|
|
||||||
## Done Criteria
|
|
||||||
|
|
||||||
The quickstart execution is complete when:
|
|
||||||
|
|
||||||
- Inventory artifacts satisfy Spec 226 AC1-AC7.
|
|
||||||
- Suitability and candidate/risk summaries are present and coherent.
|
|
||||||
- Follow-up mapping specs can consume inventory IDs without ambiguity.
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
# Research: AstroDeck Inventory Planning for Website Rebuild
|
|
||||||
|
|
||||||
## Decision 1: Inventory source boundaries are directory-driven and explicit
|
|
||||||
|
|
||||||
- **Decision**: Use current `apps/website/src` structure as the mandatory discovery boundary:
|
|
||||||
- `src/pages` -> `Page` primitives
|
|
||||||
- `src/components/sections` -> `Section` primitives
|
|
||||||
- `src/components/primitives`, `src/components/content`, `src/components/layout` -> `Component` primitives
|
|
||||||
- **Rationale**: This matches how the website is already authored and prevents hidden omissions.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Inventory only referenced components in current routes: rejected because it misses available but currently unused candidates.
|
|
||||||
- Inventory from screenshots/manual browsing only: rejected because it is incomplete and non-deterministic.
|
|
||||||
|
|
||||||
## Decision 2: Include demo/template artifacts as first-class inventory entries
|
|
||||||
|
|
||||||
- **Decision**: Mark template/demo artifacts explicitly (e.g., `demo-only`) instead of excluding them.
|
|
||||||
- **Rationale**: Excluding them early hides available options and weakens auditability of later keep/adapt/remove decisions.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Exclude demo components from inventory: rejected because it creates selective inventory bias.
|
|
||||||
|
|
||||||
## Decision 3: Suitability classes must be mandatory and exhaustive
|
|
||||||
|
|
||||||
- **Decision**: Every inventory entry receives exactly one suitability class:
|
|
||||||
- `A` strong candidate
|
|
||||||
- `B` adaptable candidate
|
|
||||||
- `C` weak candidate
|
|
||||||
- `D` non-candidate
|
|
||||||
- **Rationale**: Follow-up mapping specs require deterministic triage and cannot rely on narrative-only judgments.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Optional suitability scoring: rejected because it creates inconsistent coverage across entries.
|
|
||||||
|
|
||||||
## Decision 4: Risk and candidate markers are additive, not substitutive
|
|
||||||
|
|
||||||
- **Decision**: Marker tags (e.g., `visual-risk`, `semantic-risk`, `hero-candidate`, `trust-candidate`) augment but do not replace required fields.
|
|
||||||
- **Rationale**: Tags improve queryability for later mapping while preserving normalized core attributes.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Use markers only, skip required fields: rejected because it is too ambiguous for cross-spec traceability.
|
|
||||||
|
|
||||||
## Decision 5: Inventory output must be mapping-consumable by contract
|
|
||||||
|
|
||||||
- **Decision**: Define a logical OpenAPI contract for inventory publication and summary retrieval so downstream specs can reference stable structures.
|
|
||||||
- **Rationale**: A contractized shape increases consistency for future tasks and automation without introducing runtime implementation in this feature.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Freeform markdown only: rejected because downstream mapping extraction becomes error-prone.
|
|
||||||
|
|
||||||
## Decision 6: Existing repo patterns favor normalized inventories with explicit classes
|
|
||||||
|
|
||||||
- **Decision**: Mirror existing repository planning patterns by using normalized entity fields, explicit classifications, and deterministic summaries.
|
|
||||||
- **Rationale**: Prior specs (`040`, `042`, `045`) consistently model inventory-like domains with strict fields and taxonomy semantics.
|
|
||||||
- **Alternatives considered**:
|
|
||||||
- Introduce a novel inventory schema unrelated to existing specs: rejected because it adds unnecessary cognitive overhead.
|
|
||||||
|
|
||||||
## Resolved Clarifications
|
|
||||||
|
|
||||||
- No unresolved `NEEDS CLARIFICATION` items remain.
|
|
||||||
- The technical stack and boundaries are sufficient for Phase 1 artifact design.
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
# Feature Specification: AstroDeck Inventory Planning for Website Rebuild
|
|
||||||
|
|
||||||
**Feature Branch**: `226-astrodeck-inventory-planning`
|
|
||||||
**Created**: 2026-04-22
|
|
||||||
**Status**: Draft
|
|
||||||
**Input**: User description: "Spec 226 - apps/website AstroDeck Inventory for Strict Rebuild Planning"
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
|
||||||
|
|
||||||
- **Problem**: Das Team kann AstroDeck-Konformitat bei der Website-Re-Planung nicht belastbar nachweisen, weil ein systematisches Primitive-Inventar fehlt.
|
|
||||||
- **Today's failure**: AstroDeck wird unscharf referenziert, Kandidaten werden opportunistisch gewahlt, und wichtige oder unpassende Primitives bleiben unsichtbar.
|
|
||||||
- **User-visible improvement**: Folge-Specs und Rebuild-Tasks werden konsistent, nachvollziehbar und auditierbar, weil jede Auswahl auf dokumentierten Kandidaten basiert.
|
|
||||||
- **Smallest enterprise-capable version**: Ein vollstandiges, referenzierbares Inventory fur Pages, Sections und Components mit einheitlicher Klassifikation, Eignung und Risiko-Sichtbarkeit.
|
|
||||||
- **Explicit non-goals**: Keine finale Primitive-Auswahl je TenantAtlas-Seite, keine Umsetzung von UI-Seiten, keine Copy-Arbeit, keine Plattform- oder Backend-Themen.
|
|
||||||
- **Permanent complexity imported**: Neue Inventar-Taxonomie (Primitive Class, Suitability Class, Relevance, Markierungen) und Pflegeaufwand fur die Inventardokumentation.
|
|
||||||
- **Why now**: Nach dem AstroDeck-Bindungsentscheid muss vor jedem weiteren Mapping die verfugbare Primitive-Landschaft explizit gemacht werden, sonst droht Greenfield-Drift.
|
|
||||||
- **Why not local**: Eine schmale lokale Notiz lost weder die Vollstandigkeitsanforderung noch die Wiederverwendbarkeit fur Folge-Specs und Task-Planung.
|
|
||||||
- **Approval class**: Core Enterprise
|
|
||||||
- **Red flags triggered**: Scope creep in Mapping-Phase; verdeckte Ausnahmen ohne Inventarspur.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitat: 1 | Produktnahe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
|
||||||
|
|
||||||
- **Scope**: workspace
|
|
||||||
- **Primary Routes**: Keine runtime-Route-Anderung; betroffen sind Website-Rebuild-Planungsartefakte fur `apps/website`.
|
|
||||||
- **Data Ownership**: Repository-Dokumentation auf Workspace-Ebene; keine tenant-owned Records.
|
|
||||||
- **RBAC**: N/A - keine Laufzeit- oder Berechtigungsanderung.
|
|
||||||
|
|
||||||
For canonical-view specs, the spec MUST define:
|
|
||||||
|
|
||||||
- **Default filter behavior when tenant-context is active**: N/A
|
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: N/A
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
|
||||||
|
|
||||||
N/A - no operator-facing surface change
|
|
||||||
|
|
||||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| AstroDeck inventory documentation for `apps/website` | no | N/A | none | none | no | `N/A - planning artifact only` |
|
|
||||||
|
|
||||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
N/A - no operator-facing surface change
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
N/A - no operator-facing surface change
|
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
N/A - no operator-facing surface change
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: no
|
|
||||||
- **New persisted entity/table/artifact?**: no
|
|
||||||
- **New abstraction?**: no
|
|
||||||
- **New enum/state/reason family?**: no
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: yes (Dokumentations-Taxonomie fur Inventarbewertung)
|
|
||||||
- **Current operator problem**: Teams konnen Rebuild-Entscheidungen nicht konsistent begrunden, weil AstroDeck-Kandidaten nicht vollstandig sichtbar sind.
|
|
||||||
- **Existing structure is insufficient because**: Bestehende Spezifikationen beschreiben Ziele und Seiten, aber nicht die vollstandige Primitive-Ausgangslage.
|
|
||||||
- **Narrowest correct implementation**: Ein verpflichtendes Inventar mit klaren Pflichtattributen, Eignungsklassen und Markierungen ohne Implementierungsentscheidungen.
|
|
||||||
- **Ownership cost**: Laufender Aufwand fur Pflege und Aktualisierung des Inventars vor Folge-Mappings.
|
|
||||||
- **Alternative intentionally rejected**: Ad-hoc Mapping ohne Inventar, weil es zu intransparenter Auswahl und hoher Rework-Wahrscheinlichkeit fuhrt.
|
|
||||||
- **Release truth**: Current-release truth
|
|
||||||
|
|
||||||
### 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**: N/A (Spezifikations- und Planungsarbeit ohne Runtime-Anderung)
|
|
||||||
- **Validation lane(s)**: N/A
|
|
||||||
- **Why this classification and these lanes are sufficient**: Die Anderung erzeugt keine ausfuhrbare Laufzeitlogik; Qualitat wird uber Spezifikationsvollstandigkeit und Checkliste validiert.
|
|
||||||
- **New or expanded test families**: none
|
|
||||||
- **Fixture / helper cost impact**: none
|
|
||||||
- **Heavy-family visibility / justification**: none
|
|
||||||
- **Special surface test profile**: N/A
|
|
||||||
- **Standard-native relief or required special coverage**: ordinary feature coverage only
|
|
||||||
- **Reviewer handoff**: Reviewer bestatigen Vollstandigkeit des Inventarrahmens, fehlende Implementierungsdetails und messbare Erfolgskriterien.
|
|
||||||
- **Budget / baseline / trend impact**: none
|
|
||||||
- **Escalation needed**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Planned validation commands**: none (Dokumentenreview)
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Vollstandiges Primitive-Inventar erstellen (Priority: P1)
|
|
||||||
|
|
||||||
Als Planungsverantwortliche Person fur den Website-Rebuild will ich eine vollstandige Liste der verfugbaren AstroDeck Pages, Sections und Components haben, damit ich jede nachgelagerte Mapping-Entscheidung auf nachweisbarer Verfugbarkeit basieren kann.
|
|
||||||
|
|
||||||
**Why this priority**: Ohne Vollstandigkeit ist jede weitere Re-Planung fachlich unsicher und nicht AstroDeck-konform.
|
|
||||||
|
|
||||||
**Independent Test**: Kann eigenstandig gepruft werden, indem die drei Inventarlisten vorliegen und jeder Eintrag die Pflichtattribute enthalt.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** eine AstroDeck-basierte Re-Planung startet, **When** das Inventar erstellt wird, **Then** existieren getrennte und dokumentierte Listen fur Pages, Sections und Components.
|
|
||||||
2. **Given** ein Inventareintrag wird gepruft, **When** Pflichtattribute kontrolliert werden, **Then** sind Identifier, Primitive Class, Functional Role, Default Semantics, Default Visual Character, TenantAtlas Relevance und Suitability Class vorhanden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Kandidaten und Risiken sichtbar machen (Priority: P1)
|
|
||||||
|
|
||||||
Als Spezifikationsverantwortliche Person will ich starke, anpassbare, schwache und ungeeignete Kandidaten inklusive Risiko-Markierungen sehen, damit die Folge-Specs transparent begrundete Auswahlentscheidungen treffen konnen.
|
|
||||||
|
|
||||||
**Why this priority**: Sichtbarkeit von Risiken und Nicht-Kandidaten verhindert verzerrte Auswahl und verdeckte Auslassungen.
|
|
||||||
|
|
||||||
**Independent Test**: Kann eigenstandig gepruft werden, indem die Suitability-Distribution und Risiko-Markierungen im Inventar nachvollziehbar ausgewiesen sind.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** das Inventar liegt vor, **When** die Eignung gepruft wird, **Then** jeder Eintrag ist einer Klasse A bis D zugeordnet.
|
|
||||||
2. **Given** semantisch oder visuell riskante Primitives existieren, **When** das Inventar gelesen wird, **Then** diese sind explizit markiert statt stillschweigend entfernt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Folge-Specs belastbar vorbereiten (Priority: P3)
|
|
||||||
|
|
||||||
Als Autorin oder Autor nachgelagerter Mapping-Specs will ich auf das Inventory referenzieren konnen, damit neue Tasks und Ausnahmen konsistent, wiederholbar und auditierbar geplant werden.
|
|
||||||
|
|
||||||
**Why this priority**: Der Nutzen des Inventars entsteht erst, wenn Folge-Specs es als verpflichtende Grundlage verwenden.
|
|
||||||
|
|
||||||
**Independent Test**: Kann eigenstandig gepruft werden, indem Folge-Specs eindeutige Inventarreferenzen fur Auswahl- und Ausnahmeentscheidungen nutzen.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** eine Folge-Spec wird erstellt, **When** Primitive-Mapping dokumentiert wird, **Then** die Entscheidung verweist auf konkrete Inventareintrage und deren Eignung.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- Was passiert, wenn ein AstroDeck-Primitive mehrdeutig zwischen Section und Component einzuordnen ist? Es MUSS eine klare Primarklasse dokumentiert und die Mehrdeutigkeit im Eintrag vermerkt werden.
|
|
||||||
- Was passiert, wenn eine vermeintlich irrelevante Demo-Seite spater als Vorlage nutzbar ist? Sie MUSS trotzdem inventarisiert werden und darf nicht durch vorzeitiges Filtering verloren gehen.
|
|
||||||
- Was passiert, wenn in kurzer Zeit neue AstroDeck-Primitives hinzugefugt werden? Das Inventar MUSS vor nachsten Mapping-Entscheidungen aktualisiert werden.
|
|
||||||
|
|
||||||
### Assumptions & Dependencies
|
|
||||||
|
|
||||||
- Die AstroDeck-Bindung fur den Website-Rebuild gilt bereits als gesetzt (Referenz: vorangehender Bindungsentscheid).
|
|
||||||
- Folge-Specs fur Mapping und Task-Planung konsumieren das Inventory als verpflichtende Eingabe.
|
|
||||||
- Inventarisierung bezieht sich strikt auf den Scope `apps/website` und schlieBt Plattform-Surfaces aus.
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
**Constitution alignment (required):** Nicht anwendbar, da keine Microsoft-Graph-Aufrufe, keine Runtime-Mutation und keine long-running Operations eingefuhrt werden.
|
|
||||||
|
|
||||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** Nur Dokumentations-Taxonomie; keine neue Laufzeitpersistenz, keine Runtime-Abstraktionsschicht, keine neuen produktiven Zustandsmaschinen.
|
|
||||||
|
|
||||||
**Constitution alignment (TEST-GOV-001):** Runtime-Tests nicht erforderlich; der Nachweis erfolgt uber Vollstandigkeit, Eindeutigkeit und Messbarkeit der Spezifikationsartefakte.
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (RBAC-UX):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (OPS-EX-AUTH-001):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (BADGE-001):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (DECIDE-001):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (OPSURF-001):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (Filament Action Surfaces):** N/A
|
|
||||||
|
|
||||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** N/A
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR-001**: Das System der Rebuild-Planung MUSS vor jeder AstroDeck-basierten Re-Planung von `apps/website` ein dokumentiertes Inventory fur Pages, Sections und Components verlangen. Das Inventory MUSS als strukturierte, repository-referenzierbare Dokumentation vorliegen; rein implizites Wissen oder unstrukturierte Screenshots sind ausgeschlossen.
|
|
||||||
- **FR-002**: Das Inventory MUSS fur jeden Eintrag mindestens Identifier, Primitive Class, Functional Role, Default Semantics, Default Visual Character, TenantAtlas Relevance und Suitability Class enthalten.
|
|
||||||
- **FR-003**: Das Inventory MUSS Pages, Sections und Components als getrennte Klassen dokumentieren und als eigenstandige Listen auswertbar machen.
|
|
||||||
- **FR-004**: Das Inventory MUSS pro Eintrag eine vorlaufige Eignung in Klasse A, B, C oder D enthalten.
|
|
||||||
- **FR-005**: Das Inventory MUSS auch ungeeignete, risikoreiche oder demo-only Primitives sichtbar halten und darf diese nicht stillschweigend auslassen.
|
|
||||||
- **FR-006**: Das Inventory MUSS TenantAtlas-Relevanz pro Eintrag als hohe, mittlere, geringe oder keine Relevanz erfassen.
|
|
||||||
- **FR-007**: Das Inventory MUSS die Candidate Visibility fur Homepage, Hero, Product, Trust, Changelog, Contact/Demo, Navigation und Footer explizit ausweisbar machen.
|
|
||||||
- **FR-008**: Das Inventory MUSS eine dokumentierte Bewertungslogik enthalten, die Struktur, Semantik, Rework-Aufwand, Risiko und Reuse-Potenzial je Primitive berucksichtigt.
|
|
||||||
- **FR-009**: Folge-Specs fur Mapping und Task-Planung MUSSEN auf das Inventory als verpflichtende Referenz verweisen.
|
|
||||||
|
|
||||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
|
||||||
|
|
||||||
N/A - no Filament surface change.
|
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
|
||||||
|
|
||||||
- **Inventory Entry**: Ein dokumentierter AstroDeck-Primitive-Eintrag mit Pflichtattributen, Eignungsklasse, Relevanz und optionalen Markierungen.
|
|
||||||
- **Primitive Class**: Kategorie eines Inventareintrags (`Page`, `Section`, `Component`) zur strukturierten Trennung und Auswertung.
|
|
||||||
- **Suitability Class**: Vorlaufige Eignungsstufe (`A`, `B`, `C`, `D`) fur TenantAtlas-Nutzbarkeit.
|
|
||||||
- **Marker Set**: Optionales Tag-Set pro Eintrag (z. B. `tenantatlas-likely`, `visual-risk`, `demo-only`) zur besseren Mapping-Qualitat.
|
|
||||||
|
|
||||||
## Success Criteria *(mandatory)*
|
|
||||||
|
|
||||||
### Measurable Outcomes
|
|
||||||
|
|
||||||
- **SC-001**: 100% der im Scope identifizierten AstroDeck-Primitives sind in genau einer der drei Inventarklassen (`Page`, `Section`, `Component`) dokumentiert.
|
|
||||||
- **SC-002**: 100% der Inventareintrage enthalten alle Pflichtattribute gemaB FR-002 ohne fehlende Felder.
|
|
||||||
- **SC-003**: 100% der Inventareintrage enthalten eine Suitability Class (`A` bis `D`) und eine TenantAtlas-Relevanzstufe.
|
|
||||||
- **SC-004**: Fur alle definierten Kernsurfaces (Homepage, Hero, Product, Trust, Changelog, Contact/Demo, Navigation, Footer) existiert sichtbare Kandidatenzuordnung oder explizite Nichtverfugbarkeitsnotiz.
|
|
||||||
- **SC-005**: Jede nachgelagerte Mapping-Spec referenziert mindestens einen konkreten Inventareintrag als Entscheidungsgrundlage. *(Forward-governance rule: verified in downstream mapping specs, not a done-criterion for Spec 226 itself.)*
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user